compression
compression is the Express middleware that gzips, deflates, or brotli-compresses your HTTP responses on the way out. You app.use() it once and it inspects each response: it reads the client's Accept-Encoding header, picks an encoding, checks that the Content-Type looks compressible via the compressible module, checks that the body is over a size threshold, then swaps in a zlib stream and sets Content-Encoding and Vary for you. It also skips anything marked Cache-Control: no-transform, and adds a res.flush() method so streaming endpoints can push buffered bytes to the client on demand.
The right answer when Node itself is the edge and your responses are dynamic, and it has been stable enough that a one-line app.use() is genuinely all most apps need. Put a proxy or CDN in front instead if you have one, because compressing in JavaScript is the most expensive place to do it.
Use it if
- You serve JSON or HTML from a Node process that faces clients directly, with no nginx, Caddy, or CDN in front doing the compression
- Your responses are dynamic and large enough to matter (API payloads, server-rendered pages) so precompressing at build time is not an option
- You want per-route control over what gets compressed, which the filter option and Cache-Control: no-transform both give you
- You are already on Express or any connect-style stack and want a one-line change rather than a proxy configuration
- There is already a reverse proxy or CDN in the path: nginx, Caddy, and Cloudflare compress in native code off your event loop, and compressing twice only burns application CPU
- You are serving static files that never change: precompressing at build time and serving the .br/.gz variants (express-static-gzip, or your CDN) beats recompressing the same bytes on every request
- You need zstd: only br, gzip, deflate, and identity are negotiated, and there is no option to add a codec
- You stream responses (server-sent events, chunked progress logs): compression buffers by design, so every boundary needs an explicit res.flush() or clients sit waiting on a partial frame
- You are on ESM-only TypeScript: this is a CommonJS module with no types in the package, so you need @types/compression and a default-import interop setting
Setup reality
npm install compression is quick, but the module is old-style CommonJS with no exports map and no bundled types, so TypeScript users also need @types/compression and esModuleInterop. Placement decides whether it does anything at all: app.use(compression()) must run before the routes and static handlers whose output you want compressed. The 1kb default threshold is only reliable when Content-Length is known at header time, otherwise the middleware assumes the body is over the limit. Brotli quality defaults to 4 rather than zlib's usual tradeoffs, and brotli is preferred over gzip when the client offers both. The dependency tree still pins debug@2.6.9, which shows up in audits and dedupe reports even though nothing is broken by it.
Patterns
Compress every response in an Express appbasic-express
const express = require('express')
const compression = require('compression')
const app = express()
app.use(compression())
app.get('/api/items', (req, res) => {
res.json(items)
})
app.listen(3000)Order decides everything: middleware registered before compression writes its response without ever passing through it, so put this above your routes and static handlers.
Opt individual requests out with a filtercustom-filter
const compression = require('compression')
function shouldCompress (req, res) {
if (req.headers['x-no-compression']) return false
return compression.filter(req, res)
}
app.use(compression({ filter: shouldCompress }))Always fall through to compression.filter instead of returning true, or you start gzipping already-compressed content types like images and video and make responses larger.
Skip compression for a single responseskip-one-route
app.get('/download/:id', (req, res) => {
// never compressed: the middleware honours no-transform
res.setHeader('Cache-Control', 'no-transform')
res.setHeader('Content-Type', 'application/octet-stream')
streamFile(req.params.id).pipe(res)
})Cache-Control: no-transform is the per-response escape hatch and also tells intermediary caches not to recompress. It is more reliable than path checks in the filter when routes are mounted under changing prefixes.
Trade CPU against payload sizetune-level-threshold
app.use(compression({
level: 6, // 0 none, 1 fastest, 9 smallest, -1 zlib default
threshold: '4kb', // skip bodies smaller than this
memLevel: 8
}))The threshold is advisory: if Content-Length is unknown when headers are written, the response is assumed to be over it and gets compressed anyway. Set Content-Length on small responses you want left alone.
Configure brotli separately from gzipbrotli-options
const zlib = require('zlib')
app.use(compression({
brotli: {
params: {
[zlib.constants.BROTLI_PARAM_QUALITY]: 6,
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: 0
}
}
}))The middleware sets brotli quality to 4 by default, not zlib's own default of 11, because 11 is far too slow per request. Raise it deliberately and measure; brotli cost climbs steeply above quality 6.
Keep server-sent events flowingserver-sent-events
app.use(compression())
app.get('/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
const timer = setInterval(() => {
res.write('data: ping\n\n')
res.flush() // without this the client sees nothing
}, 2000)
res.on('close', () => clearInterval(timer))
})Compression needs a window of output before it emits anything, so an event stream stalls silently until the buffer fills. res.flush() is added to the response by this middleware and does not exist without it.
Use it without Expressbare-http-server
const http = require('http')
const compress = require('compression')({ threshold: 0 })
const server = http.createServer((req, res) => {
compress(req, res, (err) => {
if (err) {
res.statusCode = err.status || 500
res.end(err.message)
return
}
res.setHeader('Content-Type', 'text/plain')
res.end('hello world!')
})
})
server.listen(3000)It is plain (req, res, next) middleware, so any connect-style stack works. You call it yourself and handle the error argument, which Express normally does for you.
Handle clients that send no Accept-Encodingenforce-encoding
app.use(compression({
enforceEncoding: 'gzip' // default is 'identity'
}))This only applies when the request has no Accept-Encoding header at all. Old HTTP clients and some load-balancer health checks fall into that bucket, and forcing gzip on a client that cannot decode it produces unreadable bytes rather than an error.
Wire it up in TypeScripttypescript-setup
// npm i -D @types/compression
import express from 'express'
import compression from 'compression'
const app = express()
app.use(compression({ threshold: 1024 }))No types ship with the package, so without @types/compression this is an implicit any. The default import needs esModuleInterop or allowSyntheticDefaultImports because the module only sets module.exports.
Check that it is actually compressingverify-encoding
$ curl -sI -H 'Accept-Encoding: br, gzip' http://localhost:3000/api/items
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Vary: Accept-Encoding
Content-Encoding: br
$ curl -sI -H 'Accept-Encoding: gzip' http://localhost:3000/api/items | grep -i content-encodingNo Content-Encoding header means one of four things: the body was under the threshold, the Content-Type was not compressible, a filter said no, or the middleware runs after the route. Check them in that order.
Understand the Vary header it setsvary-header-caching
// compression() adds this to every response it considers:
// Vary: Accept-Encoding
// so shared caches store one entry per encoding
app.use(compression())
app.use(express.static('public', {
setHeaders: (res) => res.setHeader('Cache-Control', 'public, max-age=3600')
}))Vary is added even when the response ends up uncompressed, which is correct but multiplies cache keys in front of you. Dropping or overwriting Vary downstream is how clients end up served brotli bytes they never asked for.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @fastify/compress | npm | You are on Fastify and want compression wired into its hook and payload system instead of connect middleware |
| express-static-gzip | npm | Your compressible traffic is static assets you can precompress at build time and serve as .gz or .br files |
| http-compression | npm | You want gzip and brotli on a bare node:http or framework-agnostic server without pulling in the connect middleware style |