Node.js 61 🟢 Push Notifications and Server-Sent Events (SSE)
Real-time communication between server and client has two distinct problems to solve. Sometimes the server needs to push data to an open page—live updates, stock tickers, notifications while the user is active. Other times, the server needs to reach the user even when the app is closed—a message notification, a reminder, an alert. These are not the same problem, and they do not share the same solution.
Server-Sent Events (SSE) solves the first problem. It is a simple, HTTP-based protocol where the server holds a persistent connection open and streams text events to the client. The browser exposes this through the EventSource API, which handles reconnection automatically and delivers messages as they arrive . It is unidirectional: the server sends, the client receives. For live dashboards, news feeds, or any scenario where updates flow one way, SSE is the right tool.
Web Push solves the second problem. It uses service workers to receive messages from a push service even when the web page is not open, and displays them as native operating system notifications . The Node.js web-push library handles the encryption and VAPID authentication required to send these notifications . For reaching users outside your application, Web Push is the only standard web mechanism.
This chapter covers both: how to build an SSE endpoint in Node.js, how to subscribe to and send Web Push notifications, and how to choose between them.
Key point: SSE keeps a server-to-client connection open for live updates while the page is active; Web Push delivers notifications through a push service to a service worker even when the page is closed. They solve different problems and are complementary, not competing.
Why two mechanisms exist
The active page problem. When a user has your web application open, you often need to push data to them without them requesting it. Polling—the client asking “any updates?” every few seconds—works but is wasteful and introduces latency. SSE solves this with a single long-lived HTTP connection where the server writes events as they happen . The browser’s EventSource handles reconnection and message parsing, so the client code is minimal.
The closed page problem. When the user closes the tab, an SSE connection dies. If you need to notify them of something—a new message, an order status change—you cannot reach them through an open connection because there is none. Web Push solves this by routing messages through a push service (operated by the browser vendor) that can wake a service worker even when no page is open .
The protocol difference. SSE is plain HTTP. It works through firewalls, load balancers, and proxies with no special configuration. It uses the text/event-stream MIME type and a simple line-based format . Web Push uses a proprietary protocol between your server and the push service, with payload encryption and VAPID authentication. It requires a service worker and user permission.
The payload difference. SSE can send any text data, of any size, as often as needed. Web Push has practical payload limits (often around 4KB) and is designed for discrete notifications, not streaming data . This makes Web Push unsuitable for live updates but perfect for alerts.
The state difference. SSE is stateful: the connection persists, and the server knows which clients are connected. Web Push is stateless: you send a message to a push service, which delivers it whenever the device is reachable. There is no guarantee of immediate delivery, and no connection to monitor .
a. Server-Sent Events in Node.js
SSE works by holding an HTTP response open and writing events to it in a specific text format. The server sets three headers and then writes event blocks separated by double newlines.
const express = require('express');
const app = express();
app.get('/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.write('data: Connected to event stream\n\n');
const intervalId = setInterval(() => {
const data = { time: new Date().toISOString(), message: 'Server update' };
res.write(`data: ${JSON.stringify(data)}\n\n`);
}, 5000);
req.on('close', () => {
clearInterval(intervalId);
});
});
app.listen(3000);
The Content-Type: text/event-stream header tells the browser this is an SSE stream . Cache-Control: no-cache prevents intermediaries from caching the stream. Connection: keep-alive keeps the socket open. The req.on('close') handler is essential—it fires when the client disconnects, allowing you to stop the interval and clean up resources .
The event format is line-based. Each field is written as field: value followed by a newline, and events are separated by a blank line (\n\n). The data: field contains the payload. An event: field names a custom event type. An id: field sets the last event ID for reconnection. A retry: field sets the reconnection delay in milliseconds .
res.write('event: userJoined\n');
res.write(`data: ${JSON.stringify({ username: 'alice' })}\n\n`);
On the client, the EventSource object receives these events. A message without an event: field triggers the default message event. A message with event: userJoined triggers a userJoined event, which you listen for with addEventListener .
const source = new EventSource('/events');
source.addEventListener('userJoined', (e) => {
const data = JSON.parse(e.data);
console.log(`${data.username} joined`);
});
The browser automatically reconnects if the connection drops, using the Last-Event-ID header to tell the server where to resume . You can control the reconnection delay with the retry: field.
One important limitation: when not using HTTP/2, browsers limit SSE connections to six per domain. On HTTP/2, this limit is negotiated and defaults to 100 . For applications that open many tabs, HTTP/2 is strongly recommended.
b. Web Push with the web-push library
Web Push requires three things: a service worker registered in the browser, a push subscription obtained from the browser’s push service, and a server that can send encrypted messages to that subscription using VAPID authentication .
The subscription flow starts in the browser. The client asks the PushManager to subscribe, passing the server’s VAPID public key:
const registration = await navigator.serviceWorker.register('/sw.js');
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: '<VAPID public key>'
});
// Send subscription to server
await fetch('/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
headers: { 'Content-Type': 'application/json' }
});
The PushSubscription contains an endpoint (a unique URL for this subscription) and keys with p256dh and auth values used for payload encryption . The server stores this subscription object.
On the server, the web-push library sends notifications:
const webpush = require('web-push');
webpush.setVapidDetails(
'mailto:admin@example.com',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
const subscription = {
endpoint: 'https://fcm.googleapis.com/...',
keys: {
auth: '...',
p256dh: '...'
}
};
const payload = JSON.stringify({
title: 'New Message',
body: 'You have a new message',
icon: '/icon.png'
});
await webpush.sendNotification(subscription, payload);
The VAPID (Voluntary Application Server Identification) keys identify your server to the push service. You generate them once with webpush.generateVAPIDKeys() and store them securely . The public key goes to the client; the private key stays on the server.
The payload is encrypted so only the subscribed browser can decrypt it. The library handles the encryption using the subscription’s keys . If the subscription is invalid or expired, sendNotification throws an error, and you should remove that subscription from your database.
The service worker receives the push event and displays a notification:
// sw.js
self.addEventListener('push', (event) => {
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: data.icon
})
);
});
The web-push library also supports options like TTL (how long the push service retains the message), urgency (delivery priority), and topic (for coalescing notifications) .
c. Choosing between SSE and Web Push
The choice is determined by one question: is the user currently looking at your application? If yes, SSE. If no, Web Push.
SSE is appropriate for live dashboards, chat applications where the client sends messages via POST but receives them via SSE, real-time notifications within an open page, and any streaming data scenario . It is simple to implement, works over standard HTTP, and requires no user permission.
Web Push is appropriate for reaching users when they are not actively using your application: new message alerts, order status updates, reminders, and any notification that should appear in the operating system’s notification center . It requires user permission, a service worker, and VAPID keys, but it is the only way to reach users outside the browser.
They can be used together. An application might use SSE for live updates while the page is open and Web Push for notifications when it is closed. The server sends via SSE when a connection exists and via Web Push when it does not.
Other options exist. WebSockets provide bidirectional communication, which SSE does not, but they are more complex and do not integrate as cleanly with HTTP infrastructure . For purely server-to-client communication, SSE is simpler and sufficient. For bidirectional, WebSockets are necessary.
Complete Example Session
The following session builds both an SSE endpoint and a Web Push sender in a single Node.js application.
// ============================================
// PART 1: DEPENDENCIES AND SETUP
// ============================================
const express = require('express');
const webpush = require('web-push');
const app = express();
app.use(express.json());
// VAPID keys - generate once with webpush.generateVAPIDKeys()
const VAPID_PUBLIC = process.env.VAPID_PUBLIC_KEY;
const VAPID_PRIVATE = process.env.VAPID_PRIVATE_KEY;
webpush.setVapidDetails(
'mailto:admin@example.com',
VAPID_PUBLIC,
VAPID_PRIVATE
);
// In-memory storage for subscriptions (use a database in production)
const subscriptions = new Map();
// ============================================
// PART 2: SSE ENDPOINT
// ============================================
const sseClients = new Set();
app.get('/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
sseClients.add(res);
res.write('data: Connected\n\n');
req.on('close', () => {
sseClients.delete(res);
});
});
// ============================================
// PART 3: BROADCAST VIA SSE
// ============================================
function broadcastSSE(event, data) {
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
for (const client of sseClients) {
client.write(message);
}
}
// ============================================
// PART 4: SUBSCRIBE ENDPOINT FOR WEB PUSH
// ============================================
app.post('/subscribe', (req, res) => {
const subscription = req.body;
const id = subscription.endpoint;
subscriptions.set(id, subscription);
res.status(201).json({ message: 'Subscribed' });
});
// ============================================
// PART 5: SEND WEB PUSH NOTIFICATION
// ============================================
async function sendPush(subscription, payload) {
try {
await webpush.sendNotification(subscription, JSON.stringify(payload));
} catch (err) {
if (err.statusCode === 410) {
// Subscription expired, remove it
subscriptions.delete(subscription.endpoint);
}
throw err;
}
}
// ============================================
// PART 6: TRIGGER BOTH CHANNELS
// ============================================
app.post('/notify', async (req, res) => {
const { title, body } = req.body;
// Send via SSE to connected clients
broadcastSSE('notification', { title, body });
// Send via Web Push to all subscriptions
const payload = { title, body };
const results = await Promise.allSettled(
[...subscriptions.values()].map(sub => sendPush(sub, payload))
);
res.json({ sseClients: sseClients.size, pushSent: results.length });
});
// ============================================
// PART 7: SERVICE WORKER (sw.js)
// ============================================
// self.addEventListener('push', (event) => {
// const data = event.data.json();
// event.waitUntil(
// self.registration.showNotification(data.title, {
// body: data.body,
// icon: '/icon.png'
// })
// );
// });
// ============================================
// PART 8: CLIENT SIDE SSE
// ============================================
// const source = new EventSource('/events');
// source.addEventListener('notification', (e) => {
// const data = JSON.parse(e.data);
// showToast(data.title, data.body);
// });
// ============================================
// PART 9: CLIENT SIDE PUSH SUBSCRIPTION
// ============================================
// const reg = await navigator.serviceWorker.register('/sw.js');
// const sub = await reg.pushManager.subscribe({
// userVisibleOnly: true,
// applicationServerKey: '<VAPID_PUBLIC_KEY>'
// });
// await fetch('/subscribe', {
// method: 'POST',
// body: JSON.stringify(sub),
// headers: { 'Content-Type': 'application/json' }
// });
// ============================================
// PART 10: START SERVER
// ============================================
app.listen(3000, () => {
console.log('Server on port 3000');
console.log('SSE: GET /events');
console.log('Subscribe: POST /subscribe');
console.log('Notify: POST /notify');
});
The ten parts cover dependencies and VAPID setup, the SSE endpoint, SSE broadcasting, the push subscription endpoint, the push sending function with expiration handling, a trigger that uses both channels, the service worker, client-side SSE, client-side push subscription, and the server startup. What the session demonstrates is that SSE and Web Push are complementary: the same notification can be delivered through both channels, reaching users whether they are actively viewing the page or not.
Quick Reference
SSE vs Web Push
| Feature | SSE | Web Push |
|---|---|---|
| Direction | Server → client | Server → push service → client |
| Page required | Yes (connection open) | No (service worker wakes) |
| Protocol | HTTP (text/event-stream) | Proprietary push protocol |
| Permission | None | User must grant |
| Payload size | Unlimited (text) | ~4KB practical limit |
| Reconnection | Automatic (browser) | Handled by push service |
| Use case | Live updates | Notifications when closed |
SSE Server Headers
| Header | Value | Purpose |
|---|---|---|
Content-Type | text/event-stream | Identifies SSE stream |
Cache-Control | no-cache | Prevents caching |
Connection | keep-alive | Keeps socket open |
SSE Event Fields
| Field | Example | Purpose |
|---|---|---|
data: | data: {"x":1} | Payload (required) |
event: | event: update | Custom event type |
id: | id: 123 | Last event ID for reconnection |
retry: | retry: 5000 | Reconnection delay (ms) |
Web Push Dependencies
| Component | Purpose |
|---|---|
| Service worker | Receives push events |
PushManager.subscribe() | Creates subscription |
| VAPID keys | Authenticates server to push service |
web-push library | Encrypts and sends notifications |
Best Practices
✅ Do This:
res.write(`data: ${JSON.stringify(data)}\n\n`); // ✅ SSE format
req.on('close', () => cleanup()); // ✅ SSE cleanup
webpush.setVapidDetails(subject, publicKey, privateKey); // ✅ VAPID auth
if (err.statusCode === 410) removeSubscription(); // ✅ handle expired
❌ Don’t Do This:
res.write('data: hello'); // ❌ missing \n\n
// forget req.on('close') // ❌ memory leak
webpush.sendNotification(sub, payload); // ❌ no error handling
// store VAPID private key in client code // ❌ secret leak
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| SSE not reconnecting | Missing retry or server closes | Browser auto-reconnects; check server |
| SSE connection limit hit | 6 per domain on HTTP/1.1 | Use HTTP/2 |
| Push subscription invalid | User cleared data or expired | Handle 410, remove subscription |
| VAPID key mismatch | Different keys on client and server | Use same public key |
| Payload not encrypted | Missing subscription keys | Ensure keys object has p256dh and auth |
| Service worker not registered | Wrong path or scope | Check /sw.js path |
Real-World Examples
1. Live stock ticker
setInterval(() => res.write(`data: ${price}\n\n`), 1000);
2. Chat message stream
broadcastSSE('message', { user, text });
3. Order status updates
broadcastSSE('orderUpdate', { id, status });
4. Push notification on new message
sendPush(sub, { title: 'New message', body: 'From Alice' });
5. SSE with custom event
res.write('event: userJoined\n');
res.write(`data: ${JSON.stringify(user)}\n\n`);
6. Push with TTL
webpush.sendNotification(sub, payload, { TTL: 3600 });
7. Reconnection with last ID
res.write(`id: ${eventId}\n`);
8. Clean up expired subscriptions
if (err.statusCode === 410 || err.statusCode === 404) subscriptions.delete(id);
9. SSE keep-alive comment
setInterval(() => res.write(': keep-alive\n\n'), 30000);
10. Push urgency
webpush.sendNotification(sub, payload, { urgency: 'high' });
Visual
SSE Connection Flow
┌──────────────────────────────────────────────┐
│ CLIENT │
│ const source = new EventSource('/events') │
│ │ │
│ ▼ │
│ GET /events │
│ Accept: text/event-stream │
│ │ │
│ ▼ │
│ SERVER │
│ res.setHeader('Content-Type', 'text/event- │
│ stream') │
│ res.write('data: hello\n\n') │
│ │ │
│ ▼ │
│ Connection stays open │
│ Server writes more events as they happen │
│ │ │
│ ▼ │
│ Client receives events via onmessage │
│ Browser auto-reconnects if dropped │
└──────────────────────────────────────────────┘
Web Push Flow
┌──────────────────────────────────────────────┐
│ BROWSER │
│ Service Worker registered │
│ PushManager.subscribe(vapidPublicKey) │
│ │ │
│ ▼ │
│ PushSubscription { endpoint, keys } │
│ │ │
│ ▼ │
│ SERVER (stores subscription) │
│ │ │
│ ▼ │
│ webpush.sendNotification(sub, payload) │
│ │ │
│ ▼ │
│ PUSH SERVICE (FCM, Mozilla, etc.) │
│ │ │
│ ▼ │
│ Browser wakes service worker │
│ │ │
│ ▼ │
│ sw.js: self.addEventListener('push', ...) │
│ showNotification(title, body) │
└──────────────────────────────────────────────┘
When to Use Which
┌──────────────────────────────────────────────┐
│ USER IS LOOKING AT THE PAGE │
│ ┌────────────────────────────────────────┐ │
│ │ SSE │ │
│ │ - Live updates │ │
│ │ - Streaming data │ │
│ │ - No permission needed │ │
│ │ - Simple HTTP │ │
│ └────────────────────────────────────────┘ │
│ │
│ USER IS NOT LOOKING AT THE PAGE │
│ ┌────────────────────────────────────────┐ │
│ │ WEB PUSH │ │
│ │ - Notifications │ │
│ │ - Alerts │ │
│ │ - Requires permission │ │
│ │ - Service worker wakes │ │
│ └────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
SSE Event Format
┌──────────────────────────────────────────────┐
│ data: This is a message │
│ (blank line separates events) │
│ │
│ event: userJoined │
│ data: {"name":"alice"} │
│ id: 42 │
│ (blank line) │
│ │
│ retry: 5000 │
│ data: reconnection time set │
│ (blank line) │
│ │
│ Fields: data, event, id, retry │
│ Separator: \n\n (double newline) │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| SSE protocol | HTTP text/event-stream |
| SSE client API | EventSource |
| SSE direction | Server → client |
| SSE reconnection | Automatic (browser) |
| Web Push | Service worker + push service |
| Web Push payload | Encrypted, ~4KB limit |
| VAPID | Authenticates server to push service |
web-push library | Encrypts and sends notifications |
| Push subscription | Endpoint + p256dh + auth keys |
| Expired subscription | Status 410, remove it |
Key takeaways:
- SSE is for active pages. It keeps an HTTP connection open and streams events from server to client. The browser reconnects automatically .
- Web Push is for closed pages. It uses a service worker and a push service to deliver notifications even when the app is not running .
- SSE uses a simple text format. Events are separated by double newlines, with fields for
data,event,id, andretry. - Web Push requires VAPID. The server identifies itself with a public key to the push service. The private key stays on the server .
- Push subscriptions expire. Handle 410 errors by removing the subscription from your database.
- SSE has a connection limit. Without HTTP/2, browsers limit SSE connections to six per domain .
- They are complementary. An application can use SSE for live updates and Web Push for notifications when the page is closed.
Remember: SSE and Web Push solve different problems. SSE is a simple, HTTP-based mechanism for streaming updates to a page that is currently open. Web Push is a more complex mechanism for reaching users through the operating system’s notification system when the page is not open. Choose SSE for live dashboards, chat, and real-time data. Choose Web Push for alerts, reminders, and notifications that must reach the user regardless of whether your application is running. They can coexist, and often should.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!