All posts
9 Sep 2026

Real-Time Notifications in Node.js: Building Scalable Event Streams with SSE and Redis

A practical, code-heavy architectural guide on implementing lightweight unidirectional real-time streaming with Server-Sent Events (SSE) and Redis Pub/Sub in Node.js.

Real-Time Notifications in Node.js: Building Scalable Event Streams with SSE and Redis

When developers think of real-time communication in web applications, WebSockets are almost always the default choice. However, for a vast class of use cases—such as notification feeds, stock tickers, activity streams, and live dashboards—bi-directional communication is overkill. If your server only needs to push data down to the client, Server-Sent Events (SSE) provide a simpler, more robust, and natively HTTP-compliant alternative.

In this architectural and practical guide, we will build a production-grade real-time notification system in Node.js using SSE. We’ll handle long-lived HTTP connections, manage automatic client reconnections, and scale horizontally across multiple Node.js instances using Redis Pub/Sub.


Why Server-Sent Events (SSE)?

Server-Sent Events is a standard built directly into the web platform. Unlike WebSockets, which require a protocol upgrade and custom framing, SSE operates strictly over standard HTTP.

code
+--------+                 +-------------------------+
| Client | --- HTTP GET -> | Node.js SSE Endpoint    |
|        | <-- Event Stream| (Long-Lived Connection) |
+--------+                 +-------------------------+

SSE vs. WebSockets

Feature Server-Sent Events (SSE) WebSockets
Directionality Unidirectional (Server to Client) Bi-directional (Full Duplex)
Protocol Standard HTTP WebSocket Protocol (ws://, wss://)
Reconnection Built-in (Automatic via Browser EventSource) Requires Custom Logic
Firewall/Proxies Works seamlessly over HTTP/2 & Proxies Often blocked or requires special proxy config
Complexity Extremely low Moderate to High

For notification systems, SSE wins on maintainability and operational simplicity.


Architecture Overview

To build a scalable notification pipeline, we need two core components:

  1. The Node.js SSE Server: Manages persistent client connections, formats payloads according to the SSE specification, and streams events.
  2. Redis Pub/Sub Layer: Acts as a message broker. When Node Instance A receives an event to push to User X, it publishes it to Redis. All Node instances subscribe to Redis, ensuring User X receives the notification even if they are connected to Node Instance B.

Step 1: Setting up the Node.js SSE Endpoint

Let’s start by creating a clean Express application that handles long-lived client connections. We need to set specific headers to prevent buffering and keep the HTTP connection alive.

Project Setup

mkdir sse-notifications
cd sse-notifications
npm init -y
npm install express redis cors

server.js - Core SSE Handler

const express = require('express');
const cors = require('cors');

const app = express();
app.use(cors());
app.use(express.json());

// In-memory store for connected clients (Single-instance approach)
// Key: userId, Value: Array of Express Response objects
const clients = new Map();

app.get('/api/notifications/stream', (req, res) => {
  const userId = req.query.userId;

  if (!userId) {
    return res.status(400).send('Missing userId query parameter');
  }

  // 1. Set mandatory headers for SSE
  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 if applicable

  // Flush headers to establish the stream immediately
  res.flushHeaders();

  // Send an initial connection established event
  res.write(`data: ${JSON.stringify({ type: 'CONNECTED', message: 'Connected to notification stream' })}\n\n`);

  // Register client
  if (!clients.has(userId)) {
    clients.set(userId, []);
  }
  clients.get(userId).push(res);

  console.log(`Client connected: User ${userId} (Total: ${clients.get(userId).length})`);

  // Keep-alive heartbeat every 30 seconds to prevent proxy timeouts
  const heartbeat = setInterval(() => {
    res.write(':\n\n'); // SSE comment line as heartbeat
  }, 30000);

  // Clean up on client disconnect
  req.on('close', () => {
    clearInterval(heartbeat);
    const userClients = clients.get(userId);
    if (userClients) {
      const index = userClients.indexOf(res);
      if (index !== -1) {
        userClients.splice(index, 1);
      }
      if (userClients.length === 0) {
        clients.delete(userId);
      }
    }
    console.log(`Client disconnected: User ${userId}`);
  });
});

// Helper to send message to a specific user
function sendToUser(userId, data) {
  const userClients = clients.get(userId);
  if (userClients) {
    userClients.forEach(res => {
      res.write(`data: ${JSON.stringify(data)}\n\n`);
    });
  }
}

// Trigger endpoint to test notifications
app.post('/api/notifications/send', (req, res) => {
  const { userId, notification } = req.body;
  sendToUser(userId, notification);
  res.status(200).json({ success: true, message: 'Notification dispatched' });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`SSE Server running on port ${PORT}`);
});

Step 2: Scaling with Redis Pub/Sub

The implementation above works great for a single server instance. However, if you deploy behind a load balancer (like AWS ALB or Nginx) with multiple Node.js processes, User X might connect to Instance 1 while the notification trigger hits Instance 2.

To solve this, we layer in Redis Pub/Sub. When any instance wants to notify a user, it publishes the event to Redis. All instances listen to Redis and forward the event to any local sockets connected to that user.

Updated Architecture with Redis

+------------------+     Pub      +-----------------+     Sub      +------------------+
| Node Instance A  | ---------->|                 | -----------> | Node Instance B  |
| (Triggers Event) |            |  Redis Pub/Sub  |              | (Has User Conn)  |
+------------------+            |                 |              +------------------+
                                +-----------------+

