KL, MY
7:30:00 AM

We will explore how to use Server-Sent Events (SSE) in Node.js — from simple single-server setups to distributed, event-driven systems using BullMQ.
November 6, 2025
15 min read
Server-Sent Events (SSE) is a standard that allows servers to push real-time updates to clients over a single HTTP connection. This allows clients to receive real-time updates like notifications, live feeds, or progress updates without the need for HTTP polling or using a full-duplex protocol like WebSockets.
We will explore how SSE can be implemented in Node.js where we start with a simple monolithic setup and then evolve it into a distributed system using BullMQ for background job processing.
AlstonChan/nodejs-sse-distributed-systemYes, you could! However, SSE can be a better fit for certain use cases. Someone explain this better than I can, by web.dev:
Why would you choose server-sent events over WebSockets? Good question.
WebSockets has a rich protocol with bi-directional, full-duplex communication. A two-way channel is better for games, messaging apps, and any use case where you need near real-time updates in both directions.
However, sometimes you only need one-way communication from a server. For example, when a friend updates their status, stock tickers, news feeds, or other automated data push mechanisms. In other words, an update to a client-side Web SQL Database or IndexedDB object store. If you need to send data to a server, XMLHttpRequest is always a friend.
SSEs are sent over HTTP. There’s no special protocol or server implementation to get working. WebSockets require full-duplex connections and new WebSocket servers to handle the protocol.
In addition, server-sent events have a variety of features that WebSockets lack by design, including automatic reconnection, event IDs, and an ability to send arbitrary events.
Right, let’s start with a simple monolithic Node.js server that serves SSE to clients.
import Fastify from "fastify";
const fastify = Fastify({ logger: true });
// SSE endpointfastify.get("/events", (request, reply) => { // Set SSE headers reply.raw.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "Access-Control-Allow-Origin": "*", // Prevent CORS issues when running locally "Access-Control-Allow-Headers": "Cache-Control", });
// Send a message every 2 seconds const intervalId = setInterval(() => { const data = { message: "Hello from server", timestamp: new Date().toISOString(), random: Math.random(), };
// Notice that the data is prefixed with 'data: ' and // suffixed with two newlines We did not send a heartbeat // message here because we are already sending messages every 2 seconds. // A heartbeat message would send as something like // reply.raw.write(`: heartbeat\n\n`); // notice the colon at the start (without `data`) reply.raw.write(`data: ${JSON.stringify(data)}\n\n`); }, 2000);
// Clean up on client disconnect request.raw.on("close", () => { clearInterval(intervalId); console.log("Client disconnected"); });});
fastify.listen({ port: 3000 }, (err, address) => { if (err) throw err; console.log(`Server listening at ${address}`);});On client side, you can connect to the SSE endpoint like this:
function connect() { const eventSource = new EventSource('http://localhost:3000/events');
eventSource.onopen = () => { console.log('Connection opened'); updateStatus(true); };
eventSource.onmessage = (event) => { console.log('Message received:', event.data); const data = JSON.parse(event.data); addMessage(data); // function to create nodes in the DOM to show messages };
eventSource.onerror = (error) => { console.error('SSE error:', error); disconnect(); };}This is the simplest form of SSE in a monolithic Node.js server. However, as your application grows, you might want to move to a distributed architecture. Or better yet, start with a distributed monolith!
The very bare-bones code above shows that SSE can be implemented by just sending a header of content type text/event-stream with the correct payload without ending the connection (not returning or using .send in fastify).
For the client to establish a connection with the server, it needs to use the EventSource Web API to establish the connection.
The gist of this implementation is:
...// Event emitter for pub/sub between task processor and SSEconst taskEmitter = new EventEmitter();...fastify.get("/events", (request, reply) => { reply.raw.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "Cache-Control", });
// Handler for task completion events const taskCompletedHandler = (data) => { try { reply.raw.write(`data: ${JSON.stringify(data)}\n\n`); } catch (err) { console.error("Error writing to SSE stream:", err); } };
// Subscribe to task completion events taskEmitter.on("task-completed", taskCompletedHandler);
// Send initial connection message reply.raw.write( `data: ${JSON.stringify({ message: "Connected to SSE", timestamp: new Date().toISOString(), })}\n\n` );
// Send heartbeat every 30 seconds const heartbeatId = setInterval(() => { try { reply.raw.write(`: heartbeat\n\n`); } catch (err) { clearInterval(heartbeatId); } }, 30000);
// Clean up on client disconnect request.raw.on("close", () => { taskEmitter.off("task-completed", taskCompletedHandler); clearInterval(heartbeatId); console.log("Client disconnected from SSE"); });});
// Start job processing endpointfastify.post("/start-jobs", async (request, reply) => { // Accept request to queue job. In my example, i just // start a loop that keeps queuing job until it is told // to stop ...});// Stop job processing endpointfastify.post("/stop-jobs", async (request, reply) => { ...});...We utilize the Node.js EventEmitter class that acts as a pub/sub bridge to notify the SSE route with completed job result to notify back the user.
The simple implementation above is actually not a good implementation because it CANNOT scale. Most Node.js servers are normally expected to be scalable horizontally by adding more instances of the server. Using the EventEmitter pattern in that particular way causes missed notifications to client because the HTTP request to start job can be sent to instance A while the SSE connection is on instance B.
Furthermore, there are no way to query the status of the job reliably. This is because each instance maintain its own state for the job and client may not send the HTTP request to the same instance every time (I am not sure if enabling sticky session for this particular reason is a good idea or not, like a Band-Aid on an implementation not meant for scale).
This implementation has a more complete implementation that:
...// Create Redis clients for pub/subconst redisSubscriber = new Redis(connection);
// Create BullMQ queueconst taskQueue = new Queue("tasks", { connection });
// Map to track active job sessionsconst activeJobs = new Map();
// Subscribe to task completion channelawait redisSubscriber.subscribe("task-completed");
// Store all SSE response streamsconst sseClients = new Set();The redis client subscribes to the channel that the worker instance will broadcast when a job is completed through the Queue. The sseClients stores a set of active connection. If you wonder if it is okay to store active connection in each instances, it is actually fine.
Active connection is not something that all instance should have access to as it is just a medium to transmit the message. So in case user disconnect and connects to another instance, the user can still receive the correct message because the job is tied to the user (through session cookie/JWT/OAUTH token), not the SSE connection.
...// Handle incoming messages from RedisredisSubscriber.on("message", (channel, message) => { if (channel === "task-completed") { // Broadcast to all connected SSE clients for (const client of sseClients) { try { client.write(`data: ${message}\n\n`); } catch (err) { console.error("Error writing to SSE client:", err); sseClients.delete(client); } } }});...fastify.get("/events", (request, reply) => { reply.raw.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "Cache-Control", });
sseClients.add(reply.raw);
// Send initial connection message reply.raw.write( `data: ${JSON.stringify({ message: "Connected to SSE", timestamp: new Date().toISOString(), })}\n\n` );
// Send heartbeat every 30 seconds const heartbeatId = setInterval(() => { try { reply.raw.write(`: heartbeat\n\n`); } catch (err) { clearInterval(heartbeatId); sseClients.delete(reply.raw); } }, 30000);
// Clean up on client disconnect request.raw.on("close", () => { sseClients.delete(reply.raw); clearInterval(heartbeatId); console.log(`Client disconnected from SSE. Active clients: ${sseClients.size}`); });});...For the sake of simplicity, the redis subscriber publish messages to all connected client. However, you can implement conditions to send only to specific user by maintaining an in-memory hash map or a similar structure.
On the worker side, it will process the job then publish the result to the server via redis pub/sub
...const worker = new Worker( "tasks", async (job) => { const { taskId, sessionId } = job.data;
console.log(`Processing job ${job.id}: ${taskId}`);
// Simulate some work const duration = Math.floor(Math.random() * 3000) + 1000; // 1-4 seconds await new Promise((resolve) => setTimeout(resolve, duration));
const result = { taskId, sessionId, status: "completed", result: `Task ${taskId} completed after ${duration}ms`, timestamp: new Date().toISOString(), };
// Publish result to Redis pub/sub channel await redisPublisher.publish("task-completed", JSON.stringify(result));
console.log(`Job ${job.id} completed and published to Redis`);
return result; }, { connection, concurrency: 5, // Process up to 5 jobs concurrently });...There are a lot of things that aren’t considered yet or not addressed in the code that you should be aware of, such as:
This maximum connection constraint isn’t defined by the standard, but most browsers enforce a limit of six concurrent open connections per domain.
You can see more with this How to interpret “Connection: keep-alive, close”? or this very useful thread regarding SSE and websocket.
This should not be a concern as most production Node.js application should be deployed behind a reverse proxy or load balancer that will handle the TLS termination that enables you to use HTTP/2 that doesn’t have such limit (or rather a higher limit than 6). HTTP/2 is widely available across major browser so this limit doesn’t apply to most of your users.
During development, it’s common (and fine) to use HTTP/1.1 without TLS since it’s simpler and faster to spin up locally. However, in production, TLS and HTTP/2 should be managed by your reverse proxy (e.g., Nginx, Cloudflare, or AWS ALB) rather than your Node.js app. Each Node.js instance should focus on application logic, not TLS handshakes.
It is a separate of responsibilities that your Node.js app focus on the application logic instead of networking. If you have multiple instance of Node.js app with horizontal scaling, each instance doing their own TLS termination is a waste of compute.
Data consistency ensures that users always see correct and up-to-date information, even after refreshing the page or reconnecting.
Consider this sequence of events:
That timing gap is a race condition, and it’s very real. It happens because your backend starts streaming data only after the SSE connection is established, but there’s no guarantee that the state fetched via REST reflects the latest data at that exact moment.
To fix this, you can:
id: field in SSE). Each event should include a unique and/or incremental identifier (e.g., job ID or timestamp).Last-Event-ID header: when the client reconnects, it tells the server which event it last received, and the server can replay any missed events.This approach eliminates the race window and guarantees that even if a connection drops or the user refreshes, they always receive a complete and consistent data stream.
When implementing SSE, it’s common to maintain a list or map of active connections (often keyed by userId or clientId). Each entry typically stores a response object so that the server can write updates to it.
The risk is that if a client disconnects (for example, due to a tab close, network interruption, or page navigation) and the server doesn’t clean up the corresponding entry, memory will leak over time.
To prevent this, you should:
close event on the response or request object.This cleanup ensures that your app can handle thousands of SSE clients safely without gradually consuming more memory with each lost connection.
Not every event needs to be sent to every connected client. In many cases, it’s far more efficient to only send updates to the specific user or group of users that the event is relevant to.
For example, if your system uses a Redis or Valkey-based pub/sub architecture, you might initially publish events to a global topic that all SSE routes subscribe to. However, this becomes inefficient as your app scales — each event results in a broadcast to all clients, even those who don’t care about the data.
Instead, implement selective broadcasting:
roomId, userId, or taskId).This approach reduces CPU and I/O overhead and makes your SSE system much more scalable, especially in distributed setups.
One of the most overlooked aspects of SSE is how it behaves behind different reverse proxies and load balancers.
SSE relies on long-lived HTTP connections that stay open indefinitely while data is streamed gradually. However, some proxies buffer or time out idle connections by default, which can break your stream.
It’s also wise to periodically send heartbeat messages (like : heartbeat\n\n) every few seconds to keep the connection active and prevent it from being closed due to perceived inactivity.
Even though SSE guarantees ordered message delivery per connection, it doesn’t guarantee exactly-once delivery across reconnects. When the connection drops and the browser automatically reconnects (using the Last-Event-ID header), your backend may resend recent events to ensure the client didn’t miss any — which can lead to duplicate messages.
For this reason, your frontend must always treat each incoming event as potentially duplicated and handle it idempotently.
How to do it:
Every message your backend sends should include a unique and sequential id field.
The frontend should maintain a local record (e.g., in memory, a Map, or reactive store) of the last processed event ID.
Before applying a new event, check if it’s already been processed — if yes, ignore it.
const processed = new Set();const source = new EventSource("http://localhost:3000/events");
source.addEventListener("message", (e) => { const { taskId, result } = JSON.parse(e.data); if (processed.has(id)) return; processed.add(id); handleUpdate(result);});This approach ensures idempotent updates — no matter how many times the same event is delivered, your UI remains consistent.
It also allows your server more flexibility: it can safely re-send recent messages (for recovery or redundancy) without breaking client state.
And there you have it. We’ve taken SSE from a simple monolithic setup to a scalable, distributed system using BullMQ and Redis.
The main takeaway? SSE is simple to start with, but making it production-ready and scalable means solving the “common distributed system” problem. By decoupling your job processing from your API servers, you get a resilient system that can handle real-time updates reliably.
We also covered the real-world “gotchas”—from race conditions to proxy buffering—that you’ll actually face. Hope that helps…