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"}
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);
});
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`);
}
}
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
For horizontal scaling across multiple server instances, add the Redis adapter:
npm install sse-manager redis
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 };
}
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");
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);
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();
});
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" });
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,
});
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);
});
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" }));
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)