Building a Real-Time Notification System with Server-Sent Events in Node.js
Your dashboard needs live updates, your build pipeline needs progress streams, and your users want to know when something happens without refreshing the page. Here is how to build it with Server-Sent Events: 30 lines of server code, the EventSource API on the client, and Redis pub/sub so it actually scales past one server instance.
Your monitoring dashboard refreshes every 30 seconds with a full-page AJAX call. That is 30 seconds of potentially the wrong data, plus a burst of N+1 database queries every time the cron fires. Your users click a button and wonder whether the action actually happened. Your build pipeline UI polls an endpoint every 2 seconds and 99% of the responses are “still running, try again.”
The fix is a persistent HTTP connection from the server to the browser that pushes events as they happen. No polling, no WebSocket upgrade dance, no sticky sessions (if you design it right). Just an HTTP/1.1 connection that stays open and sends text.
This is Server-Sent Events (SSE). It is built into every modern browser as the EventSource API. It reconnects automatically. It tracks what events you missed via Last-Event-ID. It compresses over HTTPS just as well as any other HTTP response. And it is about 30 lines of server code to get started.
The server: 30 lines to a live stream
SSE is just an HTTP response with Content-Type: text/event-stream and a specific wire format. Here is the simplest possible Express endpoint that pushes a timer event every second:
// events.mjs -- minimal SSE endpoint
import express from 'express';
const app = express();
const PORT = process.env.PORT || 3000;
app.get('/events', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // disable nginx buffering
});
const interval = setInterval(() => {
const data = JSON.stringify({ time: new Date().toISOString() });
res.write(`data: ${data}\n\n`);
}, 1000);
req.on('close', () => {
clearInterval(interval);
res.end();
});
});
app.listen(PORT, () => console.log(`SSE server on ${PORT}`));
The wire format is deliberate. Each event is one or more lines starting with a field name and a colon. The standard fields are event, data, id, and retry. Events are terminated by two newlines. That is the entire protocol.
On the client side:
const source = new EventSource('/events');
source.onmessage = (event) => {
console.log('Received:', JSON.parse(event.data));
};
That is it. The browser opens a GET request, the server never closes the response, and data flows server-to-browser for as long as the connection lives. If the connection drops, the browser reconnects automatically.
Named events and event IDs
Notifications are not a timer. You need different event types: new-comment, deploy-complete, payment-failed. SSE supports this natively with the event field.
// server
res.write(`event: deploy-complete\n`);
res.write(`data: ${JSON.stringify({ version: 'v2.3.1', duration: 42 })}\n\n`);
// client
source.addEventListener('deploy-complete', (event) => {
const data = JSON.parse(event.data);
showNotification(`Deploy ${data.version} finished in ${data.duration}s`);
});
Every EventSource connection also sends an HTTP header Last-Event-ID when reconnecting, carrying the last id the client received. This is how you avoid missing events during a disconnect. On the server:
// server with event IDs for reconnection
let eventId = 0;
app.get('/events', (req, res) => {
const lastId = parseInt(req.headers['last-event-id'], 10) || 0;
res.writeHead(200, { /* ... same headers ... */ });
// replay missed events from a buffer
const missed = eventBuffer.getSince(lastId);
for (const evt of missed) {
res.write(`id: ${evt.id}\nevent: ${evt.type}\ndata: ${evt.data}\n\n`);
}
// ... then continue with live events
});
The client-side event.lastEventId is automatically sent on reconnect. You do not have to wire it up manually. You just have to maintain a buffer on the server (or a database table) long enough to cover the 99th percentile reconnect gap.
Authentication and user-scoped streams
The raw EventSource API does not support custom headers. You cannot set an Authorization header on the constructor. This is the most common complaint about SSE and there are two clean workarounds.
Option 1: Auth via cookie. If your app already uses session cookies, the EventSource request sends cookies automatically because it is a same-origin GET request. This is the simplest approach and works for most single-page apps.
Option 2: Auth via token in the URL. Append a short-lived token as a query parameter and validate it on the server:
const source = new EventSource(`/events?token=${sessionToken}`);
If you go this route, make sure the token expires quickly (5 minutes) and that you rotate it. The URL appears in server logs, referrer headers, and browser history. Do not put a long-lived API key in a URL.
On the server, validate the token and register the response object in a user-to-connection map:
// user-scoped event bus
const connections = new Map(); // userId -> Set<Response>
app.get('/events', ensureAuth, (req, res) => {
const userId = req.user.id;
if (!connections.has(userId)) {
connections.set(userId, new Set());
}
connections.get(userId).add(res);
res.writeHead(200, { 'Content-Type': 'text/event-stream', /* ... */ });
req.on('close', () => {
connections.get(userId).delete(res);
if (connections.get(userId).size === 0) {
connections.delete(userId);
}
});
});
// push notification to a specific user
function notifyUser(userId, event, data) {
const userConns = connections.get(userId);
if (!userConns) return;
for (const res of userConns) {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
}
}
This works for a single server instance. For multiple instances, you need a shared pub/sub layer. That is next.
Scaling across server instances with Redis pub/sub
When you have more than one Node.js process (either via cluster module, multiple containers behind a load balancer, or a multi-region deployment), a connection to instance A cannot receive events pushed to instance B. You need a broker.
Redis pub/sub is the simplest fit. Every server instance subscribes to a Redis channel and publishes events to it. All instances receive all events and forward them to their local connections.
import { createClient } from 'redis';
const pub = createClient();
const sub = createClient();
await pub.connect();
await sub.connect();
// subscribe to all notification events
await sub.subscribe('notifications', (message) => {
const { userId, event, data } = JSON.parse(message);
broadcastToUser(userId, event, data);
});
// publish a notification (called from anywhere in your app)
async function notifyUser(userId, event, data) {
await pub.publish('notifications', JSON.stringify({ userId, event, data }));
}
Now when your billing service finishes a charge, it calls notifyUser(userId, 'payment-ok', { amount }) on any instance, the message fans out through Redis to all instances, and every open SSE connection for that user receives it.
Important: Redis pub/sub does not buffer messages. If a subscriber disconnects temporarily, it misses messages published during that window. If you need guaranteed delivery for the replay-on-reconnect pattern, use Redis streams instead of pub/sub, or add a per-user event log table in Postgres that clients query for missed events on reconnect.
Heartbeats and connection health
Some proxy servers and load balancers close idle HTTP connections after 60 to 120 seconds. To keep the SSE connection alive, send a periodic comment line. Comments in SSE start with a colon and are ignored by the client:
// heartbeat every 30 seconds
const heartbeat = setInterval(() => {
res.write(': heartbeat\n\n');
}, 30000);
req.on('close', () => {
clearInterval(heartbeat);
// ... cleanup
});
The colon-comment format sends an empty “event” that the client’s onmessage handler does not fire for. It exists solely to keep the TCP connection warm. Set your heartbeat interval to half the proxy idle timeout value.
On the client side, you can also detect a stale connection by monitoring source.readyState:
const source = new EventSource('/events');
source.onerror = () => {
console.warn('SSE connection lost, browser will auto-reconnect');
// The browser retries automatically, but you can force an immediate
// reconnect by closing and reopening:
// source.close();
// setTimeout(() => { source = new EventSource('/events'); }, 1000);
};
The browser’s built-in reconnection uses an exponential backoff starting at 2-3 seconds, backing off to a maximum of about 15 seconds. You can override the retry delay by sending a retry field from the server:
// server sends this on initial connection
res.write(`retry: 3000\n\n`);
This tells the browser to wait 3 seconds between reconnection attempts instead of the default backoff.
Performance: how many concurrent connections?
A single Node.js process can handle tens of thousands of concurrent SSE connections. Each connection is a held HTTP response, which costs approximately 20-40 KB of memory per socket on a typical Linux system (socket buffer, TCP control block, Node.js handle). At 40 KB each, 10,000 concurrent connections use about 400 MB.
The CPU cost is minimal because the connections are mostly idle. You are not polling or running per-connection timers (other than the heartbeat interval, which you batch). The main CPU cost comes when you push events: serializing JSON and writing to N sockets for the same event. This is an O(N) write operation where N is the number of connections subscribed to that event.
The key optimization is to write the same data to all matching sockets in a single loop rather than serializing once per socket:
// efficient broadcast: serialize once, write many
function broadcastToUser(userId, event, data) {
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
const sockets = connections.get(userId);
if (!sockets) return;
for (const res of sockets) {
res.write(payload);
}
}
If you need to push to thousands of users simultaneously, consider batching writes or using res.write with cork()/uncork() on the underlying socket (available on Node.js streams as socket.cork() for TCP-level batching). But for most notification workloads, the naive loop is fast enough.
Graceful shutdown
When your Node.js server restarts during a deploy, every open SSE connection drops. Users see a temporary disconnect and the browser reconnects automatically. But you should still clean up gracefully:
process.on('SIGTERM', async () => {
console.log('Shutting down, closing SSE connections');
// send a goodbye event so clients know to reconnect
const goodbye = `event: shutdown\ndata: {}\n\n`;
for (const [userId, sockets] of connections) {
for (const res of sockets) {
res.write(goodbye);
res.end();
}
}
// give Redis pub/sub a moment to flush
await pub.quit();
await sub.quit();
process.exit(0);
});
On the client side, listen for the shutdown event and reconnect to the next healthy instance:
source.addEventListener('shutdown', () => {
// server is going down, close and re-open
source.close();
setTimeout(() => {
window.location.reload(); // or re-create EventSource
}, 2000);
});
In a containerized environment with a load balancer, the load balancer health check should stop routing new requests to the draining instance before the SIGTERM fires. The standard pattern is a /health endpoint that returns 503 during the draining window (10-30 seconds), giving existing SSE connections time to receive the shutdown event and reconnect elsewhere.
Putting it all together: a complete notification service
Here is a minimal but production-ready SSE notification service that combines everything above:
// notification-service.mjs
import express from 'express';
import { createClient } from 'redis';
const app = express();
const PORT = process.env.PORT || 3000;
const pub = createClient({ url: process.env.REDIS_URL });
const sub = createClient({ url: process.env.REDIS_URL });
await pub.connect();
await sub.connect();
// user -> Set<Response>
const connections = new Map();
// middleware: parse session token
app.use('/events', (req, res, next) => {
const token = req.query.token;
if (!token || !validateToken(token)) {
return res.status(401).end();
}
req.userId = getUserIdFromToken(token);
next();
});
app.get('/events', (req, res) => {
const userId = req.userId;
if (!connections.has(userId)) {
connections.set(userId, new Set());
}
connections.get(userId).add(res);
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
// send retry and initial heartbeat
res.write(`retry: 3000\n\n`);
const heartbeat = setInterval(() => {
res.write(': heartbeat\n\n');
}, 30000);
req.on('close', () => {
clearInterval(heartbeat);
connections.get(userId).delete(res);
if (connections.get(userId).size === 0) {
connections.delete(userId);
}
});
});
// Redis subscription: forward events to local connections
await sub.subscribe('notifications', (message) => {
const { userId, event, data } = JSON.parse(message);
const sockets = connections.get(userId);
if (!sockets) return;
const payload = `event: ${event}\ndata: ${data}\n\n`;
for (const res of sockets) {
res.write(payload);
}
});
// REST endpoint to publish a notification
app.post('/notify', express.json(), (req, res) => {
const { userId, event, data } = req.body;
// validate, persist to DB, then publish
pub.publish('notifications', JSON.stringify({ userId, event, data }));
res.json({ ok: true });
});
// graceful shutdown
process.on('SIGTERM', async () => {
for (const sockets of connections.values()) {
for (const res of sockets) {
res.write(`event: shutdown\ndata: {}\n\n`);
res.end();
}
}
await pub.quit();
await sub.quit();
process.exit(0);
});
app.listen(PORT);
On the client:
// client.js
const source = new EventSource('/events?token=' + getSessionToken());
source.addEventListener('new-comment', (event) => {
const { postId, author } = JSON.parse(event.data);
showToast(`${author} commented on your post`);
});
source.addEventListener('deploy-complete', (event) => {
const { version } = JSON.parse(event.data);
updateDeployStatus(version);
});
source.addEventListener('payment-failed', (event) => {
const { amount, reason } = JSON.parse(event.data);
showBanner(`Payment of $${amount} failed: ${reason}`);
});
source.addEventListener('shutdown', () => {
source.close();
// reconnect after a brief pause
setTimeout(() => window.location.reload(), 3000);
});
source.onerror = () => {
// EventSource auto-reconnects, but log it for debugging
console.warn('SSE connection interrupted, reconnecting...');
};
When SSE is not the right tool
SSE has constraints that matter for specific use cases:
Binary data. SSE is text-only. The format natively transmits UTF-8. Binary frames require base64 encoding (33% overhead). If you are streaming binary frames at high frequency, WebSockets are a better fit.
Bidirectional traffic. SSE is server-to-client only. The client can send data to the server via regular HTTP POST requests, but if your protocol requires sub-100ms round-trips in both directions (collaborative editing, real-time gaming), WebSockets avoid the HTTP overhead.
Very high throughput. At tens of thousands of messages per second per connection, the text framing overhead of SSE (the data: prefix, newlines, field names) adds up. Each message costs about 10-20 extra bytes of framing. On a single connection pushing 50,000 messages/second, that is 500 KB/s of framing overhead. For most applications this is insignificant, but for high-frequency financial tickers or telemetry feeds, a binary protocol is more efficient.
For everything else (notifications, dashboards, build logs, AI streaming, live blog updates, sports scores, stock tickers that update every few seconds), SSE is the simplest, most HTTP-native, most proxy-friendly option. It does not require a protocol upgrade. It works over HTTP/2 multiplexing. It works behind every CDN that supports HTTP. It is built into the browser and requires zero client-side library code.
The takeaway
Server-Sent Events are the most underused real-time primitive in web development. They solve the most common server-push requirement (send JSON from server to browser as events happen) with zero client dependencies, automatic reconnection, and a wire format so simple you can debug it with curl:
curl -N http://localhost:3000/events
Start with the 30-line Express endpoint. Add named events for different notification types. Scope connections to authenticated users. Add Redis pub/sub when you scale past one instance. Send heartbeats every 30 seconds. Listen to the shutdown event in your deploy pipeline.
Your users will stop refreshing. Your database queries will stop polling. Your deployment will push status updates in real time. And the best part: you did not add a single npm dependency for the client side. It ships in every browser.
A note from Yojji
Building systems that push real-time updates to users without eating your infrastructure budget (or your team’s debugging time) requires the same production-first thinking as any other backend service: connection management, graceful shutdown, and a clear protocol boundary. The kind of pragmatic architecture decisions Yojji’s teams make every day when shipping full-cycle applications for clients across Europe, the US, and the UK.
Yojji is an international custom software development company founded in 2016, specializing in the JavaScript ecosystem (React, Node.js, TypeScript), cloud-native infrastructure on AWS, Azure, and Google Cloud, and the full cycle of product delivery from discovery through DevOps. Their senior engineers build systems that work in production, not just in demos.