Looking for a lightweight way to push real-time updates from your server to the browser without the overhead of WebSockets? Server-Sent Events (SSE) in Node.js might be exactly what you need. In this practical tutorial, we will build a live notification feed using Express, cover reconnection handling on the client, and share production-ready patterns we use at Box Software when shipping real-time features.
What Are Server-Sent Events (SSE)?
Server-Sent Events are a standard web technology that lets a server push data to a client over a single, long-lived HTTP connection. Unlike WebSockets, SSE is one-way: only the server sends messages to the client. This makes it simpler, lighter, and easier to deploy behind proxies and load balancers.
When Should You Use SSE Instead of WebSockets?
- Live notifications, activity feeds, or toasts
- Real-time dashboards and charts
- Streaming AI/LLM responses (token by token)
- Log streaming and progress bars
- Stock tickers, sports scores, or price updates
SSE vs WebSockets vs Polling
| Feature | SSE | WebSockets | Polling |
|---|---|---|---|
| Direction | Server to client | Bi-directional | Client pulls |
| Protocol | HTTP/HTTPS | ws:// wss:// | HTTP/HTTPS |
| Auto-reconnect | Built-in | Manual | N/A |
| Complexity | Low | Medium/High | Very low |
| Proxy friendly | Yes | Sometimes tricky | Yes |

Setting Up the Node.js and Express Project
Let’s start with a minimal setup. Create a new folder and initialize the project:
mkdir sse-notifications && cd sse-notifications
npm init -y
npm install express
Create an index.js file. We will build the SSE endpoint step by step.
Building the SSE Endpoint in Express
An SSE endpoint is just an HTTP route that keeps the connection open and writes events using the text/event-stream content type.
const express = require('express');
const app = express();
app.use(express.static('public'));
// Store connected clients
const clients = new Set();
app.get('/events', (req, res) => {
// Required SSE headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // Disable Nginx buffering
res.flushHeaders();
// Send a welcome event
res.write(`retry: 5000\n\n`);
res.write(`event: connected\ndata: ${JSON.stringify({ msg: 'Welcome!' })}\n\n`);
// Track this client
const clientId = Date.now();
const client = { id: clientId, res };
clients.add(client);
// Handle disconnect
req.on('close', () => {
clients.delete(client);
console.log(`Client ${clientId} disconnected`);
});
});
app.listen(3000, () => console.log('SSE server on http://localhost:3000'));
Understanding the SSE Message Format
- data: the payload (usually JSON, serialized as a string)
- event: optional event name to filter on the client
- id: optional unique ID for the message (used for reconnection)
- retry: tells the browser how long to wait before reconnecting (ms)
- Each message must end with two newlines (
\n\n)

Broadcasting Notifications to All Clients
Now let’s add an endpoint that broadcasts a notification to every connected client. In real projects this could be triggered by a database change, a queue worker, or a webhook.
app.use(express.json());
let lastEventId = 0;
function broadcast(eventName, payload) {
lastEventId++;
const message = `id: ${lastEventId}\nevent: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`;
for (const client of clients) {
client.res.write(message);
}
}
app.post('/notify', (req, res) => {
const { title, body } = req.body;
broadcast('notification', { title, body, at: new Date().toISOString() });
res.json({ ok: true, delivered: clients.size });
});
// Demo: push a heartbeat every 20s to keep proxies happy
setInterval(() => {
for (const client of clients) {
client.res.write(`: ping\n\n`);
}
}, 20000);
The line starting with : is an SSE comment. It keeps the connection alive without triggering an event on the client. This is essential when running behind Nginx, Cloudflare, or an AWS load balancer with idle timeouts.
Building the Client with EventSource
The browser exposes the native EventSource API. It handles reconnection automatically. Create public/index.html:
<!DOCTYPE html>
<html>
<head><title>Live Notifications</title></head>
<body>
<h1>Live Feed</h1>
<ul id="feed"></ul>
<script>
const feed = document.getElementById('feed');
const source = new EventSource('/events');
source.addEventListener('connected', (e) => {
console.log('Connected:', JSON.parse(e.data));
});
source.addEventListener('notification', (e) => {
const n = JSON.parse(e.data);
const li = document.createElement('li');
li.textContent = `[${n.at}] ${n.title} - ${n.body}`;
feed.prepend(li);
});
source.onerror = (err) => {
console.warn('SSE error, browser will retry...', err);
};
</script>
</body>
</html>
Trigger a notification with curl:
curl -X POST http://localhost:3000/notify \
-H "Content-Type: application/json" \
-d '{"title":"New order","body":"Order #4021 received"}'

