Compression middleware
Think of it like…vacuum-sealing parcels for shipping: smaller, cheaper, faster to deliver. But you never vacuum-seal anything with a return address visible through the wrap (cookies, auth headers, CSRF tokens), because a thief watching the loading dock could measure the bulge and figure out what's inside. That's the BREACH attack, and that's why the middleware skips compression on sensitive headers and small responses by default.
Daloy ships a focused compression slice: a first-party compression() middleware that uses the web-standard CompressionStream API instead of a Node-only compression package.
Key Recommendation for Compression middleware
Use compression when serving large text-based responses (HTML, JSON, SVG) directly to clients without an intermediate CDN or reverse proxy doing it for you. Do not compress small responses or payloads that are already compressed (like images or PDFs).
When & Where to Use
- ✓Serving large, text-heavy responses (HTML, JSON, XML, SVG) over 1KB in size.
- ✓Direct-to-origin API traffic that bypasses caching CDNs or API gateways.
- ✓Deploying to serverless environments (like AWS Lambda, Deno Deploy, or Cloudflare Workers) that do not compress origin responses by default.
When & Where NOT to Use
- ✗When a CDN (such as Cloudflare, Fastly, or Vercel Edge) or reverse proxy (NGINX) fronting the app is already configured to compress responses (doing it in the application wastefully consumes CPU).
- ✗For response payloads under 1KB, where compression overhead makes the response larger or yields negligible gains.
- ✗For binary or pre-compressed content types (PNG, JPEG, PDF, ZIP, WOFF2), which are already optimized.
What it compresses
The middleware negotiates br, gzip, and deflate from the request Accept-Encodingheader and the runtime codecs available through CompressionStream. Runtime support is probed once and cached. If the platform has no supported codec, the middleware becomes a silent no-op instead of breaking older runtimes.
The default minimumSize is 1024 bytes. Small responses are left alone, and Daloy also checks the compressed byte length after encoding. If compression made the payload larger, the original response is kept.
Memory bound for large responses
Compressing a response means buffering its bytes in memory first. To keep a single large (or unknown-and-growing) response from forcing unbounded heap growth, the middleware only compresses bodies up to maxCompressibleBytes, which defaults to 1_048_576(1 MiB). Anything larger is sent uncompressed rather than buffered. When a response declares a Content-Length above the cap it is skipped immediately, without buffering a single byte; streamed responses are read up to the cap and released untouched if they exceed it.
This is a deliberate trade-off: it spends bandwidth and latency on very large responses to protect the origin's memory. Large responses are usually already-compressed media (skipped anyway) or belong behind a CDN. If you serve large, highly compressible payloads from the origin and can afford the heap, raise the cap:
maxCompressibleBytes must be a positive integer no larger than 2**31 - 1, and must be greater than or equal to minimumSize; violating either is refused at construction.
Application vs. CDN or reverse proxy
A response only needs to be compressed once. Choose the layer closest to the client that reliably supports the encodings and content types you need:
- No CDN or reverse-proxy compression: register
compression()globally. Daloy's skip rules still decide whether each individual response should be compressed. - CDN or reverse proxy already compresses responses: let that layer handle compression so the application does not spend CPU compressing the same traffic at the origin.
Do not assume every hosting platform enables compression automatically. Send a request with Accept-Encoding: br, gzip and check the response for Content-Encoding. If some traffic can bypass the CDN or proxy, decide whether those direct origin responses also need application-level compression.
Security skip rules
Compression can become an oracle when secrets and attacker-controlled bytes share the same compressed response. Daloy keeps those guards built in rather than asking every app to remember the same list.
- onSendResponse readynegotiate br / gzip / deflate
- secretsSet-Cookie / Authorization / session cookie?skip, send identity
- not worth itBelow minimumSize or already encoded?skip, keep original
- safeCompress the bodyGET / HEAD, 2xx, text-like content
- Skips responses with
Set-Cookie. - Skips requests with
Authorization. - Skips requests carrying session, CSRF, XSRF,
__Host-, or__Secure-cookies. - Skips any response that already has
Content-Encoding. - Skips non-
GET/ non-HEADrequests and non-2xx responses. - Skips already-compressed content types such as images, video, audio, archives, fonts, WebAssembly, and PDFs.
image/svg+xmlis carved back in because it is XML text.
Cache and ETag behavior
Every response that reaches compression() gets Vary: Accept-Encoding appended (de-duplicated against any existing Varyvalue), even when Daloy decides not to compress that specific response. That keeps downstream caches keyed by the negotiation surface from the first response onward, so a cache can't serve a gzipped body to a client that only advertised identity.
If a compressed response already has a strong ETag such as "abc", Daloy downgrades it to W/"abc". The ETag was computed over the upstream body, not the compressed wire bytes, so a weak validator is the honest one, RFC 9110 §8.8.1 requires strong validators to be byte-equal to the representation on the wire, and the wire bytes change per encoding.
Interaction with etag()
compression() and etag() are safe to combine in either order. Both run as onSend hooks:
- If
etag()runs first it sets a strong ETag over the uncompressed body.compression()then encodes the body and downgrades the strong ETag to weak so the validator stays consistent with what actually leaves the server. - If
compression()runs first it encodes the body.etag()then hashes the already-compressed bytes, which is also valid, the strong tag still byte-matches the wire bytes the client receives.
Either way, conditional GETs using If-None-Match stay correct across br, gzip, deflate, and identity clients because the Vary: Accept-Encodingheader forces per-encoding cache keys. You don't have to manage the weak/strong downgrade yourself, even if you set ETag manually from a route handler, compression() performs the downgrade for you on the way out.
No compression level knob
CompressionStream uses the runtime default. Daloy refuses any compressLevel option at construction, including 6, because exposing the knob invites expensive level-9 compression for tiny byte savings under load.
Ordering
Register compression() after middleware that may add Set-Cookie, Content-Encoding, or ETag headers. Daloy runs onSend hooks in registration order, so the compression hook should see the final response headers before it decides whether to encode the body.