Building SSE Endpoints with Fastify Permalink to this section

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

Fastify is built around a request/reply lifecycle that ends: a handler returns or sends a payload, serialisation and onSend hooks run, and the reply is finished. A Server-Sent Events stream never finishes, so it has to step outside that lifecycle deliberately. Done carelessly, Fastify and its plugins fight the stream: the reply is sent twice, compression buffers events, hooks log a request that never completes, and the handler’s promise resolution closes the connection. This guide shows the clean way to stream from Fastify.

Symptom & Developer Intent Permalink to this section

  • FST_ERR_REP_ALREADY_SENT or “Reply was already sent” warnings appear on every stream.
  • The stream closes immediately after the first event, or as soon as the async handler returns.
  • Events arrive in bursts after @fastify/compress was registered.
  • onResponse hooks and access logs never fire for streams, or fire with nonsense durations.
  • Memory grows because listeners attached per stream are never removed.

The intent is a Fastify route that streams named, identified events immediately, cooperates with the rest of the application’s plugins, and releases everything when the client leaves.

Root Cause Analysis Permalink to this section

When an async Fastify handler resolves, Fastify takes the resolved value as the payload and sends it. When a handler writes to reply.raw (the underlying Node.js ServerResponse) without telling Fastify, Fastify still believes it owns the response and tries to send it later — hence “already sent” errors, or a premature end() that kills the stream.

Where a Fastify reply normally goes, and where a stream must leave it Stack of the Fastify reply lifecycle stages — handler, serialisation, onSend hooks and compression, send — with the stream leaving the lifecycle after the handler via reply.hijack. Where a Fastify reply normally goes, and where a stream must leave it Route handler reply.hijack() stream owns reply.raw from here Serialisation skipped would stringify a return value onSend hooks + compress skipped would buffer the stream reply.send skipped would end the response
hijack tells Fastify that the handler now owns the raw response. Serialisation, onSend hooks and Fastify's own send are skipped for this reply.

reply.hijack() exists for exactly this: it marks the reply as handled by user code, so Fastify skips serialisation, onSend hooks and its own send. Compression plugins that wrap the payload in onSend therefore stop interfering too — though a globally registered compression plugin that hooks lower down may still need an explicit exclusion.

Step-by-Step Resolution Permalink to this section

Step 1 — Hijack the reply and write headers yourself Permalink to this section

// routes/events.js
export default async function routes(fastify) {
  fastify.get('/events', async (request, reply) => {
    reply.hijack();                                   // Fastify: hands off this reply
    const res = reply.raw;
    res.writeHead(200, {
      'Content-Type': 'text/event-stream; charset=utf-8',
      'Cache-Control': 'no-cache, no-transform',
      'Connection': 'keep-alive',
      'X-Accel-Buffering': 'no',
    });
    res.flushHeaders?.();
    res.write('retry: 3000\n\n');

    const cursor = request.headers['last-event-id'];
    for (const evt of await fastify.events.after(cursor)) res.write(format(evt));

    const onEvent = (evt) => { if (!res.writableNeedDrain) res.write(format(evt)); };
    fastify.events.on('event', onEvent);
    const hb = setInterval(() => res.write(': hb\n\n'), 15000);

    request.raw.on('close', () => {                   // client left: release everything
      clearInterval(hb);
      fastify.events.off('event', onEvent);
    });
  });
}