Reconnection Handling and Missed Events
One big advantage of SSE is that the browser automatically reconnects. When it does, it sends the Last-Event-ID header with the last id it received. Use this on the server to replay missed events.
const eventHistory = []; // In production, use Redis or a database
const MAX_HISTORY = 100;
function storeAndBroadcast(eventName, payload) {
lastEventId++;
const event = { id: lastEventId, event: eventName, data: payload };
eventHistory.push(event);
if (eventHistory.length > MAX_HISTORY) eventHistory.shift();
const message = `id: ${event.id}\nevent: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n`;
for (const client of clients) client.res.write(message);
}
app.get('/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
// Replay missed events
const lastId = parseInt(req.headers['last-event-id'] || '0', 10);
if (lastId > 0) {
const missed = eventHistory.filter(e => e.id > lastId);
for (const e of missed) {
res.write(`id: ${e.id}\nevent: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`);
}
}
const client = { id: Date.now(), res };
clients.add(client);
req.on('close', () => clients.delete(client));
});
Production Tips for SSE with Node.js
- Disable proxy buffering: set
X-Accel-Buffering: nofor Nginx andproxy_buffering off;in the Nginx config. - Use HTTP/2 or HTTP/3: older browsers limit HTTP/1.1 to 6 connections per domain. HTTP/2 removes this bottleneck.
- Send heartbeats: a comment line every 15 to 30 seconds prevents idle timeouts.
- Authentication: EventSource does not support custom headers. Use cookies or a short-lived token in the query string.
- Scale horizontally: use Redis Pub/Sub or a message broker so every Node instance can broadcast to its own connected clients.
- Compression: avoid gzip on the SSE route, it can delay message flushing.
- Graceful shutdown: close active responses on
SIGTERMso clients reconnect to healthy instances.
Scaling Across Multiple Node Instances with Redis
const Redis = require('ioredis');
const sub = new Redis();
const pub = new Redis();
sub.subscribe('notifications');
sub.on('message', (channel, message) => {
const { event, data } = JSON.parse(message);
storeAndBroadcast(event, data);
});
app.post('/notify', (req, res) => {
pub.publish('notifications', JSON.stringify({
event: 'notification',
data: req.body
}));
res.json({ ok: true });
});

Common Pitfalls to Avoid
- Forgetting the double newline
\n\nat the end of each message - Sending non-string data without
JSON.stringify - Not cleaning up clients on disconnect (memory leak)
- Trying to send binary data (use WebSockets for that)
- Forgetting that EventSource does not support POST or custom headers
Frequently Asked Questions
Is SSE supported in all modern browsers?
Yes. All modern browsers including Chrome, Firefox, Safari, and Edge support the EventSource API. Internet Explorer never supported it, but polyfills exist if you need legacy coverage.
Can I use SSE with HTTPS?
Absolutely. In fact, using HTTPS with HTTP/2 is the recommended setup because it removes the 6-connection-per-domain limit of HTTP/1.1.
How many concurrent SSE connections can Node.js handle?
A single Node.js process can typically handle tens of thousands of open SSE connections since each one is idle most of the time. Tune ulimit, use clustering, and offload broadcasting through Redis Pub/Sub for very large deployments.
SSE vs WebSockets: which one should I choose in 2026?
Choose SSE when communication is one-way from server to client, when you want simplicity, automatic reconnection, and easy HTTP tooling. Choose WebSockets when you need true bidirectional communication like chat, collaborative editing, or gaming.
Does SSE work with serverless platforms?
SSE requires long-lived connections, which most serverless functions do not support well due to execution time limits. Use a container platform, a VM, or an edge runtime that supports streaming responses (like Cloudflare Workers or Vercel Edge Functions with streaming).
Can I send events to a specific user only?
Yes. Store clients in a Map keyed by user ID (extracted from the session or JWT during the SSE handshake), then write only to that user’s response objects when broadcasting.
Wrapping Up
Server-Sent Events give Node.js developers a clean, low-overhead way to push real-time data to browsers using nothing more than plain HTTP. For notification feeds, dashboards, and streaming responses, SSE is often the smarter choice over WebSockets.
At Box Software, we regularly implement SSE-based architectures for clients who need real-time features without the operational burden of maintaining a WebSocket layer. If you need help designing or scaling a real-time system in Node.js, get in touch with our team.
