While implementing Server-Sent Events (SSE) using
Fastify, I ran into a subtle but important gotcha. Using
reply.raw
in Fastify gives you direct access to Node.js’s native
http.ServerResponse
object, but it bypasses middleware like CORS, compression,
and lifecycle hooks like OnSend
.
The Scenario
I was setting up an SSE endpoint, and naturally reached for the low-level
reply.raw
to manually manage the connection and keep it open:
fastify.get("/events", (req, reply) => {
reply.raw.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
reply.raw.write(`data: hello\n\n`);
});
This seemed fine at first, but when I tested it in the browser, the frontend was throwing CORS errors, and the SSE connection wasn’t working properly.
It turned out that since I was using reply.raw
, Fastify’s CORS middleware was
not applying the Access-Control-Allow-Origin
header. That header is required
for the browser to accept the connection.
What You’re Bypassing
By using reply.raw
, you’re skipping:
- Fastify’s
reply.send()
, which handles serialisation - Any plugin-added headers or logic (e.g. compression, CORS, etc.)
- Lifecycle hooks like
onSend
oronResponse
In this case, the missing CORS header was the issue. Without it, the browser rejected the connection.
The Safer Alternative
If you don’t need low-level control, stick with the built-in reply
API:
fastify.get("/standard-endpoint", (req, reply) => {
reply.code(200).send({
hello: "world",
});
});
This ensures:
- Proper
Content-Type
headers - JSON serialisation
- Compatibility with plugins like CORS
If you do use reply.raw
, be aware that you’re responsible for setting
everything yourself, including security-related headers like CORS.
Takeaway
Using reply.raw
gives you full control over the response, but it opts you out
of built-in behaviours like header injection, plugin support, and lifecycle
hooks. Be careful when using it in browser-facing endpoints like SSE, where
things like CORS headers are critical.