Implementing Redis Pub/Sub in Node.js

Update your project to use the official redis client (npm install redis).

const express = require('express');
const cors = require('cors');
const { createClient } = require('redis');

const app = express();
app.use(cors());
app.use(express.json());

const clients = new Map();

// Create Redis Publisher and Subscriber clients
const redisPublisher = createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379' });
const redisSubscriber = redisPublisher.duplicate();

async function setupRedis() {
  await redisPublisher.connect();
  await redisSubscriber.connect();

  // Subscribe to the global notification channel
  await redisSubscriber.subscribe('notifications', (message) => {
    const { userId, notification } = JSON.parse(message);
    
    // Deliver to local clients connected to this specific Node instance
    const userClients = clients.get(userId);
    if (userClients) {
      console.log(`[Redis Sub] Delivering notification to user ${userId}`);
      userClients.forEach(res => {
        res.write(`data: ${JSON.stringify(notification)}\n\n`);
      });
    }
  });

  console.log('Connected to Redis and subscribed to channels.');
}

setupRedis().catch(console.error);

// SSE Endpoint
app.get('/api/notifications/stream', (req, res) => {
  const userId = req.query.userId;
  if (!userId) return res.status(400).send('Missing userId');

  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no');
  res.flushHeaders();

  if (!clients.has(userId)) {
    clients.set(userId, []);
  }
  clients.get(userId).push(res);

  const heartbeat = setInterval(() => res.write(':\n\n'), 30000);

  req.on('close', () => {
    clearInterval(heartbeat);
    const userClients = clients.get(userId);
    if (userClients) {
      const index = userClients.indexOf(res);
      if (index !== -1) userClients.splice(index, 1);
      if (userClients.length === 0) clients.delete(userId);
    }
  });
});

// Broadcast notification across cluster via Redis
app.post('/api/notifications/send', async (req, res) => {
  const { userId, notification } = req.body;
  
  try {
    await redisPublisher.publish('notifications', JSON.stringify({ userId, notification }));
    res.status(200).json({ success: true, message: 'Notification published to cluster' });
  } catch (error) {
    console.error('Failed to publish notification:', error);
    res.status(500).json({ success: false, error: 'Internal server error' });
  });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Cluster Node running on port ${PORT}`);
});

Step 3: Client-Side Implementation & Automatic Reconnection

One of the greatest advantages of SSE is that the native browser EventSource API handles reconnections automatically. However, handling custom payloads and connection states requires a solid client-side wrapper.

Vanilla JavaScript Implementation

function connectNotificationStream(userId) {
  const eventSource = new EventSource(`http://localhost:3000/api/notifications/stream?userId=${userId}`);

  eventSource.onopen = (event) => {
    console.log('SSE connection established.');
  };

  eventSource.onmessage = (event) => {
    const data = JSON.parse(event.data);
    
    if (data.type === 'CONNECTED') {
      console.log(data.message);
      return;
    }

    // Handle incoming real-time notification
    displayNotificationBanner(data);
  };

  eventSource.onerror = (error) => {
    console.error('SSE connection error, browser will attempt auto-reconnect:', error);
    // EventSource automatically retries connection after a delay (default ~3 seconds)
  };
}

function displayNotificationBanner(notification) {
  const banner = document.createElement('div');
  banner.className = 'notification-card';
  banner.innerHTML = `
    <h4>${notification.title}</h4>
    <p>${notification.body}</p>
  `;
  document.getElementById('notifications-container').appendChild(banner);
}

// Initialize stream
connectNotificationStream('user_12345');

Production Best Practices

When taking an SSE + Redis notification engine to production, keep these architectural considerations in mind:

Note on Last-Event-ID: The SSE specification supports built-in state recovery via the Last-Event-ID header. If a connection drops, the browser sends this ID back upon reconnecting. In high-availability systems, back your event stream with a Redis Streams or PostgreSQL ring buffer to replay missed messages during disconnection windows.

1. Reverse Proxy Configuration (Nginx)

If you are using Nginx in front of Node.js, you must disable proxy buffering, or your events will pool up in memory instead of streaming instantly to the client:

location /api/notifications/stream {
    proxy_pass http://node_backend;
    proxy_http_version 1.1;
    proxy_set_header Connection '';
    proxy_buffering off;
    proxy_cache off;
    chunked_transfer_encoding on;
    proxy_read_timeout 3600s;
}

2. Connection Limits

Browsers enforce a limit on concurrent HTTP/1.1 connections per domain (typically 6 connections). Because SSE keeps an open connection, heavy SPA architectures can quickly exhaust this pool. Always use HTTP/2 in production, which multiplexes multiple streams over a single TCP connection.

3. Memory Leaks Management

Ensure that every Express req.on('close', ...) listener properly unregisters the response object from your tracking collections. Unmanaged Maps will cause silent memory leaks as client connections churn.


Conclusion

Server-Sent Events offer a remarkably lightweight, reliable, and standards-compliant way to stream real-time data from Node.js to modern web browsers. By pairing an Express SSE endpoint with a Redis Pub/Sub backend, you get a horizontally scalable notification architecture that avoids the protocol complexity of WebSockets while easily handling thousands of concurrent persistent connections.

More posts