Serialization: Objects to Bytes
A response is bytes on a socket. The encoder decides which of your runtime's types survive the trip and which quietly change shape.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
What actually happens between a handler returning an object and a client reading a field?
The endpoint returns an order: its total, when it was placed, and its line items. The client is a browser today and a mobile app next quarter.
Return the object. res.json(order) turns it into JSON and writes it. Serialization is a one-line concern that has never needed thinking about, and for the first year it does not.
The total was a fixed-point decimal in the database, became a binary float in the runtime, and the client renders 19.989999999999998.
- The
totalwas a fixed-point decimal in the database, became a binary float in the runtime, and the client renders19.989999999999998. - A timestamp leaves as an ISO string and comes back as a plain string, so a round trip through your own API changes the type of your own field and nothing in the code says so.
- A field that is
undefinedvanishes rather than serializing as null: the key is absent, and a client written against "the key is always present" throws on a value it has never seen missing. - Someone attaches a parent backreference to an entity for convenience. The encoder walks into a cycle and the request fails with a structural error that names no route, no user and no field.
- A 64-bit id encodes as a JSON number, and a JavaScript client silently rounds it. Two different orders now resolve to the same id on the client and nothing errors anywhere.
What is actually happening
- Serialization is a graph walk. The encoder starts at the root value and visits every reachable property in order, emitting as it goes. Cost and output are proportional to the nodes it visits, not to the fields you meant to send (What Serialization Costs).
- The wire format has its own type system, and it is smaller than your language's. JSON offers objects, arrays, strings, numbers, booleans and null. Dates, decimals, binary, sets, maps, enums,
undefinedandNaNare all conventions encoded into one of those six. - Numbers are the sharp edge. A JSON number is a decimal literal with no declared precision, and most parsers land it in an IEEE-754 double. Money and 64-bit identifiers do not fit that shape safely; both are usually sent as strings.
- Hooks run during the walk.
toJSON(), custom encoders, replacer functions, ORM getters and computed properties all execute inside serialization, which means the shape a client sees can be decided in a file the handler never mentions. - The output is bytes, not a string. The encoded text is written in a character encoding — practically always UTF-8 — possibly compressed, possibly chunked.
Content-Typetells the client how to read it, and a wrong charset corrupts every non-ASCII name in the payload.
From object graph to bytes
The handler returns a value. Between there and the socket, the runtime walks that value, converts each node into the format's vocabulary, joins the result into text, encodes that text into bytes and hands the bytes to the transport. Every one of those steps can change what the client sees.
Drawing it once is worth more than it looks, because it locates two things engineers usually cannot place: where a lazy database query can sneak into a response, and why an error thrown late produces a 200 with a broken body.
What JSON cannot say
encoding/json, Jackson, System.Text.Json) fails earlier and louder on several of these rows, which is better, but the representation decision is still yours.Every awkward type below has a conventional encoding. The failure is never that no encoding exists — it is that two parts of one system chose different ones, and neither wrote it down.
Pick a row, pick a representation, and make it a rule the codebase enforces in one place. The alternative is that each endpoint picks separately and the client discovers the inconsistency.
| Runtime type | What naive encoding does | What to send instead |
|---|---|---|
| Money / fixed-point decimal | Becomes a binary float; cents drift | Integer minor units, or a decimal string the client parses with a decimal type |
| 64-bit integer id | Becomes a JSON number; a JS client rounds above 2^53 | A string |
| Timestamp | Whatever toString or the driver picked — local time, epoch millis, or a date-only value | RFC 3339 in UTC, one format everywhere |
| Binary blob | A byte-array object, or mojibake | Base64 string, or a URL to fetch it (Serving Files) |
| Enum | The database ordinal, which renumbers when someone reorders the enum | A stable string constant, versioned like any contract value |
| Absent value | Key disappears (JS) or becomes null (most others) | Choose one, document it, apply it uniformly |
| Set / Map | An empty object, silently | An array, or an object of explicit key-value pairs |
| NaN / Infinity | Invalid JSON in some runtimes, null in others | Reject it before encoding — it is a bug upstream |
| Cycle | Throws, or produces an enormous payload | A response type that has no backreferences (Three Models, Not One) |
Choosing a format is choosing a consumer
There is no fastest format, only a format matched to who reads it, how often, and over what link. A browser parses JSON natively and cannot parse your custom binary encoding without shipping a library. An internal service on the same network can parse anything and cares more about schema evolution than about readability.
The honest version of this decision is that JSON is the right default for public and browser-facing APIs and a defensible one internally, and that moving off it should be a response to a profile or a bandwidth bill, not to an article.
Who reads these bytes, how often, and over what link?
when Browser clients, public APIs, anything where a human debugging with curl is a common event.
cost Verbose; no schema unless you add one; the type losses in this lesson are yours to manage.
when You want generated clients and validated responses while keeping readability (OpenAPI: Describing the Contract, Not Designing It in API Design).
cost The schema is a second artefact that drifts from the code unless generated from it.
when High-volume internal traffic, strict schema evolution rules, or a real bandwidth constraint.
cost Unreadable on the wire; a build step; browser clients need a runtime library; schema registry becomes infrastructure you operate.
when You want smaller JSON-shaped payloads without adopting a schema toolchain.
cost Smaller, not structurally safer — the same type losses, now invisible in a log.
when Large exports and result sets the consumer processes incrementally.
cost Errors after the first line cannot be signalled by status code; the consumer must handle a truncated stream (Request Bodies and Streaming).
How to build it
Most important first.
- Serialize an explicit response type rather than whatever object happens to be in hand. That single decision prevents most of this module's failures (Three Models, Not One).
- Decide the wire representation of every awkward type once, write it down, and apply it everywhere: money as a decimal string or integer minor units, timestamps as RFC 3339 in UTC, binary as base64, enums as stable strings rather than ordinals.
- Make "absent" and "null" mean different things deliberately, and say which one your responses use. Omitting nulls halves some payloads and breaks clients that treat a missing key as an error (Response Contracts Are Not Database Rows in API Design owns the contract question).
- Set
Content-Typeexplicitly, including charset, and never let it be inferred from the first bytes of the body. - Pick the format from the consumer and the payload, not from fashion — a browser, a mobile client on a metered network and an internal service-to-service hop have genuinely different answers.
What can go wrong
- The encoder throws partway through a streamed response. The status line and headers are already on the wire, so the client receives a 200 with a truncated, unparseable body.
- A lazy ORM relation is touched by a getter during the walk, issuing one query per element from inside the encoder — an N+1 that no query log attributes to the handler (The N+1 Query Problem).
- A
toJSON()added on a shared entity for one endpoint changes every other endpoint that returns that entity. - Cycles: a bidirectional relation loaded eagerly turns a response into an infinite walk, or into a payload that is orders of magnitude larger than intended.
- A field added to an entity for internal use appears in a public response the moment it exists, because nothing enumerates what may be sent (Schema Leakage).
- If the object being serialized is shared mutable state — a cached entity, a module-level object, a singleton config — another request can mutate it mid-walk and the client receives a half-updated snapshot that never existed as a consistent value (Backend Races).
- An encoder that walks a whole entity sends every property that entity has, including ones added after the endpoint was written — password hashes, internal flags, soft-delete markers and foreign keys to other tenants (Schema Leakage).
- Error objects are objects. Serializing one directly ships stack traces, SQL fragments and connection strings to the caller (Not Leaking Your Internals).
- Values encoded into contexts with different escaping rules — HTML, a CSV opened in a spreadsheet, a log line — need that context's escaping, which JSON encoding does not provide (XSS Defense by Output Context in Security Engineering covers the output-encoding side).
- Response size is an oracle. If an endpoint returns more bytes when a record exists than when it does not, it discloses existence regardless of the status code.
- "JSON is human-readable, so it is the safe default." Readable is not lossless. JSON is a good default for browser clients precisely because they parse it natively, not because it preserves your types.
- "The framework handles serialization." It handles encoding. It does not decide which fields belong in a response, which is the part that becomes a contract.
- "We can change the response shape later, it is just JSON." Every field you emit is a field some client is already reading (Backward Compatibility: The Real Rules in API Design).
Operating it
- A histogram of response size per route. Payload growth is gradual, invisible in latency dashboards until it is not, and the single most useful serialization signal there is.
- A span around encoding in the request trace, so you can tell "the query was slow" from "the query was fine and we spent the time building the body" (Tracing From the Backend's Side).
- Count responses that ended with a write error after headers were sent — those are truncated bodies, and clients report them as corrupt data rather than as your error.
- Log the
Content-Typeactually sent on a sample of responses when debugging encoding complaints; it is frequently not the one the code intended.
- At 10x traffic, encoding cost is per-request CPU that nothing amortizes. It does not benefit from a bigger database or a warmer cache.
- At 100x, payload size dominates bandwidth cost and client parse time far more than encoder speed does. Sending fewer fields beats encoding the same fields faster.
- Large collections stop fitting comfortably in memory as a single encoded buffer, which forces either pagination (Pagination That Survives a Large Table) or streaming, and streaming changes how errors can be reported at all.
- Explicit response types cost mapping code and give you a place to forget a field. What you get back is a response shape that changes only when you change it.
- Binary formats reduce bytes and parse time and cost you the ability to debug with
curland read the payload in a log. - Omitting nulls shrinks payloads and makes the contract weaker — clients must now distinguish "not sent" from "not known".
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALThe graph-walk model and the "wire types are fewer than runtime types" problem hold for every language and every text format.
- LANGUAGE-SPECIFICThe lossiness differs by runtime: JavaScript's
JSON.stringifysilently dropsundefinedand function-valued properties and throws onBigIntand on cycles; Python'sjsonmodule raises on unknown types but by default emits bareNaN/Infinity, which is not valid JSON and breaks strict parsers. Same object, different corruption. - PROTOCOL-SPECIFICHTTP decides how the format is announced and negotiated —
Content-Type,Accept,Content-Encoding. Over a message queue or a gRPC channel there is no negotiation: the schema is agreed out of band and a mismatch is a deployment problem, not a 406.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.
- — Programming Languages & Runtime Internals — how a runtime represents numbers, strings and objects in memory, which is the real reason the wire type system cannot match yours.