| |

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

FeatureSSEWeb Push
DirectionServer → clientServer → push service → client
Page requiredYes (connection open)No (service worker wakes)
ProtocolHTTP (text/event-stream)Proprietary push protocol
PermissionNoneUser must grant
Payload sizeUnlimited (text)~4KB practical limit
ReconnectionAutomatic (browser)Handled by push service
Use caseLive updatesNotifications when closed

SSE Server Headers

HeaderValuePurpose
Content-Typetext/event-streamIdentifies SSE stream
Cache-Controlno-cachePrevents caching
Connectionkeep-aliveKeeps socket open

SSE Event Fields

FieldExamplePurpose
data:data: {"x":1}Payload (required)
event:event: updateCustom event type
id:id: 123Last event ID for reconnection
retry:retry: 5000Reconnection delay (ms)

Web Push Dependencies

ComponentPurpose
Service workerReceives push events
PushManager.subscribe()Creates subscription
VAPID keysAuthenticates server to push service
web-push libraryEncrypts 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

PitfallWhy It HappensFix
SSE not reconnectingMissing retry or server closesBrowser auto-reconnects; check server
SSE connection limit hit6 per domain on HTTP/1.1Use HTTP/2
Push subscription invalidUser cleared data or expiredHandle 410, remove subscription
VAPID key mismatchDifferent keys on client and serverUse same public key
Payload not encryptedMissing subscription keysEnsure keys object has p256dh and auth
Service worker not registeredWrong path or scopeCheck /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

ItemValue
SSE protocolHTTP text/event-stream
SSE client APIEventSource
SSE directionServer → client
SSE reconnectionAutomatic (browser)
Web PushService worker + push service
Web Push payloadEncrypted, ~4KB limit
VAPIDAuthenticates server to push service
web-push libraryEncrypts and sends notifications
Push subscriptionEndpoint + p256dh + auth keys
Expired subscriptionStatus 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, and retry .
  • 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!