DEV Community

Cover image for Managing Real-Time Communication from Node.js Server to Client with Server-Sent Events

Managing Real-Time Communication from Node.js Server to Client with Server-Sent Events

Modern web applications demand live data. A cricket scorecard that updates the moment a wicket falls. A trading dashboard where prices change faster than you can refresh. A notification bell that lights up without polling. These are not nice-to-haves anymore — users expect it.

The pattern behind all of them is the same: the server pushes updates to the client the instant something changes. No client asking "anything new?" every few seconds. Just a persistent connection and a steady stream of events.


Why Real-Time Server-to-Client Communication Matters

Think about how often an application needs to push data to a user rather than wait to be asked:

  • Activity feeds — new comments, mentions, and collaboration events
  • Server notifications — alerts, health dashboards, build and deployment progress
  • Order and delivery tracking — status changes pushed to the customer in real time
  • Sports updates — live scores and match events as they happen
  • Price feeds — stock tickers, crypto prices, and market data

In all of these, data flows in one direction: server → client. The client has nothing to send back. It just needs to stay informed.

The naive solution is polling — the client asks the server for updates every few seconds. It works, but it's wasteful. Most requests return nothing new. Under load, it hammers your infrastructure. And the user always sees data that is slightly stale.

The right tool for one-way real-time updates is Server-Sent Events.


What Are Server-Sent Events?

Server-Sent Events (SSE) is a browser-native standard built on plain HTTP. The client opens a single long-lived connection, and the server streams events down it as they happen — text messages separated by blank lines.

GET /stream/cricket/ind-vs-aus HTTP/1.1
Accept: text/event-stream

HTTP/1.1 200 OK
Content-Type: text/event-stream

event: wicket
data: {"player":"Rohit Sharma","over":"12.3","totalWickets":3}

event: boundary
data: {"player":"Virat Kohli","runs":4,"over":"13.1"}
Enter fullscreen mode Exit fullscreen mode

On the client, the native EventSource API handles everything — including automatic reconnection if the network drops:

const stream = new EventSource("/stream/cricket/ind-vs-aus");

stream.addEventListener("wicket", (e) => {
  const { player, totalWickets } = JSON.parse(e.data);
  updateScorecard(player, totalWickets);
});

stream.addEventListener("boundary", (e) => {
  const { player, runs } = JSON.parse(e.data);
  flashAnimation(player, runs);
});
Enter fullscreen mode Exit fullscreen mode

No client library. No WebSocket upgrade handshake. No custom reconnection logic. It is HTTP — which means it works through every proxy, CDN, and load balancer by default.

SSE is purpose-built for exactly this pattern. It is lighter than WebSockets, simpler than Socket.io, and available natively in every modern browser.


The Problem with Using SSE Directly

The browser side of SSE is clean. The server side is not.

When you implement SSE by hand in Node.js, you are left managing a growing pile of raw HTTP response objects — one per connected client. The moment your use case has more than one "channel" (say, different streams for different cricket matches), things get messy fast:

// DIY channel management — gets painful quickly
const rooms = {}; // { "ind-vs-aus": [res, res, ...], "eng-vs-sa": [res, ...] }

app.get("/stream/:matchId", (req, res) => {
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.flushHeaders();

  const { matchId } = req.params;
  if (!rooms[matchId]) rooms[matchId] = [];
  rooms[matchId].push(res);

  req.on("close", () => {
    rooms[matchId] = rooms[matchId].filter((r) => r !== res);
  });
});

function broadcast(matchId, event, data) {
  for (const res of rooms[matchId] ?? []) {
    res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
  }
}
Enter fullscreen mode Exit fullscreen mode

This works in a toy project. In production it breaks down immediately:

  • No clean channel management — you are manually juggling arrays of response objects
  • No middleware — nowhere natural to put authentication or request validation
  • No heartbeats — proxies and load balancers will silently kill idle connections
  • Completely breaks under horizontal scaling — Server 1 has no knowledge of clients connected to Server 2

For a platform streaming updates across dozens of live matches, you end up building all of this yourself. That is a significant amount of infrastructure to write just to use a browser-native protocol.


SSE-Manager: Socket.io's Interface, SSE's Simplicity

sse-manager (npm) solves the server-side problem. It is a lightweight SSE connection manager that wraps raw SSE in a Socket.io-like API — so if you have used Socket.io before, you already know how to use it.

The core concepts map directly:

Socket.io sse-manager
io.of("/namespace") sseServer.of("/namespace")
socket.join("room") client.join("room")
io.to("room").emit(...) namespace.to("room").emit(...)
Middleware via io.use(...) Middleware via namespace.use(...)

Under the hood it is still plain SSE — no WebSocket, no client library, zero client-side bundle cost. But the server-side developer experience is as smooth as Socket.io.

Install it:

npm install sse-manager
Enter fullscreen mode Exit fullscreen mode

