Skip to content
JavaScript

Building a Real-Time Sports Scoreboard with WebSocket and Node.js

Create a live sports scoreboard using WebSocket and Node.js. Keep your API key secure and ensure real-time updates with a simple setup.

Topic
JavaScript
Reading time
5 min
Length
1,049 words
Published
Sep 28, 2026
06:04 pm IST
In this article
  1. What's Changed
  2. Why It Matters
  3. Implementing the Real-Time Scoreboard
  4. Step 1: Create the Relay Server
  5. Step 2: Design the HTML and CSS
  6. Step 3: JavaScript for Live Updates
  7. Testing and Deployment
  8. What This Doesn't Solve
  9. Common Problems and Fixes

If you've ever tried to build a live scoreboard using setInterval and fetch, you probably know the frustrations. Updates can lag, API quotas get burned quickly, and scores can go stale right when it matters most. There’s a better way: a real-time sports scoreboard with WebSocket and Node.js. This approach makes sure updates are instant and your API credentials stay secure.

What's Changed

This method, detailed in this tutorial, switches from the traditional polling approach to using REST for the initial data load and WebSocket for the on-the-fly updates. The result? Fresh data without unnecessary requests, keeping your API usage efficient.

Why It Matters

When you’re dealing with live data in production, keeping it accurate and secure is key. WebSocket connections let the server push updates to clients right as they happen, so your scoreboard is always current. Using a Node.js relay server to guard your API keys is a crucial security step. This method is not just efficient; it's scalable, lowering server load and cutting down on bandwidth usage.

Implementing the Real-Time Scoreboard

First things first: make sure Node.js 20+ is installed, along with express, ws, and dotenv. Here's how you set up your project:

mkdir live-scoreboard && cd live-scoreboard
npm init -y
npm pkg set type=module
npm i express ws dotenv
mkdir public

Then, configure your environment variables in a .env file:

ORBISTATS_API_KEY=your_key_here
ORBISTATS_WS_URL=wss://YOUR_STREAM_HOST/PATH_FROM_DOCS

Step 1: Create the Relay Server

The relay server is your gateway to handling REST and WebSocket connections. It manages API keys and sends updates to connected clients.

// 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"));

app.get("/api/live", async (_req, res) => {
  if (!KEY) return res.json(SAMPLE);
  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" });

function connectUpstream() {
  const headers = KEY ? { Authorization: `Bearer ${KEY}` } : {};
  const up = new WebSocket(UPSTREAM, { headers });
  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));
  up.on("error", (err) => console.error("upstream:", err.message));
}

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

Step 2: Design the HTML and CSS

To showcase live scores, create a clean and simple user interface. Here's a basic HTML and CSS layout to get your scoreboard looking right:

<!doctype html>
<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; }

Step 3: JavaScript for Live Updates

The heart of this scoreboard beats with JavaScript, handling updates and rendering in real-time:

// public/scoreboard.js
const board = document.getElementById("board");
const badge = document.getElementById("conn");
const state = new Map();
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
const merge = (old = {}, u) => ({
  ...old, ...u,
  home: { ...old.home, ...u.home },
  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 = 
    `${esc(m.home?.name)}
    ${m.home?.score ?? 0} : ${m.away?.score ?? 0}
    ${esc(m.away?.name)}
    ${live ? esc(m.minute) + "'" : esc(m.status)}`
  if (live) board.prepend(el);
}

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) {
    const el = document.getElementById(`m-${next.match_id}`);
    el.classList.add("goal");
    setTimeout(() => el.classList.remove("goal"), 2000);
  }
}

async function loadSnapshot() {
  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(); };
  ws.onmessage = (e) => apply(JSON.parse(e.data));
  ws.onclose = () => {
    badge.textContent = "● reconnecting…";
    setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000));
  };
}

connect();

Testing and Deployment

Before taking your application live, test it locally with a mock server to ensure everything functions smoothly without needing an API key. This involves running a mock server for simulated live updates and checking reconnection logic and UI behavior.

node mock-upstream.js     // Run mock server
node server.js            // Run relay server

Once you're satisfied with the testing, switch to using live data by updating your environment variables with your actual API credentials and WebSocket URL. Don't forget to restart your server to apply the new settings.

What This Doesn't Solve

This setup is pretty solid for real-time updates, but it's not a catch-all. Real-time data integrity issues, like duplicate or out-of-order messages, need extra mechanisms, maybe deduplication based on event IDs or timestamps if provided by the API. Handling WebSocket scaling in high-traffic scenarios might require more complex architectures.

Also, consider security and compliance, especially when working with sensitive sports data. Make sure your implementation aligns with the terms and conditions of your data provider.

Common Problems and Fixes

Some common issues you might face include:

  • The board loads but doesn’t update: Double-check the subscribe message.
  • Team names disappear after updates: Make sure the merge helper is doing its job.
  • Stuck in reconnect loops: Look at your authentication method and error logs.
  • Scores jumping backward: You need to handle those out-of-order messages using sequence or timestamp fields.

If you're looking to learn more about managing real-time data and WebSocket connections, check out our other articles like Vercel's scriptc: Building Tiny TypeScript Binaries Without Node or Node.js 26.10.0: New Features in Crypto, FFI, and More.

Sources

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

Every claim above was checked against this source before publishing. The analysis, the code and the opinions are mine.

Frequently asked

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 guarantees correct state.

Does this work with other sports APIs?

Yes. Change the REST URL, the upstream connection details, and the payload field names as needed.

Deepak Kumar

Written by

Deepak Kumar

Sr Software Engineer at India Today Group | Aaj Tak · MERN Stack · Generative AI

I have worked in JavaScript daily for nine years, across browser code that has to stay fast and server code that has to stay up. I write here about what those systems actually do once real traffic hits them. Hiring a JavaScript developer in India? Here is how I work.

Message me