Overview
Live score apps are among the most popular sports applications, but building one that scales to hundreds of thousands of users while delivering sub-second updates requires careful architectural planning. This guide walks through the full stack: choosing the right API, designing your data layer, implementing real-time updates, and deploying to production.
The key challenge is balancing freshness with cost. Polling a live score endpoint every second for 1,000 concurrent matches can exhaust your API quota in minutes. The solution is a hybrid architecture that combines intelligent polling, server side caching, and client-side push delivery.
Quick tip: Use the API finder to get a shortlist of live-score APIs tailored to your sport and budget before diving into implementation.
Architecture
A production live score app typically has four layers:
- Data ingestion layer — A background worker that polls the sports API at a configurable interval and writes results to a cache.
- Cache layer — Redis or Memcached storing the latest score snapshot per match, keyed by match ID.
- Delivery layer — A WebSocket or Server-Sent Events (SSE) server that pushes updates to connected clients.
- Client layer — A web or mobile frontend that subscribes to the WebSocket and renders scores in real time.
This separation lets you control how often you hit the upstream API (once per refresh interval, regardless of how many users are watching) while still delivering instant updates to every client.
Implementation
Step 1: Fetch Live Scores
Start by writing a function that fetches live fixtures from your chosen sports API. Here is a JavaScript example using the API-Sports football endpoint:
// fetchLiveScores.js
const API_KEY = process.env.SPORTS_API_KEY;
const API_HOST = "v3.football.api-sports.io";
export async function fetchLiveScores() {
const url = `https://${API_HOST}/fixtures?live=all`;
const response = await fetch(url, {
headers: {
"x-apisports-key": API_KEY,
"Accept": "application/json",
},
// Use Next.js cache or browser cache as a safety net
next: { revalidate: 0 },
});
if (!response.ok) {
throw new Error(`API returned ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Normalize the response into a flat score object
return data.response.map((fixture) => ({
matchId: fixture.fixture.id,
league: fixture.league.name,
homeTeam: fixture.teams.home.name,
awayTeam: fixture.teams.away.name,
homeScore: fixture.goals.home,
awayScore: fixture.goals.away,
minute: fixture.fixture.status.elapsed,
status: fixture.fixture.status.short,
updatedAt: new Date().toISOString(),
}));
}For Python backends, here is an equivalent implementation using the requests library:
# fetch_live_scores.py
import os
import requests
from datetime import datetime, timezone
API_KEY = os.environ["SPORTS_API_KEY"]
API_HOST = "v3.football.api-sports.io"
def fetch_live_scores():
url = f"https://{API_HOST}/fixtures?live=all"
headers = {
"x-apisports-key": API_KEY,
"Accept": "application/json",
}
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
data = response.json()
matches = []
for fixture in data["response"]:
matches.append({
"match_id": fixture["fixture"]["id"],
"league": fixture["league"]["name"],
"home_team": fixture["teams"]["home"]["name"],
"away_team": fixture["teams"]["away"]["name"],
"home_score": fixture["goals"]["home"],
"away_score": fixture["goals"]["away"],
"minute": fixture["fixture"]["status"]["elapsed"],
"status": fixture["fixture"]["status"]["short"],
"updated_at": datetime.now(timezone.utc).isoformat(),
})
return matchesStep 2: Cache and Distribute Updates
Polling the API on every client request is unsustainable. Instead, run a single background poller that updates a Redis cache, then have your WebSocket server read from that cache and broadcast changes:
// poller.js — background worker
import Redis from "ioredis";
import { fetchLiveScores } from "./fetchLiveScores.js";
const redis = new Redis(process.env.REDIS_URL);
const POLL_INTERVAL_MS = 15_000; // 15 seconds
async function pollAndCache() {
try {
const scores = await fetchLiveScores();
const pipeline = redis.pipeline();
for (const match of scores) {
const key = `live:${match.matchId}`;
pipeline.set(key, JSON.stringify(match), "EX", 60);
// Track previous score to detect changes
pipeline.get(`prev:${match.matchId}`);
}
const results = await pipeline.exec();
const previous = {};
for (let i = 0; i < scores.length; i++) {
const prevRaw = results[i * 2 + 1][1];
if (prevRaw) {
previous[scores[i].matchId] = JSON.parse(prevRaw);
}
}
// Find matches where the score changed
const changed = scores.filter((match) => {
const prev = previous[match.matchId];
return !prev || prev.homeScore !== match.homeScore
|| prev.awayScore !== match.awayScore;
});
// Publish changes to WebSocket channel
if (changed.length > 0) {
await redis.publish("score-updates", JSON.stringify(changed));
}
// Store current state as previous for next cycle
const storePipeline = redis.pipeline();
for (const match of changed) {
storePipeline.set(
`prev:${match.matchId}`,
JSON.stringify(match),
"EX", 120
);
}
await storePipeline.exec();
console.log(`Polled ${scores.length} matches, ${changed.length} changed`);
} catch (err) {
console.error("Poll error:", err.message);
}
}
setInterval(pollAndCache, POLL_INTERVAL_MS);
pollAndCache();Step 3: Broadcast to Clients via WebSocket
Your WebSocket server subscribes to the Redis pub/sub channel and pushes updates to connected clients. Only changed matches are sent, keeping bandwidth low:
// server.js — WebSocket server
import { WebSocketServer } from "ws";
import Redis from "ioredis";
const wss = new WebSocketServer({ port: 8080 });
const subscriber = new Redis(process.env.REDIS_URL);
wss.on("connection", (ws) => {
console.log("Client connected");
ws.on("close", () => console.log("Client disconnected"));
});
// Listen for score changes from the poller
subscriber.subscribe("score-updates");
subscriber.on("message", (channel, message) => {
if (channel !== "score-updates") return;
const changedMatches = JSON.parse(message);
// Broadcast to all connected clients
wss.clients.forEach((client) => {
if (client.readyState === ws.OPEN) {
client.send(JSON.stringify({
type: "score-update",
data: changedMatches,
timestamp: Date.now(),
}));
}
});
});Code Examples
Client-Side WebSocket Consumer
On the frontend, connect to the WebSocket server and update the DOM when new scores arrive. Always include a reconnection strategy with exponential backoff:
// client.js — browser side
const scoreCache = new Map();
let retryDelay = 1000;
function connect() {
const ws = new WebSocket("wss://yourapp.com/scores");
ws.onopen = () => {
console.log("Connected to live scores");
retryDelay = 1000; // reset backoff
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === "score-update") {
for (const match of message.data) {
scoreCache.set(match.matchId, match);
updateScoreCard(match);
}
}
};
ws.onclose = () => {
console.log(`Disconnected. Reconnecting in ${retryDelay}ms...`);
setTimeout(connect, retryDelay);
retryDelay = Math.min(retryDelay * 2, 30_000);
};
}
function updateScoreCard(match) {
const el = document.getElementById(`match-${match.matchId}`);
if (!el) return;
el.querySelector(".home-score").textContent = match.homeScore ?? 0;
el.querySelector(".away-score").textContent = match.awayScore ?? 0;
el.querySelector(".minute").textContent =
match.status === "FT" ? "Full Time" : `${match.minute}'`;
}
connect();Best Practices
Poll once, broadcast to many
Never let individual clients call the upstream API directly. A single background poller feeding a cache and WebSocket layer means 10 users or 10,000 users consume the same number of API requests.
Use adaptive poll intervals
Poll every 5 seconds during live matches, but back off to 60 seconds during halftime and stop entirely when no matches are in progress. This can cut API costs by 70% or more.
Implement graceful degradation
If the WebSocket connection drops, fall back to REST polling on the client side. If the API itself fails, serve the last cached scores with a stale indicator so users are never staring at a blank screen.
Monitor API quota in real time
Track remaining requests against your daily limit. Alert when usage crosses 80%. Consider rate-limit handling patterns to avoid surprise outages.
Related Guides
WebSocket vs Polling for Sports Data
12 min readCaching Strategies for Sports API Data
12 min readError Handling & Retry Strategies
14 min readReady to find the right API?
Compare live-score APIs by coverage, latency, and price.