Your JavaScript object, Java record, and PostgreSQL row do not travel through a network as-is. A network moves bytes. Serialization turns an in-memory value into a transferable representation; deserialization turns that representation back into a value the receiving program can use.
This is easy to overlook because a framework does so much of it for us. A frontend calls fetch(), an API receives req.body, and it can feel like an object teleported between machines. It did not. Both sides agreed on a format, usually JSON, and each side did some translating.
The same idea appears everywhere
HTTP is the familiar case, but the boundary might also be a queue, cache, file, database, WebSocket, or a call to another service. Once data crosses a process boundary, memory references stop meaning anything. The other process needs bytes plus an agreed interpretation.
For example, this JavaScript value is not JSON yet:
const command = {
name: "Launch plan",
dueAt: new Date("2026-10-30T09:00:00Z"),
labels: new Set(["important", "customer"]),
};After JSON.stringify, the date becomes a string, and a Set does not automatically become the list we probably intended. Functions, undefined, and class methods are not portable data either. Serialization makes these choices visible.
{
"name": "Launch plan",
"dueAt": "2026-10-30T09:00:00.000Z",
"labels": ["important", "customer"]
}That is why an API contract matters. It says what the wire representation is, not only what a TypeScript interface happens to look like in one repository.
JSON is simple, with a few sharp edges
JSON is popular because people can read it, nearly every language supports it, and it works well for ordinary API payloads. It has objects, arrays, strings, numbers, booleans, and null.
It does not have a native date, integer, decimal, undefined, NaN, bigint, or binary type. So make conventions explicit:
- Send timestamps in ISO-8601 with a timezone:
2026-10-30T09:00:00Z. - Send money as a decimal string or a smallest-unit integer, not a floating-point number.
- Decide whether an absent field and an explicit
nullmean different things. - Use strings for identifiers that may exceed JavaScript’s safe integer range.
The money point is practical, not academic. In many runtimes, 0.1 + 0.2 is not exactly 0.3. A checkout total should never depend on a rounding accident.
Parse first, trust later
Deserializing JSON answers a narrow question: “is this valid JSON syntax?” It does not establish that the data is safe or useful.
const raw = JSON.parse(body);
// raw might be an array, null, or { name: 17, role: "admin" }.The next note covers validation in detail, but the boundary is worth naming now:
bytes -> syntactically valid JSON -> expected shape -> valid commandDo not skip the middle stages because a static type says CreateProjectRequest. Types disappear at runtime. A caller can be an old mobile app, curl, an attacker, or a perfectly ordinary browser extension.
A response is also a contract
Serialization happens on the way out too. Resist returning database objects directly. They often include fields a client should never see: password hashes, internal flags, audit notes, or relationships large enough to turn one request into fifty queries.
Build a response deliberately:
function projectResponse(project: Project) {
return {
id: project.id,
name: project.name,
status: project.status,
createdAt: project.createdAt.toISOString(),
};
}This may seem repetitive on day one. On day two, it gives you a safe place to rename a database column, hide an implementation detail, or preserve compatibility for clients.
Text formats and binary formats
JSON is not the only choice.
| Format | Good fit | Trade-off |
|---|---|---|
| JSON | Public HTTP APIs, browser clients, debugging | Larger payloads; types are limited |
| XML | Legacy integrations, document-heavy standards | Verbose |
| YAML | Human-authored configuration | Avoid for untrusted API input; parsing rules can surprise |
| Protocol Buffers / Avro | High-volume internal services, event streams | Needs schemas and generated tooling |
Binary formats are often smaller and faster to decode. They earn their complexity when a system moves a lot of structured data between services or through a queue. For a first CRUD API, readable JSON is usually the better trade.
Compatibility: the quiet part of API design
Once another program consumes a representation, changes have a cost. Adding an optional field is normally safe. Removing a field, changing a string to a number, or redefining the meaning of null can break clients that were working yesterday.
The safest evolution pattern is usually:
- add a new optional field or endpoint;
- let callers migrate while both forms work;
- measure usage of the old form;
- retire it on a communicated date.
This matters just as much for queue messages as for HTTP. A message may wait for hours before an older worker reads it.
A quick desk test
When you design a payload, write down one request and one response exactly as bytes-on-the-wire JSON. Then ask:
- Could an older client ignore a field it does not know?
- Does every date say which timezone it is in?
- Is any sensitive value accidentally being returned?
- Can a number lose precision in a browser?
- Does
nullhave a documented meaning?
That small exercise catches more integration bugs than arguing about the perfect serializer library. The job is not merely to convert objects; it is to make an agreement between independent programs clear and durable.
Three decisions worth writing down
Some payload choices look tiny until two clients disagree about them.
Absent versus null. In a create request, an absent description might mean “use the default”; "description": null might mean “there is intentionally no description.” In a partial update, an absent field often means “leave it alone,” while null may mean “clear it.” Pick the meaning per endpoint and test it.
Dates. 2026-11-03 is a calendar date. 2026-11-03T09:00:00Z is an instant. A due date for a task may be the former; an audit event is the latter. Mixing them is how a task due on Monday appears on Sunday for someone in another timezone.
Names. Public JSON can use camelCase while PostgreSQL columns use snake_case. That is fine if a mapper owns the translation. Letting naming styles leak randomly between layers creates a contract that is awkward to change later.
The guiding idea is modest: make the wire format a deliberate public shape. Then a change inside one service does not automatically become a breaking change for every caller.