Streaming Live Sports Scores with SSE Permalink to this section
Part of Market Data & Live Scoreboards, under Real-Time Application Patterns.
A live scoreboard mixes two kinds of data. The match clock, possession, and statistics change constantly and only their current value matters. Goals, cards, substitutions and the final whistle are discrete moments that every viewer must see — a fan who reconnects after a tunnel should see “GOAL 67’” in the timeline, not just a score that silently changed. And the audience is spiky: a cup final brings a hundred times the usual traffic in the five minutes after kick-off. This guide builds a Server-Sent Events scoreboard that handles both kinds of data and survives the spike.
Symptom & Developer Intent Permalink to this section
- After reconnecting, users see the score change from 1–0 to 2–0 with no goal in the timeline.
- Goal notifications arrive twice after a flaky connection.
- The match clock stutters or jumps backwards.
- At kick-off of a big match, the streaming tier falls over under the reconnect and new-viewer surge.
- The scoreboard shows a goal that was later disallowed, with no correction.
The intent is a board where state (score, clock, stats) is always current, key events are delivered exactly once to the timeline even across reconnects, corrections are first-class, and the system handles a large, sudden audience.
Root Cause Analysis Permalink to this section
A single stream that treats everything as state loses events: the reconnect snapshot contains the new score but not the goal that caused it. A stream that treats everything as events replays hundreds of clock ticks and stat changes to reconnecting viewers, which is pointless and expensive. The correct split has two channels in one stream:
The match clock deserves special handling. Sending the clock every second is wasteful and still stutters, because network jitter makes consecutive updates arrive 0.7 s or 1.4 s apart. Instead, send the clock’s state — running or stopped, and the match time at a server timestamp — and let the client compute the display.
Corrections — a goal disallowed by video review, a card rescinded — are events in their own right. They reference the event they amend and are replayed like any other.
Step-by-Step Resolution Permalink to this section
Step 1 — Model events with a per-match sequence Permalink to this section
// events: stored and sequenced per match
// { seq: 14, type: 'goal', minute: 67, team: 'home', player: 'K. Adeyemi', score: [2, 0] }
// { seq: 15, type: 'var_overturn', ref: 14, reason: 'offside', score: [1, 0] }
async function recordEvent(matchId, ev) {
const seq = await db.one(
`UPDATE matches SET last_seq = last_seq + 1 WHERE id = $1 RETURNING last_seq`, [matchId]);
await db.none('INSERT INTO match_events (match_id, seq, body) VALUES ($1, $2, $3)',
[matchId, seq.last_seq, ev]);
await bus.publish(`match:${matchId}`, JSON.stringify({ kind: 'event', seq: seq.last_seq, ...ev }));
}
Step 2 — Send state as clock anchors, not ticks Permalink to this section
event: state
data: {"score":[2,0],"clock":{"running":true,"minute":67,"second":12,"at":1726651200123},"period":"2H","stats":{"poss":[54,46],"shots":[11,6]}}
// Client: derive the displayed clock from the anchor and a server-time offset.
function displayClock({ running, minute, second, at }, now) {
const elapsed = running ? Math.floor((now - at) / 1000) : 0;
const total = minute * 60 + second + elapsed;
return `${Math.floor(total / 60)}'`;
}
setInterval(() => (clockEl.textContent = displayClock(state.clock, Date.now() + offset)), 250);
The server only sends a new anchor when the clock starts, stops, or drifts; the client animates smoothly in between. Compute offset from a server timestamp in the heartbeat, as described in choosing heartbeat intervals.
Step 3 — Serve snapshot plus event replay on connect Permalink to this section
app.get('/api/matches/:id/stream', async (req, res) => {
const matchId = req.params.id;
openStream(res, { retryMs: jitter(2000, 8000) }); // spread reconnect waves
const cursor = Number(req.get('Last-Event-ID') ?? 0);
const sub = await bus.subscribe(`match:${matchId}`, (raw) => {
const m = JSON.parse(raw);
if (m.kind === 'event') {
if (m.seq <= sent) return;
sent = m.seq;
res.write(`event: key\nid: ${m.seq}\ndata: ${raw}\n\n`);
} else if (!res.writableNeedDrain) {
res.write(`event: state\ndata: ${raw}\n\n`); // conflated, droppable
}
});
let sent = cursor;
for (const e of await events.after(matchId, cursor)) { // replay missed key events
sent = e.seq;
res.write(`event: key\nid: ${e.seq}\ndata: ${JSON.stringify(e)}\n\n`);
}
res.write(`event: state\ndata: ${JSON.stringify(await matchState(matchId))}\n\n`);
req.on('close', () => sub.unsubscribe());
});
The client keys its timeline by seq, so a replayed goal that was already shown is a no-op, and a var_overturn event updates the referenced entry instead of adding a new one.
Step 4 — Prepare for the kick-off spike Permalink to this section
Big matches produce two surges: new viewers arriving at kick-off, and reconnect waves after any edge restart. Three measures keep both manageable:
- Jittered retry. Send
retry:with a random value between 2 and 8 seconds, so a node restart spreads reconnects over several seconds instead of one. - Cached snapshots. The state snapshot and the event list are identical for every viewer of a match; cache the serialised form for one second and serve it to every new connection.
- Pre-scaled edges. Scale streaming nodes ahead of known fixtures. Autoscaling on CPU reacts too late to a surge that peaks in ninety seconds.
Step 5 — Render the timeline and board separately Permalink to this section
const timeline = new Map(); // seq → event
es.addEventListener('key', (e) => {
const ev = JSON.parse(e.data);
if (ev.type === 'var_overturn') {
const orig = timeline.get(ev.ref);
if (orig) timeline.set(ev.ref, { ...orig, overturned: ev.reason });
}
if (!timeline.has(ev.seq)) { timeline.set(ev.seq, ev); if (isLiveArrival(ev)) celebrate(ev); }
renderTimeline(timeline);
});
es.addEventListener('state', (e) => { state = JSON.parse(e.data); renderBoard(state); });
isLiveArrival suppresses animations for replayed events older than a few seconds, so a reconnecting fan sees the goal in the timeline without a stale celebration.
Validation & Monitoring Permalink to this section
# Replay: reconnect from an old event id and confirm key events arrive in order, then state.
curl -sN -H 'Last-Event-ID: 10' https://live.example.com/api/matches/8812/stream | grep -E '^(event|id):' | head
# Load: ramp to many concurrent streams against a staging match.
k6 run --vus 5000 --duration 5m sse-match.js
Load-testing long-lived streams needs a tool that holds connections open rather than counting requests; load testing SSE with k6 shows the script. In production, monitor concurrent streams per match, key-event delivery latency (from recordEvent to client receipt, sampled), and the reconnect rate after deploys.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Can a CDN serve live score streams?
Some CDNs can hold and fan out long-lived streaming responses, which offloads origin connections for very large audiences. The stream must then be identical for every viewer of a match, so keep personalisation out of it and deliver per-user content separately.
Why not send the match clock every second?
It multiplies traffic and still looks jittery, because network delay varies between updates. Anchors plus client-side animation are smoother and need a new message only when the clock starts, stops or is corrected.
How do I avoid duplicate goal notifications after a reconnect?
Key the timeline by event sequence so replays are idempotent, and trigger notifications only for events that arrive live, not for replayed ones older than a few seconds.
What about push notifications for goals?
SSE covers fans with the page open. Fans who closed the app need push notifications, driven from the same event records, so both channels report the same goals.