Serving SSE from Express with Compression Enabled Permalink to this section

Part of Node.js Streaming Architecture Basics, under Backend Stream Generation & Connection Management.

app.use(compression()) is in almost every Express application, and it silently converts a real-time stream into batch delivery. Removing it entirely is the wrong fix — the rest of the application wants it.

Symptom & Developer Intent Permalink to this section

  • Events arrive in bursts of several kilobytes rather than one at a time.
  • The stream is smooth in a fresh test project and batched in the real application.
  • curl shows nothing for a while, then several events together.
  • Commenting out the compression middleware fixes it, at the cost of compressing nothing else.
  • The response carries Content-Encoding: gzip even though the endpoint never asked for it.

The intent is compression everywhere except the stream, with certainty that nothing downstream re-applies it.

Root Cause Analysis Permalink to this section

A gzip encoder builds a dictionary from the input it has seen, so it must accumulate bytes before it can emit meaningful output. The compression middleware wraps res.write in a zlib stream that buffers until it has enough data — commonly around a chunk boundary or its internal threshold — and only then writes to the socket. Each individual res.write of a small event returns, the caller believes the event was sent, and the bytes sit in the compressor.

What compression() does to res.write The path of one event through the compression middleware: written by the handler, held in the zlib buffer, and released only once the encoder has enough input. What compression() does to res.write res.write(frame) handler thinks it is sent returns true immediately zlib buffer building a dictionary holds until threshold or flush Socket nothing yet bytes arrive in a burst later what actually happens
The handler believes the event was sent. It is sitting in a compressor waiting for enough input to be worth emitting.

Calling res.flush() (exposed by the middleware) forces the encoder to emit what it has, which is the classic workaround. It works, but it is fragile: every write path must remember to call it, a Z_SYNC_FLUSH per tiny event wastes most of the benefit of compressing at all, and event-stream text compresses poorly in small pieces anyway.

The cleaner answer is to exclude the route. Compression buys little on a text stream sent in small increments, and excluding it removes a whole class of failure.

There is a second-order problem: even with the middleware excluded, an upstream proxy or CDN may compress the response itself. That is what Cache-Control: no-transform addresses.

Step-by-Step Resolution Permalink to this section

Flush per event, or exclude the route? Decision between calling res.flush() after every write and excluding the streaming route from the compression middleware. Flush per event, or exclude the route? Is compression worth it on this stream? Exclude the route filter by path + type no — small text frames flush() per event fragile, remember it everywhere yes — large payloads
Excluding the route removes the failure mode; flushing per event preserves it and asks every future write path to remember a call.

Step 1 — Exclude the stream from the middleware by filter Permalink to this section

const compression = require('compression');

app.use(compression({
  filter: (req, res) => {
    // Never compress an event stream, whatever the client's Accept-Encoding says.
    if (res.getHeader('Content-Type')?.toString().includes('text/event-stream')) return false;
    if (req.path.startsWith('/events')) return false;
    return compression.filter(req, res);      // default behaviour for everything else
  },
}));

Checking both the path and the content type covers routes added later that forget the convention.

Step 2 — Mount the stream router before compression as a belt-and-braces measure Permalink to this section

// Order matters: middleware mounted after the route never sees its responses.
app.use('/events', streamRouter);
app.use(compression({ /* … */ }));
app.use('/api', apiRouter);

Step 3 — State the intent in headers Permalink to this section

router.get('/', (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream; charset=utf-8',
    // no-transform tells every downstream intermediary not to compress or alter this body.
    'Cache-Control': 'no-cache, no-store, no-transform',
    'X-Accel-Buffering': 'no',
  });
  res.flushHeaders();
  // …
});

Step 4 — Audit the rest of the middleware stack Permalink to this section

Compression is the most common culprit but not the only one. Anything that wraps res.write or buffers the body has the same effect.

// Middleware that commonly breaks streaming, and what to do about it:
//   compression()            → exclude the route (above)
//   express-static-gzip      → does not apply to dynamic routes
//   body-parser              → harmless on GET, but keep limits sane
//   response-time            → harmless; it only measures
//   morgan (:response-time)  → logs on finish; a stream never finishes, so log on close
//   helmet                   → harmless, but check for a CSP that blocks EventSource
//   any HTML/JSON rewriter   → exclude the route
// Morgan: log stream connections at close, not at finish.
app.use('/events', (req, res, next) => {
  const started = Date.now();
  req.on('close', () => {
    logger.info({ event: 'sse_closed', ms: Date.now() - started, path: req.path });
  });
  next();
});

Step 5 — Verify no encoding is applied, even when the client asks Permalink to this section

# Offer gzip explicitly — the response must still be uncompressed.
curl -sI -H 'Accept-Encoding: gzip, deflate, br' https://api.example.com/events \
  | grep -iE 'content-encoding|content-type'
# content-type: text/event-stream; charset=utf-8
# (no content-encoding line)

Validation & Monitoring Permalink to this section

Arrival timing is the real test; headers only tell you what was intended.

Middleware order decides what sees the response Sequence showing the stream router mounted before compression, so compression never processes the streaming response. Middleware order decides what sees the response Request /events router compression() GET /events — matched first streams directly GET /api/data — falls through gzip applied, correctly
Middleware mounted after a route never sees that route's responses — which makes mount order a second, independent line of defence.
# Timestamp each line as it arrives. Compression shows up as clustered timestamps.
curl -N -s -H 'Accept-Encoding: gzip' https://api.example.com/events \
  | while IFS= read -r line; do printf '%s %s\n' "$(date +%s.%N)" "$line"; done | head -20

# Time to first byte should be well under a second.
curl -N -s -o /dev/null -w 'first byte: %{time_starttransfer}s\n' https://api.example.com/events

Run both against the origin and through every proxy in front of it: excluding the middleware fixes your process, and a CDN can still compress the same response. The hop-by-hop procedure is in Proxy & CDN Configuration for SSE.

Add a regression guard so a future middleware addition cannot reintroduce this quietly:

// test/no-compression.test.js
it('never compresses the event stream', async () => {
  const res = await request(app)
    .get('/events')
    .set('Accept-Encoding', 'gzip, deflate, br')
    .buffer(false);

  expect(res.headers['content-encoding']).toBeUndefined();
  expect(res.headers['content-type']).toMatch(/text\/event-stream/);
});

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Can I keep compression and just call res.flush() after each event?

It works, but it is brittle and buys almost nothing: every write path must remember the flush, and a sync-flush per small event defeats most of the compression benefit anyway. Excluding the route removes the failure mode entirely and costs a negligible amount of bandwidth on text sent in small pieces.

Why does the stream work in a fresh Express app but not in mine?

Because the fresh app has no middleware stack. Compression is the usual culprit, but anything that wraps res.write or buffers the body — HTML rewriters, response transformers, some logging setups — produces the same batching. Audit the stack in mount order, and remember that middleware mounted after a route never sees it.

Does no-transform actually stop CDNs compressing?

It instructs them not to, and well-behaved intermediaries honour it. Treat it as a statement of intent rather than a guarantee: configure the CDN's own compression rule to exclude the path as well, and verify by requesting with Accept-Encoding: gzip and checking that no Content-Encoding comes back.

Is losing compression on the stream a real bandwidth cost?

Small. Event-stream frames are short and are sent individually, so a compressor has little context to exploit — real-world savings on small JSON events are modest, and the framing overhead stays uncompressed either way. If payload size genuinely matters, shrink the payloads rather than compressing the stream.