← Recipes

阅读中文版本

Enable Response Compression Safely

Compress JSON and HTML with gzip, skip pre-compressed assets, keep Vary correct, and measure the transfer win instead of assuming it.

~20 minnodejsperformancehttp

Task

Cut transfer size for text responses while avoiding double compression, broken streaming, and cache variants that serve the wrong encoding.

When to use this

A list endpoint returns 400 KB of JSON per call, mobile clients wait on slow networks, and the CDN stores one variant for every client because encoding is not negotiated correctly.

Prerequisites

  • Node.js 22 with Express
  • compression 1.7 or newer
  • curl with --compressed support

Steps

1. Install and register the compression middleware above your routes.

pnpm add compression
import express from 'express';
import compression from 'compression';

const app = express();
app.use(compression({ threshold: 1024 }));
app.use('/api', router);

Expected output: Responses larger than 1 KB are sent with Content-Encoding: gzip when the client advertises support.

2. Exclude media and already-compressed files from the filter so bytes are not wasted on them.

app.use(compression({
  threshold: 1024,
  filter: (req, res) => {
    const type = String(res.getHeader('Content-Type') ?? '');
    if (/^(image|video|audio)\//.test(type)) return false;
    if (/(zip|gzip|br|woff2)$/.test(req.path)) return false;
    return compression.filter(req, res);
  },
}));

Expected output: PNG, MP4, ZIP, and WOFF2 responses are sent untouched; JSON and HTML are still compressed.

3. Keep Vary: Accept-Encoding on every compressed response so shared caches store separate variants.

app.use((req, res, next) => {
  res.set('Vary', 'Accept-Encoding');
  next();
});

Expected output: Cache keys differ between gzip-capable and identity clients, preventing one client from receiving the other's encoding.

4. Compare compressed and uncompressed transfer sizes.

curl -s -H 'Accept-Encoding: gzip' -o /dev/null -w 'gzip: %{size_download} bytes\n' http://localhost:3000/api/orders && curl -s -H 'Accept-Encoding: identity' -o /dev/null -w 'raw: %{size_download} bytes\n' http://localhost:3000/api/orders

Expected output: gzip: 18234 bytes and raw: 412880 bytes for the same payload.

Troubleshooting

Problem

The response arrives double-encoded and clients fail to parse it

Solution

The proxy and the application both compress. Disable compression at one layer, usually the proxy, and keep a single owner of Content-Encoding.

Problem

Server-sent events stop arriving incrementally

Solution

Exclude text/event-stream from compression; buffering for compression defeats streaming semantics.

Limitations

  • Compression spends CPU per response; very small or already-compressed payloads should be skipped.
  • Compressing responses that mix secrets with attacker-controlled input enables BREACH-style attacks; disable it on those endpoints.

Compression is negotiated, not unconditional

A client sends Accept-Encoding: gzip, br and the server may choose an encoding. If the server compresses anyway, or compresses the wrong content type, bytes are wasted and caches become ambiguous. The middleware approach works because it runs at write time: it sees the final Content-Type and decides once per response.

Pick a threshold and a filter

1 KB is a common threshold because gzip headers and CPU cost outweigh the savings below it. Filters should exclude images, video, archives, and font formats, all of which are already compressed. Do not forget conditional requests: a 304 Not Modified has no body and must not be compressed.

Vary is part of the contract

Any shared cache between you and the client keys on the URL. Without Vary: Accept-Encoding, a gzip response can be served to a client that never asked for it, and the client will see binary garbage. Most compression libraries set this header automatically, but proxies and custom writers often drop it, so assert it in a test.

app.use(compression({ threshold: 1024 }));
app.use((req, res, next) => {
  res.set('Vary', 'Accept-Encoding');
  next();
});

Measure both sides

Track compressed bytes, uncompressed bytes, and CPU time per route. A win in transfer size that doubles p99 latency on a CPU-bound service is not a win. Keep large static assets precompressed at build time and let the CDN serve Brotli, which avoids per-request CPU entirely.

Test the negative cases too

Send requests with Accept-Encoding: identity and with no header at all; both must return plain bytes without Content-Encoding. Test an SSE endpoint and a 304 response, and confirm neither is compressed. These are the regressions that reach production because the happy path looks fine.