Skip to content

Response Compression

[middleware]
compression = true

That is the whole interface for most deployments. With it on, the HTML and JSON the application renders are encoded for clients that accept it.

It is off by default because something in front of the application often compresses already, and encoding a body twice is work nobody benefits from. Turn it on when nothing else is doing it.

The switch applies to what the application renders while a request waits:

Compressed Not compressed
WriteHTML, WriteHTMLPage, WriteHTMLChain WriteProblem
WriteHTMLFragment static assets, which carry their own precompressed sidecars
WriteAPI update refusals, for the same reason as WriteProblem
the streamed branch of a page with await boundaries
partial updates and live regions

A response that already has a Content-Encoding is left as it is. Nothing encodes twice.

WriteProblem is the deliberate exception. A problem document is a few hundred bytes built by hand on a path that must not fail, and every coding makes a body that small larger — the frame header alone outweighs anything there is to save. An encoder there would add a way to fail in exchange for nothing.

The same reasoning gives the update responses a floor rather than an exemption. A redraw, an action response, and a sequence tree are assembled before they are written, so their length is known, and one under 512 bytes goes out as it stands. The streamed responses cannot apply the floor — their length is not known when the frame has to be opened — so they encode from the first byte.

If you serve partial updates and something in front of the application is doing the compressing, check that it covers application/x-ndjson and application/json and not only text/html. A proxy compressing pages but not deltas leaves the delta larger than the page it replaces, which reverses the point of asking for one.

Two are offered, and zstd is tried first:

[middleware]
compression = true
compression_codings = ["zstd", "gzip"]

That is the default, so you only write the second line to change it. The order is the deployment’s, not the client’s: a q-value in Accept-Encoding says what the client can read, and which of two readable codings is worth spending CPU on is not the client’s judgement to make. A q=0 still excludes, because that half of the header is a statement about capability.

A coding left out of the list is not offered at all, even to a client asking for it, so the one key expresses removal as well as order. To turn compression off entirely, use compression = false rather than an empty list.

Leave the order alone unless you have measured your own traffic. zstd leads because it stays slightly ahead of gzip on ratio at the levels both run, and because a client already receiving zstd keeps receiving it. Reverse it only if your clients are overwhelmingly ones that would take gzip anyway and you would rather not link the zstd encoder at all — in which case the build tag below is the more direct answer.

gzip is what makes this list worth having. It is the one coding no browser, proxy, crawler, or command-line client lacks. Safari in particular advertises zstd only from Safari 26 on macOS Tahoe and Safari 26.3 on iOS, and that support comes from the operating system’s network stack rather than the browser, so it arrives at the speed of OS upgrades. Before gzip was offered here, those clients received the original bytes.

Brotli is not on the list. It belongs to the static asset build, where the encoder runs once at build time. Per request it is the wrong trade: at a level fast enough to serve, it costs roughly four times the CPU of zstd to remove another eight percent of the body, and its pure-Go encoder is worth about 795 KB of binary — three times the zstd one, in a framework that ships a build tag to remove that.

Vary: Accept-Encoding is set whether or not the body ends up encoded. This matters more than it looks: a cache that stored one representation must not hand it to a client that asked for another, and the header is what tells it so.

When a coding is selected:

  • Content-Encoding: zstd or gzip
  • no Content-Length — the encoder knows the length only after it closes
  • no ETag. The hash is readable only after Close, by which time the headers are long gone. Static assets get validators; dynamically rendered responses do not

A page with await boundaries commits its shell first and sends each region as it settles. Compression follows it there, and the encoder is flushed once per settled boundary so a completion reaches the browser instead of waiting in a buffer.

Flushing ends a block, and a block ended early compresses worse. A streamed page compresses worse than the same page buffered. That is the trade, and it is usually worth it — the first paint arrives sooner either way. It holds for both codings; nothing about it is specific to which one was negotiated.

There is a subtler cost. Per-boundary flushing makes the compressed length of each region observable on its own, which is a finer-grained oracle than one length for the whole page. If a boundary renders a secret next to input an attacker controls, do not stream it. Render that page buffered:

[html]
streaming = false

The same key is the workaround for a proxy that buffers encoded responses and defeats progressive delivery anyway.

Whether to encode, and in what order. Nothing else: no minimum size, no content-type list, and in particular no encoder level. A short response is encoded like a long one.

The level is fixed rather than exposed because it answers to a measured cliff that does not move between deployments. A body here is encoded while a request waits, so throughput is the scarce resource and ratio is the one that gives: gzip runs at level 1 and zstd at its fastest setting, which put both near thirty percent of the source at roughly 450 MB/s. Level 6 gzip would buy three more points of ratio for a third of the throughput, and level 9 for a ninth of it. The deep levels are not lost — the asset build spends them, where the cost lands on a machine that is not answering a request.

If you need the other knobs, the honest answer is that a reverse proxy or CDN in front of the application already has them, and it is the better place for this job. This switch exists for the deployment that has no such layer.

What the encoders cost when you do not use them

Section titled “What the encoders cost when you do not use them”

compression = false stops the encoders from running. It does not stop them from being compiled in — a runtime value cannot unlink code — so a build that terminates compression at a CDN still carries them.

That is 387 KB for the pair, stripped. It used to be worth a build tag: zstd once meant linking a decoder ten times the size of the encoder, and Popcorn Web published pw_nozstd and pw_nogzip to escape it. The encoder is its own package now, the decoder is gone, and the remaining figure is smaller than the question. Both tags were removed; a build that still passes one is not refused, it simply changes nothing.

So there is one switch, and it is the configuration one. Set compression = false where something in front of the application already compresses, and leave the binary alone.