DEV Community

Cover image for Build a Live Sports Scoreboard in 50 Lines of JavaScript with a WebSocket API
orbistats
orbistats

Posted on

Build a Live Sports Scoreboard in 50 Lines of JavaScript with a WebSocket API

If you've ever built a live scoreboard with setInterval and fetch, you know how it goes: it works in the demo, then feels laggy in production, burns your API quota, and shows a stale score exactly when the goal goes in.

In this tutorial we'll build a proper live scoreboard: about 50 lines of browser JavaScript, plus a small Node relay that keeps your API key off the client. It loads the current state over REST, then stays current over WebSocket, reconnects automatically, and flashes a card when a goal is scored. There's also a mock server so you can run the whole thing locally without an API key.

We'll use Orbistats as the data source, but the pattern works with any sports data API that offers REST plus WebSocket.

What You'll Build
A live scoreboard that updates the instant a score changes
REST for the initial snapshot, WebSocket for deltas
Automatic reconnection with exponential backoff and a resync after every reconnect
A relay server so your API key never reaches the browser
A local mock feed for testing without credentials
Why REST Plus WebSocket?

Polling asks "anything new?" on a timer, so your freshness is capped by your interval and most requests return nothing. A WebSocket keeps one connection open and the server pushes updates the moment something changes.

But WebSocket alone has a gap: when a client connects, it doesn't know the current state of every match. So the standard pattern is:

REST for the initial snapshot
WebSocket for live changes after that
REST again after any reconnect, to fill anything you missed

Well-designed streaming APIs support this directly. For example, Odds-API.io's WebSocket guide describes a resync message and a sequence value so you can reconnect without gaps, and The Rundown's WebSocket docs describe heartbeats every 15 seconds until you subscribe. Check whether your provider offers something similar in its docs. If it doesn't, the "refetch the snapshot on every reconnect" step below is your safety net.

Why We Need a Relay Server

A tempting shortcut is to put your API key straight into browser JavaScript. Don't. Anyone can open dev tools and copy it. As one dev.to WebSocket sports demo puts it, frontend code should never carry private API credentials, so that demo uses a Node relay between browser and provider. We'll do the same:

text
Orbistats REST + WebSocket → your Node relay (holds the key) → browsers
Prerequisites
Node.js 20+ (built-in fetch)
An Orbistats API key from the signup page, or skip it and use the mock feed below
Want to see real payloads first? The sandbox shows responses without an account
Project Setup
bash
mkdir live-scoreboard && cd live-scoreboard
npm init -y
npm pkg set type=module
npm i express ws dotenv
mkdir public

Create a .env file:

bash
ORBISTATS_API_KEY=your_key_here

Copy the exact WebSocket URL from the WebSocket API docs:

ORBISTATS_WS_URL=wss://YOUR_STREAM_HOST/PATH_FROM_DOCS

Leave both empty to run against the local mock feed.

Step 1: The Relay Server
javascript
// server.js
import "dotenv/config";
import express from "express";
import { createServer } from "http";
import { WebSocketServer, WebSocket } from "ws";

const KEY = process.env.ORBISTATS_API_KEY;
const UPSTREAM = process.env.ORBISTATS_WS_URL || "ws://localhost:4000"; // mock by default

const SAMPLE = [{ match_id: "demo_1", status: "live", minute: 12,
home: { name: "Home FC", score: 0 }, away: { name: "Away United", score: 0 } }];

const app = express();
app.use(express.static("public"));

// 1) Initial snapshot over REST
app.get("/api/live", async (_req, res) => {
if (!KEY) return res.json(SAMPLE); // no key → demo data
const r = await fetch("https://api.orbistats.com/v1/football/live", {
headers: { Authorization: Bearer ${KEY} },
});
res.status(r.status).json(await r.json());
});

const server = createServer(app);
const wss = new WebSocketServer({ server, path: "/ws" });

// 2) Upstream WebSocket → fan out to every browser
function connectUpstream() {
const headers = KEY ? { Authorization: Bearer ${KEY} } : {};
const up = new WebSocket(UPSTREAM, { headers });

// Auth style and subscribe message: match the WebSocket docs exactly
up.on("open", () => KEY && up.send(JSON.stringify({ action: "subscribe", sport: "football" })));
up.on("message", (data) => {
for (const client of wss.clients)
if (client.readyState === WebSocket.OPEN) client.send(data.toString());
});
up.on("close", () => setTimeout(connectUpstream, 3000)); // simple upstream reconnect
up.on("error", (err) => console.error("upstream:", err.message));
}
connectUpstream();

