SerializationGENERALLANGUAGE-SPECIFICPROTOCOL-SPECIFIC

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.

What actually happensHow to build it

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.

The question

What actually happens between a handler returning an object and a client reading a field?

The requirement

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.

The obvious build

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.

Why it breaks

The total was a fixed-point decimal in the database, became a binary float in the runtime, and the client renders 19.989999999999998.

How it breaks in production
  • The total was a fixed-point decimal in the database, became a binary float in the runtime, and the client renders 19.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 undefined vanishes 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.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

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, undefined and NaN are 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-Type tells 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.

runs your codethe hidden N+1six JSON typescharsetheaders already sentHandler returns objectEncoder walks the graphGetters / toJSON hooksText in format vocabularyLazy relation -> queryUTF-8 bytes (+ compression)Socket write
UserLLMAgentToolDataDecisionHumanGuardrail

What JSON cannot say

LANGUAGE-SPECIFICThe "what naive encoding does" column is the JavaScript/Python common case. A statically typed encoder (Go's 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 typeWhat naive encoding doesWhat to send instead
Money / fixed-point decimalBecomes a binary float; cents driftInteger minor units, or a decimal string the client parses with a decimal type
64-bit integer idBecomes a JSON number; a JS client rounds above 2^53A string
TimestampWhatever toString or the driver picked — local time, epoch millis, or a date-only valueRFC 3339 in UTC, one format everywhere
Binary blobA byte-array object, or mojibakeBase64 string, or a URL to fetch it (Serving Files)
EnumThe database ordinal, which renumbers when someone reorders the enumA stable string constant, versioned like any contract value
Absent valueKey disappears (JS) or becomes null (most others)Choose one, document it, apply it uniformly
Set / MapAn empty object, silentlyAn array, or an object of explicit key-value pairs
NaN / InfinityInvalid JSON in some runtimes, null in othersReject it before encoding — it is a bug upstream
CycleThrows, or produces an enormous payloadA 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.

Which wire format?

Who reads these bytes, how often, and over what link?

JSON

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.

JSON + a published schema

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.

Binary with a schema (Protobuf, Avro, Thrift)

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.

Schemaless binary (MessagePack, CBOR)

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.

Streaming lines (NDJSON, CSV)

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-Type explicitly, 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

Failure modes
  • 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).
What can race
  • 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).
Security
  • 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.
Misreads
  • "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

How you see it in production
  • 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-Type actually sent on a sample of responses when debugging encoding complaints; it is frequently not the one the code intended.
What changes at 10x and 100x
  • 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.
What this costs
  • 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 curl and 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.stringify silently drops undefined and function-valued properties and throws on BigInt and on cycles; Python's json module raises on unknown types but by default emits bare NaN/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.

Securityxss-defense
Performancethroughput
Domains that do not exist yet
  • 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.