Skip to content
Express.js ex realtime 4 min read

WebSockets & Realtime Overview

The classic Express request/response cycle is one-shot: a client asks, the server answers, and the connection closes. That model breaks down the moment your application needs to push data — a chat message, a live score, a notification, a progress bar — without the user asking for it. This page maps the three realtime techniques available to an Express app (polling, Server-Sent Events, and WebSockets), explains when each is the right tool, and shows how a WebSocket server shares the very same HTTP server that Express already runs on.

When you actually need realtime

Realtime is not free. It keeps connections open, complicates scaling, and adds moving parts. Reach for it only when latency or push direction genuinely matters: live dashboards, collaborative editing, multiplayer games, chat, presence indicators, or streaming progress for long-running jobs. If a user can tolerate a few seconds of staleness, a periodic fetch is simpler and cheaper. A useful rule of thumb: ask whether the server needs to initiate the message. If yes, you want SSE or WebSockets; if the client can simply ask again, polling may be enough.

The three approaches

TechniqueDirectionTransportBest forCost
PollingClient → server (repeated)Plain HTTP requestsInfrequent updates, simple infraWasted requests, latency = interval
Server-Sent Events (SSE)Server → clientOne long-lived HTTP responseFeeds, notifications, live logsOne-way only, ~6 connection limit on HTTP/1.1
WebSocketsBidirectionalUpgraded TCP connectionChat, games, collaborationMore infra, separate protocol

Polling

Polling means the client repeatedly hits a normal Express route on a timer. It needs no special server support — every route you already have works — but it trades latency for simplicity and generates traffic even when nothing has changed.

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

let lastEvent = { id: 0, message: 'idle' };

app.get('/api/status', (req, res) => {
  res.json(lastEvent);
});

app.listen(3000);

A browser polling every three seconds:

setInterval(async () => {
  const res = await fetch('/api/status');
  const data = await res.json();
  console.log('status:', data.message);
}, 3000);

Output:

status: idle
status: idle
status: build complete

Tip: Long polling is a refinement where the server holds the request open until data is available, then responds — reducing wasted round-trips. It works everywhere but has largely been superseded by SSE for one-way push.

Server-Sent Events

SSE is a thin standard layered on top of a single HTTP response that never closes. The server keeps the connection open and writes text/event-stream frames; the browser’s built-in EventSource reconnects automatically. It is one-directional (server to client only) but trivially easy to run inside Express because it is just a long-lived response.

app.get('/events', (req, res) => {
  res.set({
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    Connection: 'keep-alive',
  });
  res.flushHeaders();

  const timer = setInterval(() => {
    res.write(`data: ${JSON.stringify({ time: Date.now() })}\n\n`);
  }, 1000);

  req.on('close', () => clearInterval(timer));
});

Output:

data: {"time":1749868800000}

data: {"time":1749868801000}

WebSockets

WebSockets give you a full-duplex channel: after an initial HTTP Upgrade handshake, the connection becomes a persistent two-way pipe over which either side can send messages at any time. This is the right choice when the client also needs to push frequently — chat, cursors, game input. In Node you typically use the lightweight ws library or the higher-level Socket.IO on top of it.

Sharing one HTTP server with Express

A common misconception is that WebSockets require a separate port. They do not. Express’s app.listen() returns a Node http.Server, and a WebSocket library can attach to that same server by listening for the upgrade event. Create the server explicitly so both Express and the WebSocket layer can use it.

const express = require('express');
const http = require('http');
const { WebSocketServer } = require('ws');

const app = express();
app.get('/', (req, res) => res.send('HTTP route still works'));

const server = http.createServer(app);          // one server
const wss = new WebSocketServer({ server });     // attach WS to it

wss.on('connection', (socket) => {
  socket.send('welcome');
  socket.on('message', (data) => {
    wss.clients.forEach((c) => c.send(`echo: ${data}`));
  });
});

server.listen(3000, () => console.log('HTTP + WS on :3000'));

Now http://localhost:3000/ serves your Express routes and ws://localhost:3000/ serves WebSocket connections — same port, same process. Express handles ordinary requests; the ws server intercepts the Upgrade handshake before Express ever sees it. The same pattern applies in Express 5, whose router and async handling are unchanged by the presence of a WebSocket server.

Warning: WebSocket connections bypass Express middleware entirely — your app.use auth, CORS, and logging do not run on the upgrade request. Authenticate WebSocket clients explicitly (for example, by validating a token in the handshake), and apply rate limiting at the WebSocket layer.

Best Practices

  • Choose the simplest technique that meets your latency needs: polling, then SSE, then WebSockets.
  • Use SSE for one-way server push — it reuses HTTP, survives proxies, and reconnects for free.
  • Reserve WebSockets for genuinely bidirectional, high-frequency traffic like chat or collaboration.
  • Run WebSockets on the same http.Server as Express to keep one port and one deployment unit.
  • Authenticate and rate-limit WebSocket handshakes separately — Express middleware never runs on the upgrade request.
  • Always clean up timers and listeners on connection close to avoid leaks from abandoned realtime connections.
  • Behind a reverse proxy (nginx, ALB), enable Upgrade/Connection header passthrough and sticky sessions when scaling horizontally.
Last updated June 14, 2026
Was this helpful?