server.listen(3000, () => console.log("http://localhost:3000"));

Two lines are marked as needing the docs: how the upstream connection authenticates (header versus query token) and the exact subscribe message. Protocol details like these are the part most likely to differ from any tutorial, so copy them from the official WebSocket API page and the API reference.

Step 2: The HTML and CSS
html

<!doctype html>

Live Scoreboard

body { font: 16px system-ui; background: #0f1218; color: #eee; max-width: 560px; margin: 2rem auto; }
#conn { font-size: .85rem; color: #f5a623; }
.card { display: grid; grid-template-columns: 1fr auto 1fr; gap: .75rem; align-items: center;
background: #171c26; padding: .9rem 1rem; margin: .5rem 0; border-radius: 10px;
border-left: 4px solid #444; transition: background .4s; }
.card small { grid-column: 1 / -1; color: #8b93a3; text-align: center; }
.card.live { border-left-color: #ff4d4d; }
.card.goal { background: #1e4d2b; }
.team:last-of-type { text-align: right; }
.score { font-size: 1.3rem; color: #ff9f1c; }

Live Scores ● connecting…

Step 3: The 50-Line Scoreboard

This is the heart of it, in public/scoreboard.js:

javascript
// public/scoreboard.js
const board = document.getElementById("board");
const badge = document.getElementById("conn");
const state = new Map(); // match_id → latest merged match
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => &#${c.charCodeAt(0)};);

const merge = (old = {}, u) => ({
...old, ...u,
home: { ...old.home, ...u.home }, // deltas may carry only what changed
away: { ...old.away, ...u.away },
});

function draw(m) {
let el = document.getElementById(m-${m.match_id});
if (!el) {
el = Object.assign(document.createElement("article"), { id: m-${m.match_id}, className: "card" });
board.append(el);
}
const live = m.status === "live";
el.classList.toggle("live", live);
el.innerHTML =
<span class="team">${esc(m.home?.name)}</span>
<strong class="score">${m.home?.score ?? 0} : ${m.away?.score ?? 0}</strong>
<span class="team">${esc(m.away?.name)}</span>
<small>${live ? esc(m.minute) + "'" : esc(m.status)}</small>
;
if (live) board.prepend(el); // live matches float to the top
}

function apply(update) {
const before = state.get(update.match_id);
const next = merge(before, update);
state.set(update.match_id, next);
draw(next);
const scored = before &&
(before.home?.score !== next.home?.score || before.away?.score !== next.away?.score);
if (scored) { // flash the card on a goal
const el = document.getElementById(m-${next.match_id});
el.classList.add("goal");
setTimeout(() => el.classList.remove("goal"), 2000);
}
}

async function loadSnapshot() { // REST: full current state
const body = await (await fetch("/api/live")).json();
(Array.isArray(body) ? body : body.data ?? []).forEach(apply);
}

let retry = 0;
function connect() {
const proto = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(${proto}://${location.host}/ws);
ws.onopen = () => { retry = 0; badge.textContent = "● live"; loadSnapshot(); }; // resync every time
ws.onmessage = (e) => apply(JSON.parse(e.data));
ws.onclose = () => {
badge.textContent = "● reconnecting…";
setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000)); // exponential backoff, capped
};
}
connect();

A few details worth understanding:

merge handles partial updates. A WebSocket delta may carry only { match_id, home: { score: 1 } }, and merging preserves the team names already on screen.
esc escapes text before it goes into innerHTML, so a malicious or malformed team name can't inject markup into your page.
loadSnapshot() runs on every open, not just the first. Anything that happened while you were disconnected is corrected by the fresh REST snapshot.
Backoff doubles the delay after each failed attempt up to 15 seconds, so a provider outage doesn't turn into a reconnect storm. The same idea appears in OpticOdds' streaming guide, which recommends exponential backoff and shows a replay-from-last-ID pattern for gap-free recovery.
Payload shape: the field names (match_id, status, minute, home.score) follow the live-score example shown on Orbistats' Live Scores API page. If your messages arrive wrapped in an envelope, unwrap them in ws.onmessage before calling apply.
Step 4: Test It Without an API Key

This mock upstream emits fake goals so you can watch the full pipeline work locally:

javascript
// mock-upstream.js
import { WebSocketServer } from "ws";

const wss = new WebSocketServer({ port: 4000 });
let minute = 12, home = 0, away = 0;

setInterval(() => {
minute += 1;
if (Math.random() < 0.25) Math.random() < 0.5 ? home++ : away++;
const msg = JSON.stringify({ match_id: "demo_1", status: "live", minute,
home: { score: home }, away: { score: away } });
wss.clients.forEach((c) => c.readyState === 1 && c.send(msg));
}, 2000);

console.log("mock feed on ws://localhost:4000");

Run it in two terminals:

bash
node mock-upstream.js # terminal 1
node server.js # terminal 2 → open http://localhost:3000

You'll see the card update every two seconds and flash green when the score changes. Kill the mock feed and the badge switches to "reconnecting…"; restart it and the board recovers by itself. That's your reconnection logic proven end to end.

Step 5: Switch to Real Data
Add your key and the WebSocket URL from the docs to .env
Restart server.js
Compare a live payload from the sandbox with what apply() expects, and adjust field names if needed

Two expectations to set up front. Free-tier live data has been documented as delayed by roughly 30 to 60 seconds, with real-time data on paid plans, and plan details change, so check the pricing page. Also, the "sub-50ms" latency figure on Orbistats' site is the vendor's own marketing claim, not an independent benchmark, so measure delivery lag yourself if it matters to your product.

Common Problems and Fixes
Symptom Likely cause Fix
Board loads, never updates Upstream not subscribed Check the subscribe message against the docs
Team names vanish after an update Delta overwriting the object Keep the merge helper
Reconnect loop Auth failing upstream Check the key and the auth style; read the upstream error log
Duplicate goal flashes Duplicate deliveries Dedupe on an event ID or compare state before flashing
Works locally, not on HTTPS host Mixed content The client already switches to wss:// on HTTPS; make sure your proxy forwards WebSocket upgrades
Score jumps backwards Out-of-order messages Use sequence or timestamp fields if the feed provides them

The duplicate-delivery row deserves attention. Real-time feeds can redeliver events, so if you write scores to a database or trigger notifications, deduplicate on a stable key. Orbistats' webhook receiver tutorial in Go raises the same idempotency question for webhook retries.

Extending the Scoreboard
Add more sports. Orbistats advertises 13 sports now (verify the current list in the documentation). Swapping /football/ for another sport path in the REST call is usually the main change, since the API is described as using one shape across sports.
Add odds. Pull prices from the Odds API and render them as a second column per card.
Use webhooks for backend logic. Keep WebSocket for the UI, and use Webhooks for server-side triggers such as notifications.
Add fixtures and history. Use the Sports Data API for schedules and a team page.
Cache reference data. A TTL cache on your relay's REST route protects your quota. Orbistats' Python and FastAPI tutorial shows a 15-second cache example.
Port it to another language. See the Go client tutorial or the .NET Core example.
Plan for growth. If you might move from prototype to a licensed product later, keep provider-specific code in one adapter, as described in this abstraction-layer guide.

If you want a comparison with a slightly different approach, my earlier post, Building a Live Scoreboard with a Real-Time Sports Data API (REST + WebSocket), covers the concept, and the browser-side reference for the API is MDN's WebSockets guide. The Node side uses the ws package.

Production Checklist
API key lives only on the server, never in client code
Client resyncs from REST after every reconnect
Reconnect uses capped exponential backoff
All API text is escaped before rendering
Duplicate and out-of-order messages are handled
Relay is behind HTTPS with WebSocket upgrades enabled
You've measured real latency and data delay on your plan
You've read the provider's terms before using the data in a public product
FAQ

Can I do this without a relay server?
Only if your provider offers short-lived, browser-safe tokens. Otherwise, the relay is the safe default because it keeps the key private.

Why not just poll every few seconds?
Polling adds latency, wastes requests and drains quota, while a WebSocket pushes only real changes.

Why fetch the snapshot again after reconnecting?
Because you can't know what you missed while disconnected. Refetching is the simplest way to guarantee correct state.

Does this work with other sports APIs?
Yes. Change the REST URL, the upstream connection details and the payload field names.

Is the free tier enough to try this?
For learning and prototyping, yes, but free-tier live data has been delayed, so verify current limits on the pricing page.

Wrap-Up

You now have a live scoreboard that loads state over REST, stays fresh over WebSocket, survives disconnects and keeps your credentials off the client, in about 50 lines of browser code. From here, add odds, more sports and notifications. And before you build on any provider, run the mock feed, then the sandbox, then your own key, so you know exactly how it behaves under your own conditions.

Have you built a live feed before? What broke first for you: reconnects, duplicates or rate limits? Tell me in the comments.

Top comments (2)

Collapse
 
unitbuilds profile image
UnitBuilds •

Do not follow external links, this is a phishing scam. DEV.to uses Sloan for automated messaging. Report them please.

Press the ... Report abuse - Other - In message, write Phishing.