Skip to main content
  1. Languages/
  2. Nodejs Guides/

Mastering Real-Time Node.js: WebSockets vs. Server-Sent Events

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

In the landscape of modern web development, “refreshing the page” is an archaic concept. Whether it’s a stock trading dashboard, a collaborative document editor, or a simple notification feed, users in 2025 expect data to flow instantly.

For Node.js developers, the event-driven, non-blocking I/O model makes the runtime a perfect candidate for handling these persistent connections. However, choosing the right transport mechanism remains a common architectural stumbling block. Should you go full-duplex with WebSockets, or is the unidirectional simplicity of Server-Sent Events (SSE) sufficient?

In this guide, we will move beyond the basics. We will implement both technologies from scratch using Node.js, analyze their handshake protocols, and discuss production-grade considerations like connection limits and load balancing.

The State of Real-Time in 2025
#

Before writing code, we need to understand the architectural distinction. The era of Long Polling is effectively over for most standard use cases. The decision now rests between HTTP-native streams (SSE) and raw TCP upgrades (WebSockets).

Here is a high-level comparison to help you visualize the capabilities:

Feature Server-Sent Events (SSE) WebSockets
Direction Unidirectional (Server → Client) Bidirectional (Full Duplex)
Protocol Standard HTTP/1.1 or HTTP/2 TCP (via HTTP Upgrade)
Retries Built-in (EventSource API) Manual implementation required
Firewall Friendliness High (It’s just HTTP) Medium (Proxies can block WS)
Binary Data No (UTF-8 text only) Yes (Binary & Text)
Ideal Use Case News feeds, stock tickers, notifications Chat apps, multiplayer games, collaborative tools

Prerequisites and Environment Setup
#

To follow this tutorial, ensure your development environment meets the following criteria. We are focusing on modern ESM (ECMAScript Modules) syntax.

  • Node.js: Version 20.x (LTS) or higher.
  • Package Manager: npm or pnpm.
  • Knowledge: Intermediate understanding of Express.js and JavaScript Promises.

Project Initialization
#

Let’s create a modular workspace. We will build a single server that handles both SSE and WebSocket endpoints to demonstrate how they can coexist.

Open your terminal:

mkdir node-realtime-mastery
cd node-realtime-mastery
npm init -y

Install the necessary dependencies. We will use express for the HTTP server and ws for the WebSocket implementation. We are avoiding heavy abstraction libraries like Socket.IO for now to understand the underlying mechanics.

npm install express ws cors

Create a file named server.js. We will also enable ES modules by adding "type": "module" to your package.json.

Modified package.json snippet:

{
  "name": "node-realtime-mastery",
  "version": "1.0.0",
  "type": "module",
  "dependencies": {
    "cors": "^2.8.5",
    "express": "^4.21.0",
    "ws": "^8.18.0"
  }
}

Part 1: Server-Sent Events (SSE)
#

Server-Sent Events are often underestimated. If your application only needs to push data to the client (e.g., “User X liked your post” or “Bitcoin price updated”), SSE is superior to WebSockets because it utilizes standard HTTP. It automatically handles reconnections and doesn’t require a custom protocol upgrade.

The SSE Implementation
#

In SSE, the client sends a standard GET request. The server keeps the connection open by sending a specific set of headers and streaming data with a text/event-stream MIME type.

Add the following code to server.js:

import express from 'express';
import cors from 'cors';
import { WebSocketServer } from 'ws';
import http from 'http';

const app = express();
const PORT = 3000;

// Enable CORS for frontend testing
app.use(cors());
app.use(express.static('public'));

// --- SSE Endpoint ---
app.get('/events', (req, res) => {
    console.log(`Client connected to SSE: ${req.ip}`);

    // 1. Essential Headers for SSE
    res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive',
        'X-Accel-Buffering': 'no' // Crucial for Nginx proxies
    });

    // 2. Send initial connection data
    res.write(`data: ${JSON.stringify({ message: "Connection established" })}\n\n`);

    // 3. Simulate real-time data push
    const intervalId = setInterval(() => {
        const data = {
            timestamp: new Date().toISOString(),
            cpuUsage: Math.floor(Math.random() * 100),
            type: 'system_metric'
        };
        
        // Format: data: <payload>\n\n
        res.write(`data: ${JSON.stringify(data)}\n\n`);
    }, 2000);

    // 4. Handle connection close
    req.on('close', () => {
        console.log('SSE Connection closed');
        clearInterval(intervalId);
        res.end();
    });
});

