Operationscompressiongzipbrotlicontent-encodingbandwidth

Compression: Cheaper Bytes, Not Fewer

gzip or brotli shrinks JSON 5–10× for a CPU price paid on every request. The trade inverts on small payloads, already-compressed data and CPU-bound services — and the negotiation headers are contract clauses, not transport trivia.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
For this endpoint's payload sizes, consumers and traffic, does trading CPU for bandwidth pay — and what does the contract promise about negotiation?
Consumers
Bandwidth-bound clients (mobile apps, browsers on slow links, cross-region callers) that want the 5–10× transfer win, and the operators of both endpoints who pay the CPU for it on every single request.
The promise
A well-specified compression clause negotiates honestly (`Accept-Encoding` in, `Content-Encoding` out), never sends an encoding the client did not offer, and is applied where the math wins rather than everywhere by default.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

The trade, with numbers

JSON compresses embarrassingly well: repeated keys, quoted strings and whitespace give gzip a typical 5–10× ratio on API responses (brotli a little better at higher CPU). A 200KB response becomes ~30KB — on a 5 Mbps mobile link that is ~320ms of transfer collapsing to ~50ms, per request, for every consumer at once. For bandwidth-bound consumers this is the cheapest large win in the whole performance toolbox: no contract shape changes, no consumer code beyond standard HTTP support.

The price is CPU on both ends, paid per request. gzip at a mid level (6) compresses very roughly 50–100 MB/s per core; a service pushing 1,000 RPS of 100KB responses is asking one to two cores to do nothing but deflate. Decompression is ~5–10× cheaper, so the client's share is small — the provider carries the bill. That asymmetry is why the decision is per-endpoint and per-deployment: the same setting that is obviously right for a public mobile API is a pure loss on a CPU-bound internal service whose 3KB responses cross a datacenter switch.

Level tuning is a real dial, not a detail: gzip 1 gets most of the ratio for a third of the CPU of gzip 9, and for dynamic responses generated per-request, the last few percent of ratio are almost never worth the cores. Static or cacheable bodies invert this — compress once at maximum, serve many times (see Caching as a Contract Clause).

  • JSON ratio: 5–10× with gzip; brotli ~10–20% smaller at noticeably higher compression CPU.
  • Where it wins: payloads over ~1KB, links slower than the datacenter, high-latency consumers, egress you pay for.
  • Where it inverts: sub-1KB bodies (headers and framing dominate; the deflate header can exceed the savings), already-compressed content (JPEG, MP4, ZIP — recompression burns CPU for ~0% gain), and services whose bottleneck is already CPU.
  • Who pays: compression is ~5–10× the CPU of decompression — the provider funds the consumer's bandwidth win.

Negotiation is part of the contract

Compression on the web is negotiated, and the negotiation is a promise like any other. The client lists what it can decode in Accept-Encoding; the server picks one and declares it in Content-Encoding, or sends identity if there is no overlap. Two contract sins follow from skipping this. Sending compressed bytes without a correct Content-Encoding — or to a client that never offered it — hands the consumer garbage it will fail to parse with an error that looks like corruption. And compressing while ignoring Vary: Accept-Encoding poisons shared caches: a cache that stores the gzipped body and serves it to a client that only speaks identity has broken that client with a 200.

