All posts
3 Sep 2026

Real-Time Notifications in Node.js: SSE vs. WebSockets for Unidirectional Streaming

{

{ “title”: “Real-Time Notifications in Node.js: SSE vs. WebSockets for Unidirectional Streaming”, “summary”: “Learn how to build a scalable, production-ready real-time notification system in Node.js using Server-Sent Events (SSE) and Redis Pub/Sub.”, “tags”: [“Node.js”, “WebSockets”, “Backend”, “API Design”, “Real-Time”], “body”: “# Real-Time Notifications in Node.js: SSE vs. WebSockets for Unidirectional Streaming\n\nModern web applications thrive on real-time interactions. Whether it’s a live chat message, a stock price ticker, or a user notification, users expect interfaces to update instantly without manual page refreshes. \n\nWhen developers think of real-time communication in Node.js, WebSockets are almost always the default recommendation. However, for a vast category of features—specifically unidirectional data streams from server to client like notifications, live feeds, and progress trackers—WebSockets can be overkill.\n\nEnter Server-Sent Events (SSE). SSE provides a lightweight, HTTP-native protocol for pushing updates from the server to the browser over a single, long-lived connection.\n\nIn this comprehensive guide, we will compare SSE to WebSockets, build a complete real-time notification system using Node.js and Express, and scale it across multiple instances using Redis Pub/Sub.\n\n—\n\n## SSE vs. WebSockets: Understanding the Architectural Trade-offs\n\nBefore diving into code, let’s look at why you might choose one over the other for your notification pipeline.\n\n| Feature | Server-Sent Events (SSE) | WebSockets | Verdict for Notifications |\n| :— | :— | :— | :— |\n| Direction | Unidirectional (Server $\rightarrow$ Client) | Bidirectional (Server $\leftrightarrow$ Client) | SSE (Notifications are server-driven) |\n| Protocol | Standard HTTP/1.1 or HTTP/2 | Custom WebSocket Protocol (ws://, wss://) | SSE (Works through corporate proxies/firewalls) |\n| Reconnection | Built-in automatic browser reconnection | Manual implementation required | SSE (Saves boilerplate code) |\n| Binary Data | Requires Base64 encoding | Native binary support | SSE (Notifications are text/JSON) |\n| State Management | Standard HTTP connection lifecycle | Persistent TCP stateful connection | SSE (Easier to scale horizontally) |\n\n### When to use WebSockets\nChoose WebSockets when you need low-latency, high-frequency, bidirectional communication (e.g., multiplayer gaming, collaborative text editing, or real-time bi-directional chat).\n\n### When to use SSE\nChoose SSE when your clients only need to listen to updates from the server (e.g., social media feeds, dashboards, progress meters, and notifications).\n\n—\n\n## System Architecture\n\nTo build a robust notification system, our architecture needs to handle three core components:\n\n1. The Client Connection: Browsers maintain an open HTTP connection to a Node.js server using the standard EventSource API.\n2. The Node.js SSE Server: Express handles incoming subscription requests, formats the stream payload according to the SSE specification, and manages connection cleanups.\n3. The Distributed Backend (Redis Pub/Sub): In a production setup, your Node.js application runs behind a load balancer across multiple instances. If a notification is triggered on Instance A, clients connected to Instance B must still receive it. Redis Pub/Sub ensures messages are broadcasted to all running server instances.\n\n\n[Client Browser] --(GET /notifications)--\n |\n[Client Browser] --(GET /notifications)--+--> [Node.js Server 1] <---+\n | |\n[Client Browser] --(GET /notifications)--+--> [Node.js Server 2] <---+-- [Redis Pub/Sub]\n |\n [API Trigger / POST /notify]--+\n\n\n—\n\n## Step 1: Building the Node.js SSE Server\n\nLet’s start by initializing our project and building the core SSE endpoint using Express.\n\n### Project Setup\n\nInitialize a new Node.js project and install the required dependencies:\n\nbash\nmkdir sse-notifications\ncd sse-notifications\nnpm init -y\nnpm install express redis dotenv cors\n\n\n### Creating the Server (server.js)\n\nAn SSE response is simply a standard HTTP response with the Content-Type header set to text/event-stream. The connection must remain open, and messages must follow a strict text format (data: <payload>\\n\\n).\n\njavascript\nconst express = require('express');\nconst cors = require('cors');\nconst { createClient } = require('redis');\n\nconst app = express();\napp.use(cors());\napp.use(express.json());\n\nconst PORT = process.env.PORT || 3000;\n\n// Keep track of connected clients on this instance\nlet clients = [];\n\n// 1. SSE Endpoint for clients to subscribe\napp.get('/api/notifications/stream', (req, res) => {\n // Extract user ID from query params or auth headers\n const userId = req.query.userId;\n if (!userId) {\n return res.status(400).send('Missing userId parameter');\n }\n\n // Set mandatory headers for SSE\n res.setHeader('Content-Type', 'text/event-stream');\n res.setHeader('Cache-Control', 'no-cache');\n res.setHeader('Connection', 'keep-alive');\n res.flushHeaders();\n\n // Send an initial connection established event\n res.write(`data: ${JSON.stringify({ type: 'CONNECTED', message: 'Subscribed to notification stream' })}\\n\\n`);\n\n const client = {\n id: Date.now(),\n userId,\n res\n };\n\n clients.push(client);\n console.log(`Client connected: User ${userId} (Connection ID: ${client.id})`);\n\n // Remove client from active list when connection closes\n req.on('close', () => {\n clients = clients.filter(c => c.id !== client.id);\n console.log(`Client disconnected: User ${userId}`);\n });\n});\n\napp.listen(PORT, () => {\n console.log(`Notification service running on port ${PORT}`);\n});\n\n\n—\n\n## Step 2: Scaling Across Instances with Redis Pub/Sub\n\nIf you deploy this application behind a load balancer (like AWS ALB or Nginx), a user might hit Instance A while a trigger command hits Instance B. To solve this, we use Redis Pub/Sub.\n\nWhen a notification is dispatched, we publish it to a Redis channel. Every Node.js instance subscribes to this channel, picks up the message, and pushes it down to any matching connected clients it owns.\n\n### Expanding server.js with Redis\n\njavascript\n// Redis Clients Setup\nconst redisPub = createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379' });\nconst redisSub = redisPub.duplicate();\n\nconst CHANNEL_NAME = 'notifications_channel';\n\nasync function setupRedis() {\n await redisPub.connect();\n await redisSub.connect();\n\n // Subscribe to the Redis channel\n await redisSub.subscribe(CHANNEL_NAME, (message) => {\n const notification = JSON.parse(message);\n broadcastToLocalClients(notification);\n });\n\n console.log('Connected to Redis and subscribed to channel:', CHANNEL_NAME);\n}\n\n// Helper to send data to clients connected to *this* server instance\nfunction broadcastToLocalClients(notification) {\n clients.forEach(client => {\n // If targetUserId is specified, only send to that user. Otherwise, broadcast to all.\n if (!notification.targetUserId || client.userId === notification.targetUserId) {\n client.res.write(`data: ${JSON.stringify(notification)}\n\n`);\n }\n });\n}\n\n// 2. Trigger Notification Endpoint\napp.post('/api/notifications/send', async (req, res) => {\n const { targetUserId, title, body } = req.body;\n\n const notification = {\n id: Date.now(),\n targetUserId: targetUserId || null, // null means broadcast\n title,\n body,\n timestamp: new Date().toISOString()\n };\n\n try {\n // Publish to Redis. All Node.js instances will receive this.\n await redisPub.publish(CHANNEL_NAME, JSON.stringify(notification));\n res.status(200).json({ success: true, message: 'Notification published' });\n } catch (error) {\n console.error('Failed to publish notification:', error);\n res.status(500).json({ success: false, error: 'Internal server error' });\n }\n});\n\nsetupRedis().catch(console.error);\n\n\n—\n\n## Step 3: Consuming SSE on the Client-Side\n\nUnlike WebSockets, which require a specialized library or custom wrapper, the browser has native support for SSE via the EventSource interface.\n\nCreate an index.html file to test your real-time notification pipeline:\n\nhtml\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <title>SSE Notification Test</title>\n <style>\n body { font-family: system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; }\n #notifications { border: 1px solid #ccc; padding: 15px; height: 300px; overflow-y: scroll; background: #f9f9f9; }\n .notification { background: #fff; padding: 10px; margin-bottom: 8px; border-left: 4px solid #0070f3; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }\n </style>\n</head>\n<body>\n <h1>Live Notifications</h1>\n <p>Logged in as User ID: <strong id=\"current-user\">user_123</strong></p>\n \n <h3>Activity Feed:</h3>\n <div id=\"notifications\"></div>\n\n <script>\n const userId = 'user_123';\n document.getElementById('current-user').innerText = userId;\n\n const container = document.getElementById('notifications');\n\n // Establish SSE Connection\n const eventSource = new EventSource(`http://localhost:3000/api/notifications/stream?userId=${userId}`);\n\n eventSource.onopen = () => {\n console.log('SSE connection established.');\n };\n\n // Listen for incoming messages\n eventSource.onmessage = (event) => {\n const data = JSON.parse(event.data);\n console.log('Received notification:', data);\n\n if (data.type === 'CONNECTED') return;\n\n const div = document.createElement('div');\n div.className = 'notification';\n div.innerHTML = `<strong>${data.title}</strong><p>${data.body}</p><small>${new Date(data.timestamp).toLocaleTimeString()}</small>`;\n \n container.prepend(div);\n };\n\n eventSource.onerror = (error) => {\n console.error('SSE connection error. Browser will attempt reconnect automatically.', error);\n };\n </script>\n</body>\n</html>\n\n\n—\n\n## Production Best Practices for SSE\n\nWhen taking your SSE notification system to production, keep the following considerations in mind:\n\n### 1. Heartbeats (Keep-Alive Pings)\nLoad balancers, proxies, and NAT routers often drop idle TCP connections after 60 seconds of inactivity. To prevent silent disconnects, send periodic comment lines or empty ping events from your server.\n\njavascript\n// Send a heartbeat every 30 seconds\nconst heartbeatInterval = setInterval(() => {\n clients.forEach(client => {\n client.res.write(':ping\\n\\n'); // SSE comments start with a colon\n });\n}, 30000);\n\n// Clean up interval on server shutdown\napp.on('close', () => clearInterval(heartbeatInterval));\n\n\n### 2. Connection Limits\nStandard browsers limit the number of open HTTP/1.1 connections to a single domain (typically 6 connections per domain). If a user opens multiple tabs, they can quickly exhaust this limit. \n\n* Solution: Upgrade your infrastructure to HTTP/2, which supports multiplexing hundreds of streams over a single TCP connection.\n\n### 3. Missing Events During Disconnections\nIf a client temporarily loses internet access, EventSource automatically attempts to reconnect and sends the Last-Event-ID header. You can track sequence numbers or store missed notifications in a database (e.g., Redis Streams or PostgreSQL) to replay missed events upon reconnection.\n\n—\n\n## Conclusion\n\nServer-Sent Events offer a simpler, leaner alternative to WebSockets when your architecture calls for unidirectional, server-driven updates. By pairing Express with Redis Pub/Sub, you get an infinitely scalable notification service that handles multi-instance deployments seamlessly, all while leveraging standard HTTP infrastructure.” }

More posts