DEV Community

minia2a
minia2a

Posted on Edited on Originally published at minia2a.uk

Your JSON body limit counts bytes the caller never sent

This content reflects minia2a as of its publication date and is kept for the record. Current model: x402 pay-per-call in USDC on Base only, 5 free trial calls per signed wallet, no credits and no top-up rail.

November 2026

A caller sends 64,000 bytes. Your server says the body is too large for a 100 KB limit. Nothing in the request is near 100 KB. Both statements are true, because the number the limit is applied to is not the number the caller sent.

This is a note about one hop that re-encodes a JSON body on its way through a stack, and about the two ways that hop lies to you: it inflates the body, and it changes the shape of the rejection. We hit it in our own stack — a Go gateway re-serialising a caller's body on its way to a Node sidecar — which is what made us measure it instead of estimate it. The measurement is worth keeping because the failure mode generalises to any service that receives JSON and forwards JSON.

1. Measure the inflation before believing it

Go's encoding/json escapes three characters by default — <, > and & — to the six-byte sequences \u003c, \u003e and \u0026. That is six bytes where one was. The default is deliberate: the output is safe to place inside HTML. The cost is paid by anyone whose payload is markup.

Measured with Go 1.26.5, marshalling the same wrapper ({"xml": "..."}) with two different string bodies of the same length:

raw 64,000 chars of "a"       → json.Marshal →  64,010 bytes
raw 64,000 chars of "<ab>"  → json.Marshal → 224,010 bytes   (+160,002)

per "<ab>": 4 raw bytes → 14 marshalled bytes (each bracket +5)
Enter fullscreen mode Exit fullscreen mode

So a body of angle brackets adds 2.5× its own size on the wire — the marshalled form is 3.5× the input — under the same JSON type and the same declared limit. The limit that fails is not 100 KB of input; it is about 28.5 KB of markup input:

100,000 / 3.5 ≈ 28,571 raw bytes of "<ab>" before the marshalled form crosses 100 KB
Enter fullscreen mode Exit fullscreen mode

That estimate is checkable against a live endpoint. Three calls, one signed trial wallet, same service, same wrapper:

20,000 chars of "a"      20,011 B → 200  served
25,000 chars of "<ab>"  25,011 B → 400  reached the handler, refused by its own depth guard
30,000 chars of "<ab>"  30,011 B → 413  HTML, rejected before the handler
Enter fullscreen mode Exit fullscreen mode

The 25 KB markup body crossed the hop and was judged by the service. The 30 KB one never got there. The ceiling sits between them, where the arithmetic puts it.

2. The rejection is usually not JSON

If the limit belongs to express.json({limit: '100kb'}) in a Node hop, the 413 comes from raw-body/body-parser, and Express's default error handler returns an HTML error page. The caller's client sees a non-JSON body from a JSON API. If the caller's retry logic keys on "parse error", it retries a request that can never succeed. If the caller's error handling keyed on error and code, it gets neither.

Two distinct defects, one cause: the hop decided the body was malformed before the service the caller was addressing ever saw it.

3. Three fixes, in the order I would try them

  1. Do not decode and re-encode. If a hop only routes a body, forward the bytes. Every decode/encode round trip is a chance to change both size and semantics (numbers become floats, large integers lose precision, key order changes), and a gateway that rewrites a payload it does not interpret is doing work that only creates failure modes.
  2. Turn the escaping off on the outbound encoder — enc := json.NewEncoder(w); enc.SetEscapeHTML(false) — when the destination is another JSON service rather than an HTML template. Do this per hop, not globally: the same payload rendered into a page still wants the escape.
  3. If you must keep a byte cap, return a JSON 413 that states the arithmetic. A caller can act on {"error":"body exceeds 100000 bytes after JSON escaping","received":224010,"limit":100000}. It cannot act on an HTML page that says "PayloadTooLargeError".

4. A black-box test for your own stack

You do not need to read anyone's source to find out whether this affects you. Send the same byte count twice, once as a plain filler and once as markup-heavy filler, and compare the status codes:

A: 64,000 "a" characters        → expect 200
B: 64,000 characters of "<ab>" → 413 means a hop re-encoded the body

If B fails and A succeeds, the limit is not on the caller's bytes.
Enter fullscreen mode Exit fullscreen mode

Run it at the edge and again at the inner service. If the edge passes and the inner one fails, the inflating hop is between them, and its config is the thing to read.

What this is really about

A size limit is only a sentence about bytes if you name the encoding it is applied to. "100 KB" at the TCP edge, "100 KB" after UTF-8 decoding, and "100 KB" after json.Marshal are three different limits with three different failure points, and only the first one is what the caller measured. When a service in the middle re-serialises without measuring the result, it silently converts a documented limit into an input-dependent one — and the documentation keeps claiming the first.

The same reasoning applies to any transformation on the path: pretty-printing, normalising, re-wrapping into an envelope. Each one changes the size the next limit sees. If the limit is a promise, the promise needs to name the form it applies to; if it names the form the caller sent, the middle hop must not change it.

All numbers above were measured on Go 1.26.5 with the standard library, not estimated. The 413-through-Express shape was observed on a stack with a Go gateway in front of a Node sidecar; the HTML body came from body-parser's default handler.


Originally published at minia2a.uk.

Top comments (0)