Document the behavior where consumers read: which encodings the API supports, whether large *request* bodies may be compressed (Content-Encoding on the request is rarer and needs explicit support and limits — a compressed request is also a decompression-bomb vector, so cap the expanded size), and the fact that Content-Length, when present, describes the compressed bytes on the wire. SDKs should negotiate by default so consumers get the win without reading any of this (see SDK Design: The Contract's User Interface).

Honest negotiation: the client offers, the server declares, caches are warned
Request
GET /projects/42/tasks?limit=50 HTTP/1.1
Host: api.example.com
Accept-Encoding: br, gzip
Authorization: Bearer <token>
Response
HTTP/1.1 200 OK
Content-Type: application/json
Content-Encoding: br
Vary: Accept-Encoding
Content-Length: 6214

<6,214 bytes of brotli — a 41KB body on the wire at 6.2KB>

Placement, streaming and one security caveat

Where compression runs matters operationally. Terminating it at the gateway or CDN centralizes the CPU and the configuration (one place to set levels, minimum sizes and content-type filters) and keeps application pods spending cores on application work (see The Gateway as Policy Boundary); compressing in the service keeps the bytes small across every internal hop, which matters when hops cross zones you pay for. Most stacks land on: edge compresses responses to the public internet, internal hops stay identity unless a cross-region link says otherwise.

Two edge cases earn attention. Streaming: compression buffers, and a naive configuration will hold your carefully-flushed Server-Sent Events events or chunked stream until its window fills — streaming endpoints need compression disabled or explicitly flush-aware, or your real-time API quietly becomes a batch one (see Streaming APIs: Partial Data as a Contract). Security: compressing responses that mix attacker-influenced input with secrets enables BREACH-style side channels, where an attacker measures compressed sizes to extract the secret. The practical rule for APIs: fine to compress; do not reflect attacker-controlled strings alongside secrets (CSRF tokens, session material) in the same compressed body.

Should this endpoint compress?
SituationCompress?Why
Public JSON API, responses 10–500KBYes — gzip/brotli at moderate levelBandwidth-bound consumers; 5–10× transfer win dwarfs the CPU
Responses under ~1KBNo (set a minimum-size threshold)Framing overhead eats the savings; pure CPU loss
File/media passthrough (JPEG, ZIP, video)No — filter by content typeAlready compressed; recompression is CPU for ~0%
Internal datacenter RPC, small payloadsUsually noBandwidth is not the bottleneck; CPU and latency are
SSE / chunked streamingOnly flush-aware, often not at allBuffering breaks the real-time contract
Static or cacheable bodiesYes — precompress at max levelCompress once, serve many; ratio is free at request time

Key points

  • Compression trades provider CPU for everyone's bandwidth: 5–10× on JSON, paid for at ~50–100 MB/s per core at moderate gzip levels.
  • The trade inverts on small payloads, already-compressed content, and CPU-bound services — set minimum-size and content-type filters, not a blanket "on".
  • Negotiation is contract: never send an encoding the client did not Accept-Encoding, always declare Content-Encoding, always Vary: Accept-Encoding for caches.
  • Compression level is a dial: low levels for per-request dynamic bodies, maximum for precompressed static ones.
  • Streaming endpoints and compression conflict by default — buffering silently converts real-time to batch.
  • Compression divides transfer cost only; serialization, memory and parse still scale with the uncompressed size (see Payload Size: 20KB, 200KB, 5MB).

Follow the failure

How the contract fails or gets misused, hop by hop — and what it costs when it completes.

  1. 1
    Team → gateway: flips compression on globally — every content type, every size, level 9 — because bandwidth graphs looked bad.
  2. 2
    Gateway → fleet: CPU per request jumps; the sub-1KB health and status endpoints now cost more than they did, for larger effective wire size.
  3. 3
    Gateway → media endpoints: JPEGs and export ZIPs are recompressed for ~0% gain; p99 latency on downloads climbs.
  4. 4
    Gateway → SSE endpoint: the compressor buffers event flushes; the "live" progress feed arrives in 8KB lumps, and the consumer files a bug against the wrong service.
  5. 5
    Cache → old client: a shared cache stores a gzipped body without Vary: Accept-Encoding and serves it to an identity-only client, which fails parsing a 200 OK.
What breaks
  • CPU-bound services lose real capacity to compressing bytes nobody was waiting for; autoscaling hides it as cost.
  • Real-time contracts silently degrade: buffered SSE/streaming looks like an application bug and burns debugging time two teams away from the config.
  • Mis-negotiated or cache-poisoned encodings hand clients unparseable bodies with success status codes — failures that dashboards count as wins.

Design, observe, evolve

A contract decision is incomplete until you know how you would notice it failing and how it changes later.

Design the contract
  • • Compress by policy, not blanket: minimum size (~1KB), content-type allowlist (text/JSON), moderate level for dynamic bodies, maximum for precompressed static ones.
  • • Honor negotiation strictly and emit `Vary: Accept-Encoding` on every compressible route; treat a mismatch as a bug of contract severity.
  • • Exempt or flush-configure streaming endpoints explicitly, and document the exemption next to the streaming contract.
  • • Cap decompressed request-body size if you accept compressed requests — expansion limits are your defense against decompression bombs.
Observe in production
  • • Compression ratio and compression CPU per endpoint: a ratio near 1.0 flags already-compressed content burning cores.
  • • Bytes-on-wire vs bytes-uncompressed per route confirms the win is real where you pay for egress.
  • • Time-to-first-event on streaming routes — the canary that catches a buffering compressor before consumers do.
Evolve without breaking
  • • New encodings (br, zstd) add compatibly through negotiation: clients that offer them benefit, clients that do not keep gzip or identity.
  • • Changing thresholds or levels is invisible to correct clients — which is the test: any consumer that breaks when compression toggles was depending on an encoding you never promised.
  • • Moving compression between service and edge is an internal re-plumbing as long as the negotiated behavior at the public boundary stays identical.
What it costs
  • • Every compressed response spends provider CPU to save consumer bandwidth — a subsidy that is correct for public mobile traffic and wasteful inside a rack.
  • • Policy filters (size, type, route) are configuration that drifts; the failure modes are quiet (wasted CPU) or confusing (buffered streams), not loud.
  • • Precompressing static bodies duplicates artifacts per encoding and complicates the build for a ratio win only high-traffic routes will notice.

Misconceptions

Claim
“Always enable compression — it is free bandwidth.”
Reality
It is bandwidth bought with CPU on every request. Below ~1KB and on already-compressed content the purchase is a pure loss, and on CPU-bound services it converts a bandwidth non-problem into a capacity problem.
Claim
“Compression fixes our large-payload problem.”
Reality
It divides transfer cost only. Serialization, the parsed-object memory multiplier and client parse time all scale with the uncompressed size — a bounded response shape fixes what compression cannot (see Payload Size: 20KB, 200KB, 5MB).
Claim
“If the client sends Accept-Encoding: gzip, we must compress.”
Reality
Accept-Encoding is permission, not obligation. The server legitimately sends identity when the body is tiny, already compressed, or streaming — the only obligation is to declare whatever it did send.