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_SENTor “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/compresswas registered. onResponsehooks 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.
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…
});
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.
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.