Live notifications, the small bell that lights up when something new arrives, are a feature you can ship in an afternoon if you pick the right transport. Most teams reach for WebSockets out of habit and end up writing more reconnection code than business logic. Server-Sent Events let you skip the WebSocket complexity entirely for any one-way notification feature.
This guide walks through implementing notifications end to end: the server endpoint, the client subscription, the storage layer for replay, and the production hardening that turns it from a demo into a feature you can trust.

Photo by Theia Sight on Pexels
Step 1: define the notification shape
Before any code, agree on what a "notification" actually is in your system. A small, opinionated shape works far better than a flexible one.
{
"id": "ntf_01HX...",
"kind": "comment.created",
"subject_id": "doc_42",
"actor_id": "usr_8",
"created_at": "2026-06-07T14:32:11Z",
"text": "Alex commented on your draft"
}
Five fields cover almost every notification feed: an opaque ID, a kind discriminator, the subject of the notification (whatever the user clicks on), the actor (who did the thing), a timestamp, and the human-readable text. Anything more is application-specific and goes in a separate meta field.
Pin this shape early. The notification model is the contract between the producer side of your application and the streaming layer, and changes to it propagate everywhere.
Step 2: stand up the SSE endpoint
The server side has two pieces: the long-lived endpoint that holds connections open, and the publish path that the rest of the application uses to add notifications.
import express from 'express';
import { randomUUID } from 'node:crypto';
const app = express();
const subscribers = new Map();
app.get('/notifications/stream', (req, res) => {
if (!req.user) {
res.status(401).end();
return;
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write(': hello\n\n');
const connId = randomUUID();
let userSubs = subscribers.get(req.user.id);
if (!userSubs) {
userSubs = new Map();
subscribers.set(req.user.id, userSubs);
}
userSubs.set(connId, res);
req.on('close', () => {
userSubs.delete(connId);
if (userSubs.size === 0) subscribers.delete(req.user.id);
});
});
function publish(userId, notification) {
const userSubs = subscribers.get(userId);
if (!userSubs) return;
const line = `id: ${notification.id}\nevent: notification\ndata: ${JSON.stringify(notification)}\n\n`;
for (const res of userSubs.values()) res.write(line);
}
app.listen(3000);
The endpoint sets four headers (the content type, no-cache, explicit keep-alive, and X-Accel-Buffering: no for nginx-style proxies), writes a comment line to flush headers immediately, then tracks the response in a per-user subscriber map. The publish function fans out a single notification to every active connection for the target user.
This is the canonical shape. Frameworks like Express and Fastify accept it without ceremony, and the same pattern works on top of any HTTP server runtime documented at Node.js.
Step 3: connect from the client
The browser-side code is the smallest part of the system.
function subscribeToNotifications(onNotification) {
const source = new EventSource('/notifications/stream');
source.addEventListener('notification', (e) => {
const ntf = JSON.parse(e.data);
onNotification(ntf);
});
source.addEventListener('error', () => {
console.warn('notification stream interrupted, browser will retry');
});
return () => source.close();
}
EventSource is built into every modern browser. It opens the connection, subscribes to named events, and reconnects automatically with a default backoff if the connection drops. When you call the returned cleanup function, the connection closes cleanly.
The browser also handles Last-Event-ID for you: every event the server sends with an id: field becomes the Last-Event-ID header on the next reconnect, which means the server can replay missed notifications from a known point. This is the closest thing to "free durability" any real-time transport offers, and it is the biggest reason to prefer SSE over WebSockets for this kind of feature.

Photo by Andrea Piacquadio on Pexels
Step 4: implement the replay window
The free Last-Event-ID durability requires the server to actually have the missed events around to replay. Most teams underestimate how cheap this is to do correctly.
The pattern: every notification is written to durable storage (a database row, an event-log table, whatever fits) before it is published to the stream. The notification's id field is the durable storage primary key. When a client reconnects with Last-Event-ID: ntf_01HW..., the server reads from durable storage every notification newer than that ID for the user, writes them all to the response as event lines, then continues streaming new events as they arrive.
app.get('/notifications/stream', async (req, res) => {
// ...auth and headers as above...
const lastSeenId = req.headers['last-event-id'];
if (lastSeenId) {
const missed = await db.notifications.findAllSince(req.user.id, lastSeenId);
for (const ntf of missed) {
const line = `id: ${ntf.id}\nevent: notification\ndata: ${JSON.stringify(ntf)}\n\n`;
res.write(line);
}
}
// ...register in subscribers map and listen for new events...
});
How far back the replay window should reach is a product decision. A common starting point: replay any notification from the last hour, plus anything explicitly newer than Last-Event-ID regardless of age. Past the replay window, the client should re-fetch the notification list via a normal HTTP endpoint on initial load.
Step 5: scale out across processes
A single Node process running the endpoint above can hold a few thousand connections comfortably. Past that, you need multiple processes, and the publish function as written only broadcasts to subscribers on the local process.
The standard fix is to put a message bus between the application processes and the producer side. Every notification gets written to durable storage, then published to a topic on the bus. Every process subscribes to the bus and, on receiving a published notification, runs the local-subscribers publish logic. The longer guide on implementing Server-Sent Events for real-time web updates walks the multi-process fan-out in detail.
Redis pub/sub is the canonical choice for this layer. NATS, Kafka, or any other broker works the same way. Pick the one your team already runs in production; the choice is rarely worth a separate investment for this feature alone.
"We have shipped notification feeds on top of SSE for clients in five different stacks. The protocol is small. The durability story is built in. The places it goes wrong are almost always at the boundary: reverse-proxy buffering, missing heartbeats, replay window too short. None of those are exotic; they are all checkboxes." - Dennis Traina, founder of 137Foundry
Step 6: add the production checkboxes
A working demo and a production-grade feature differ by a small list of habits.
Send heartbeats every 15-30 seconds. A comment-line ping (: ping\n\n) keeps the connection alive past idle timeouts at the load balancer and lets the server detect dead clients faster.
Disable buffering at every middlebox. X-Accel-Buffering: no is the nginx fix. The load balancer config should disable response buffering for the SSE path. CDN edge configs should disable the same. Without these, events sit in the proxy until the timeout closes the connection.
Cap per-user connections. A single user opening twenty tabs to the same notification feed should not cost twenty connections. Cap to a sensible number (often three to five) and close the oldest when the cap is exceeded. Document the cap in the API contract.
Authenticate on the connection. SSE uses GET requests, so the browser will send cookies automatically. If you use bearer-token auth instead, send the token via cookie or as a short-lived query parameter, never as a long-lived URL token.
Instrument the stream. Log connection-open, connection-close, and per-event latency. Surface them in your normal observability stack. The notification feed is now a long-lived system; treat it like one.
Test the reconnect path. Kill the application process while a client is connected, restart it, and confirm the client reconnects and receives missed notifications. If the replay window or the bus path is broken, this is where you find out.
What you skip vs WebSockets
For this feature specifically, the WebSocket-route savings are noticeable.
You skip the upgrade handshake, the WebSocket framing layer, the manual reconnection-with-backoff logic, the heartbeat-and-presence dance, the binary-frame handling, and most of the corporate-firewall edge cases. None of those are hard individually, but together they add up to a feature that takes a sprint instead of an afternoon.
You give up bidirectional flow. If your notification feed ever needs the client to send messages back through the same connection (typing indicators on a chat, for example), SSE is the wrong transport and you should have started with WebSockets. For a strictly-notification feature, the trade is squarely in SSE's favor.
What good looks like
A production-grade live-notification feature on SSE has all of the following in place: a small, opinionated notification shape; a per-user fan-out endpoint with proper headers; durable storage of every notification with id-based replay; a message-bus layer for multi-process fan-out; heartbeats; middlebox-buffering disabled; per-user connection caps; cookie or short-lived-token auth; instrumented metrics; and a tested reconnect path.
None of those individually is hard. Together they convert a demo into a feature you stop thinking about. The protocol is small; the discipline around it is where the work is. The first time you ship a notification feed this way, you stop reaching for WebSockets for this kind of feature, and the engineering team at 137Foundry and other shops doing similar work have come to the same conclusion. Pick the simpler transport when it fits, and reinvest the savings in the surface area around it.
Top comments (0)