For horizontal scaling across multiple server instances, add the Redis adapter:

npm install sse-manager redis
Enter fullscreen mode Exit fullscreen mode

Building a Real-Time Sports Update Server

Let's put it together. We will build a server that streams live cricket and football updates, where each match has its own isolated channel and clients only receive updates for the match they are watching.

1. Define Event Types

TypeScript support is built in. Define your event shapes once and get full type safety across the codebase:

// types.ts
export interface FootballEvents {
  score_update: {
    match: string;
    homeScore: number;
    awayScore: number;
    minute: number;
  };
  red_card: { match: string; player: string; team: string; minute: number };
  match_end: { match: string; finalScore: string };
}

export interface CricketEvents {
  wicket: { match: string; player: string; team: string; totalWickets: number };
  boundary: { match: string; player: string; runs: 4 | 6 };
  over_complete: { match: string; oversPlayed: number; runsInOver: number };
}
Enter fullscreen mode Exit fullscreen mode

2. Create the Server and Namespaces

One namespace per sport keeps each sport's clients and events fully isolated:

import express from "express";
import { SSEServer } from "sse-manager";
import { RedisAdapter } from "sse-manager/adapters/redis";
import type { FootballEvents, CricketEvents } from "./types";

const app = express();

const sseServer = new SSEServer({
  heartbeatInterval: 30000, // keeps connections alive through proxies
  cors: { origin: "https://your-frontend.com", credentials: true },
});

// Redis adapter enables horizontal scaling across multiple instances
sseServer.adapter(new RedisAdapter({ url: process.env.REDIS_URL }));

const football = sseServer.of<FootballEvents>("/football");
const cricket = sseServer.of<CricketEvents>("/cricket");
Enter fullscreen mode Exit fullscreen mode

3. Connect Clients to Match Rooms

Each match gets its own room. A fan opening the India vs Australia stream joins that room and receives only those events — nothing from other matches:

app.get("/stream/football/:matchId", (req, res) => {
  const client = football.connect(req, res);
  client.join(req.params.matchId);
});

app.get("/stream/cricket/:matchId", (req, res) => {
  const client = cricket.connect(req, res);
  client.join(req.params.matchId);
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

4. Add Authentication

Middleware runs before the client is registered. Call next(error) to reject the connection:

football.use((client, next) => {
  const token = client.handshake.headers["authorization"];
  if (!isValidToken(token)) return next(new Error("Unauthorized"));
  next();
});
Enter fullscreen mode Exit fullscreen mode

5. Broadcast Updates

Pushing a goal to everyone watching a specific match is one line. Rooms are fully isolated — fans in other matches receive nothing:

// Only fans watching this match receive the update
football.to("arsenal-vs-chelsea").emit("score_update", {
  match: "Arsenal vs Chelsea",
  homeScore: 2,
  awayScore: 1,
  minute: 67,
});

// Broadcast to all connected football fans at once
football.emit("announcement", { message: "All VAR reviews suspended today" });
Enter fullscreen mode Exit fullscreen mode

The same pattern works across sports simultaneously — cricket and football rooms are completely separate:

cricket.to("ind-vs-aus").emit("wicket", {
  match: "India vs Australia",
  player: "Rohit Sharma",
  team: "India",
  totalWickets: 3,
});
Enter fullscreen mode Exit fullscreen mode

6. Client Side

No library, no configuration. The browser's built-in EventSource handles the connection and reconnects automatically on drops:

const stream = new EventSource("/stream/football/arsenal-vs-chelsea");

stream.addEventListener("score_update", (e) => {
  const { homeScore, awayScore, minute } = JSON.parse(e.data);
  document.getElementById("score").textContent = `${homeScore} - ${awayScore}`;
  document.getElementById("minute").textContent = `${minute}'`;
});

stream.addEventListener("match_end", (e) => {
  const { finalScore } = JSON.parse(e.data);
  stream.close();
  showFinalScore(finalScore);
});
Enter fullscreen mode Exit fullscreen mode

Horizontal Scaling

When a single server is not enough, the Redis adapter distributes events across all instances automatically. When Server 1 emits an update, it publishes to a Redis pub/sub channel. Every server instance — including Server 1 — receives the message and delivers it to their locally connected clients. No sticky sessions required.

sseServer.adapter(new RedisAdapter({ url: "redis://localhost:6379" }));
Enter fullscreen mode Exit fullscreen mode

That is the only change needed to go from a single instance to a horizontally scaled deployment.


Wrapping Up

For any use case where the server needs to push updates to clients — sports scores, price feeds, notifications, order tracking — SSE is the right tool. It runs over plain HTTP, reconnects automatically, and requires zero client-side code beyond what the browser already ships.

The missing piece has always been server-side structure: a clean way to manage channels, rooms, authentication, and scaling. sse-manager fills that gap with an API that Socket.io developers will find immediately familiar, at a fraction of the complexity.

Links:

Top comments (0)