function format({ id, type, data }) {
  return `id: ${id}\nevent: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
}

Connection: keep-alive is meaningful only on HTTP/1.1 and is ignored (and must not be sent) on HTTP/2; drop it if the server runs with http2: true. Headers set earlier with reply.header() are not applied after hijacking, so any CORS or security headers from plugins must be copied onto reply.raw explicitly.

Step 2 — Keep compression away from the stream Permalink to this section

await fastify.register(import('@fastify/compress'), {
  global: true,
  customTypes: /^(?!text\/event-stream).*$/,     // never compress event streams
});

Or register compression with global: false and enable it per route. Either way, verify with curl --compressed -N that events still arrive one at a time.

Step 3 — Make hooks and logging stream-aware Permalink to this section

onResponse fires when the response finishes — for a stream, when the client disconnects. That is fine for logging duration, but request timers and metrics that assume short requests will produce extreme values. Tag stream routes with route config and branch on it:

fastify.get('/events', { config: { stream: true } }, handler);

fastify.addHook('onResponse', async (request, reply) => {
  if (request.routeOptions.config.stream) {
    metrics.streamDuration.observe(reply.elapsedTime / 1000);   // separate histogram
    return;
  }
  metrics.httpDuration.observe(reply.elapsedTime / 1000);
});

Step 4 — Share one event source across connections Permalink to this section

Put the fan-out in a decorator so every route and test uses the same instance:

import { EventEmitter } from 'node:events';
import fp from 'fastify-plugin';

export default fp(async (fastify) => {
  const bus = new EventEmitter();
  bus.setMaxListeners(0);                             // one listener per open stream
  fastify.decorate('events', {
    on: bus.on.bind(bus), off: bus.off.bind(bus),
    publish: (evt) => bus.emit('event', evt),
    after: async (cursor) => replayStore.after(cursor),
  });
});

In a multi-instance deployment, feed publish from a Redis subscription so every instance receives every event, as in Redis pub/sub fan-out.

Step 5 — Authenticate before hijacking Permalink to this section

Run authentication in a preHandler hook or at the top of the handler, and reject with an ordinary Fastify reply before calling reply.hijack(). Once the reply is hijacked, Fastify’s error handling no longer applies, so a thrown error after that point leaves a half-open stream. A 401 or 403 sent the normal way also tells EventSource to stop reconnecting, which is the correct behaviour for an unauthenticated client.

fastify.get('/events', { preHandler: fastify.authenticate }, async (request, reply) => {
  if (!request.user.canStream) return reply.code(403).send({ error: 'forbidden' });
  reply.hijack();
  // …stream as above…
});
One Fastify stream from request to cleanup Flow from the GET request, through hijacking the reply, writing headers and retry, replaying from Last-Event-ID, subscribing to the decorated event bus, and releasing the listener and heartbeat on close. One Fastify stream from request to cleanup GET /events request route hijack() own reply.raw headers Replay after cursor then live Subscribe bus + heartbeat client leaves on close off + clearInterval
The close handler is the mirror image of the subscription. Every resource added between hijack and close must be released in it.

Validation & Monitoring Permalink to this section

# Events arrive one per publish, with ids, and compression is not applied.
curl -sN --compressed -H 'Accept-Encoding: gzip' http://localhost:3000/events -D - | head -12

# No "already sent" warnings under load.
npx autocannon -c 200 -d 30 --renderStatusCodes http://localhost:3000/events &
grep -c 'Reply was already sent' logs/app.log

Fastify’s inject() buffers the whole response, so it is unsuitable for stream tests; start the server on an ephemeral port with fastify.listen({ port: 0 }) and read the stream incrementally, as shown in testing and load testing SSE endpoints.

Listeners on the event bus after 10,000 connect/disconnect cycles Bar chart comparing the number of listeners left attached to the event bus without a close handler and with one. Listeners on the event bus after 10,000 connect/disconnect cycles No close handler 10,000 off() on close 0 listeners remaining attached with zero clients connected
A missing off() in the close handler is invisible in short tests and a steady leak in production. Export the listener count as a gauge.

Export bus.listenerCount('event') as a gauge; it should equal the number of open streams at all times.

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Is there a Fastify plugin for SSE?

Several community plugins add a reply.sse() helper around the same mechanics. They are convenient, but check that they hijack the reply, flush per event and expose a close hook, since those are the parts that matter in production.

Can the handler return a Node.js stream instead?

Fastify can send a readable stream as the payload, and that works for SSE if the stream produces correctly formatted frames and compression is excluded. Hijacking is simpler when events come from an emitter rather than a stream.

Does Fastify's HTTP/2 mode change anything?

The handler is the same, but connection-specific headers such as Connection are not allowed on HTTP/2 and must be omitted. Many streams then share one connection, removing the browser's six-connection limit.

Why does my stream close when the handler returns?

Without hijack, Fastify sends the resolved value and ends the response. Call reply.hijack() so Fastify leaves the response alone after the handler resolves.