// Create HTTP server to share with WebSocket
const server = http.createServer(app);

server.listen(PORT, () => {
    console.log(`Server running on http://localhost:${PORT}`);
});

Analyzing the Code
#

  1. Headers: Content-Type: text/event-stream tells the browser strictly not to close the request. Connection: keep-alive prevents timeouts.
  2. Data Format: SSE is strict about formatting. Messages must start with data: and end with double newline characters \n\n.
  3. Cleanup: You must clear intervals or event listeners on req.on('close'), otherwise, your server will leak memory for every disconnected client.

The Client (SSE)
#

Create a folder public and an index.html file inside it. The EventSource API is built into all modern browsers.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Node Realtime Demo</title>
    <style>
        body { font-family: sans-serif; padding: 20px; display: flex; gap: 20px; }
        .box { border: 1px solid #ccc; padding: 15px; width: 45%; }
        #sse-logs, #ws-logs { height: 200px; overflow-y: scroll; background: #f4f4f4; border: 1px solid #ddd; padding: 5px; font-family: monospace; font-size: 12px; }
    </style>
</head>
<body>

    <div class="box">
        <h3>Server-Sent Events (SSE)</h3>
        <div id="sse-logs"></div>
    </div>

    <script>
        // --- SSE Client Logic ---
        const sseLogs = document.getElementById('sse-logs');
        const eventSource = new EventSource('/events');

        eventSource.onmessage = (event) => {
            const data = JSON.parse(event.data);
            const line = document.createElement('div');
            line.textContent = `[SSE] ${data.timestamp}: CPU ${data.cpuUsage}%`;
            sseLogs.prepend(line);
        };

        eventSource.onerror = (err) => {
            console.error("EventSource failed:", err);
            // EventSource auto-reconnects by default!
        };
    </script>
</body>
</html>

Part 2: WebSockets
#

While SSE is great for broadcasting, WebSockets are the standard for interactive communication. A WebSocket connection starts as an HTTP request but performs an “Upgrade” handshake to switch protocols to TCP.

The Handshake Architecture
#

It is vital to understand that WebSockets bypass the HTTP request/response overhead after the initial handshake.

sequenceDiagram participant Client participant Server Note over Client, Server: The Handshake Phase Client->>Server: HTTP GET /ws (Upgrade: websocket) Server->>Server: Validate Origin & Protocol Server-->>Client: HTTP 101 Switching Protocols Note over Client, Server: The Data Phase (Bi-directional) loop Active Connection Client->>Server: Frame (Masked Data) Server->>Client: Frame (Data) Client->>Server: Ping Server-->>Client: Pong end Client->>Server: Close Frame Server-->>Client: Close Frame

The WebSocket Implementation
#

We will attach the WebSocket server to our existing HTTP server instance. This allows them to share the same port (3000).

Append this to your server.js (before server.listen):

// --- WebSocket Setup ---
const wss = new WebSocketServer({ server });

wss.on('connection', (ws, req) => {
    const ip = req.socket.remoteAddress;
    console.log(`WebSocket connected: ${ip}`);

    // 1. Send welcome message
    ws.send(JSON.stringify({ type: 'info', message: 'Welcome to WS Server' }));

    // 2. Handle incoming messages from Client
    ws.on('message', (message) => {
        // ws receives raw buffers, convert to string
        const parsedMessage = message.toString();
        console.log(`Received: ${parsedMessage}`);

        // Echo back with a timestamp (Simulate processing)
        ws.send(JSON.stringify({
            type: 'response',
            original: parsedMessage,
            processedAt: new Date().toISOString()
        }));
    });

    // 3. Handle specific heartbeat (Ping/Pong)
    // The 'ws' library handles ping/pong frames automatically at the protocol level,
    // but application-level heartbeats are often useful for detecting stale connections.
    
    ws.on('close', () => {
        console.log('WebSocket disconnected');
    });
});

The Client (WebSocket)
#

Update your public/index.html to include the WebSocket client logic inside the <body> tag, below the SSE section.

    <div class="box">
        <h3>WebSockets (Echo)</h3>
        <input type="text" id="ws-input" placeholder="Type message..." />
        <button onclick="sendWsMessage()">Send</button>
        <div id="ws-logs" style="margin-top: 10px;"></div>
    </div>

    <script>
        // ... Previous SSE Code ...

        // --- WebSocket Client Logic ---
        const wsLogs = document.getElementById('ws-logs');
        const wsInput = document.getElementById('ws-input');
        
        // Note: usage of ws:// protocol
        const ws = new WebSocket(`ws://${window.location.host}`);

        ws.onopen = () => {
            logWS("Connected to WebSocket Server");
        };

        ws.onmessage = (event) => {
            const data = JSON.parse(event.data);
            if (data.type === 'response') {
                logWS(`Server Echo: ${data.original}`);
            } else {
                logWS(`Server Info: ${data.message}`);
            }
        };

        function sendWsMessage() {
            const text = wsInput.value;
            if(text) {
                ws.send(text);
                logWS(`Sent: ${text}`);
                wsInput.value = '';
            }
        }

        function logWS(msg) {
            const line = document.createElement('div');
            line.textContent = msg;
            wsLogs.prepend(line);
        }
    </script>

Performance and Best Practices
#

Implementing the code is the easy part. Operating real-time services in production requires managing resources carefully.

1. Connection Limits and Ephemeral Ports
#

Each WebSocket connection consumes a file descriptor on your Linux server. By default, systems may limit this to 1024 or 4096.

  • Solution: Increase ulimit -n in your OS configuration if you expect high concurrency (e.g., 65,535).

2. The Heartbeat Problem (Zombie Connections)
#

If a user’s WiFi drops abruptly (without sending a FIN packet), the server might think the connection is still open. This leads to “zombie” connections filling up your server memory.

Implementation strategy for ws:

// Server-side Heartbeat Logic
const interval = setInterval(function ping() {
  wss.clients.forEach(function each(ws) {
    if (ws.isAlive === false) return ws.terminate();

    ws.isAlive = false;
    ws.ping(); // Send protocol level ping
  });
}, 30000);

wss.on('connection', function connection(ws) {
  ws.isAlive = true;
  ws.on('pong', () => ws.isAlive = true); // Client responded
});

3. Load Balancing & Sticky Sessions
#

This is the most common pitfall for Node.js developers scaling real-time apps.

If you run multiple instances of your Node app (using PM2 or Kubernetes) behind a Load Balancer (like Nginx), a WebSocket handshake is tricky. The HTTP request might hit Server A, but the subsequent packets might route to Server B, breaking the handshake.

  • Solution 1 (Sticky Sessions): Configure Nginx/AWS ALB to route requests from the same IP to the same server instance.
  • Solution 2 (Pub/Sub): If Server A holds the connection for User 1, and Server B needs to send a message to User 1, you need a message broker (like Redis Pub/Sub) to bridge the servers.

4. Compression
#

For text-heavy applications, WebSocket frames can be compressed using the permessage-deflate extension. The ws library supports this but it adds CPU overhead. Disable it if latency is prioritized over bandwidth; enable it if you are sending large JSON blobs.

const wss = new WebSocketServer({ 
    server,
    perMessageDeflate: false // Disable to save CPU
});

Conclusion
#

Both Server-Sent Events and WebSockets are powerful tools in the Node.js developer’s arsenal.

  • Choose SSE when you need a lightweight, HTTP-friendly way to push updates (dashboards, news, status updates). It handles proxies better and requires less boilerplate for reconnections.
  • Choose WebSockets when you need low-latency, bidirectional communication (chat, gaming, collaborative editing).

By understanding the underlying protocols—HTTP streaming versus TCP upgrades—you can make informed architectural decisions that scale effectively.

Further Reading
#

Did you find this deep dive helpful? Share it with your team and subscribe to Node DevPro for more architecture-level tutorials.