Overview
A live score app has one job: show users the current score of in-progress matches with minimal latency. That sounds simple until you consider the constraints: thousands of concurrent users, data that changes every few seconds, a hard dependency on third-party APIs with rate limits, and traffic that spikes 50x during major events. The architecture must absorb all of this without falling over.
This guide is the technical counterpart to our build a live score app walkthrough. Where that guide focuses on the build steps, this one focuses on the architecture decisions and the code that holds up under load.
Core principle: Decouple ingestion from delivery. Your API poller writes to a cache and a message bus; your WebSocket layer reads from the message bus and pushes to clients. They scale independently and never block each other.
Architecture Overview
The system has four layers, each with a single responsibility. Data flows left to right: the ingester pulls from the API, writes to the cache and the pub/sub bus, the WebSocket gateway fans updates out to connected clients, and the database stores historical results for after the match.
┌─────────────┐ ┌──────────────┐ ┌───────────────┐ ┌─────────────┐
│ Sports API │───▶│ Ingestion │──▶│ Redis Pub/Sub │──▶│ WS Gateway │
│ (upstream) │ │ Worker (xN) │ │ + Cache Layer │ │ (per shard) │
└─────────────┘ └──────────────┘ └───────────────┘ └─────────────┘
│ │ │
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌──────────────┐
│ PostgreSQL │ │ Redis Cache│ │ Browsers │
│ (historical)│ │ (live TTLs)│ │ (WebSocket) │
└────────────┘ └────────────┘ └──────────────┘The ingestion worker polls the sports API, normalises the response, and publishes a score update to a Redis channel. The WebSocket gateway subscribes to that channel and pushes the update to every connected client in under a second. The cache serves polling clients and API endpoints; the database stores final results for historical queries. None of these layers know about each other directly — they communicate through the cache and pub/sub, which is what makes the architecture horizontally scalable.
Tech Stack Selection
Your stack should optimise for two things: fast I/O for real-time fan-out and a typed data model for complex sports structures. This is the stack we recommend for a new live score app in 2026:
| Layer | Technology | Why |
|---|---|---|
| Frontend | Next.js + React Server Components | SSR for SEO, client components for live updates |
| API gateway | Next.js Route Handlers or Hono | Edge-deployed, low latency, typed |
| Cache + pub/sub | Redis (or Upstash) | Sub-ms reads, native pub/sub channels |
| Database | PostgreSQL | JSONB for flexible stats, strong consistency |
| WebSocket gateway | Socket.io or native WS + Redis adapter | Horizontal scaling across multiple nodes |
| Ingestion | Node.js worker (BullMQ or cron) | Async fetch + normalise + publish |
API Selection
Your data source determines your latency ceiling and your cost structure. For a startup, API-Sports offers the best balance of free-tier generosity and sport coverage. For an enterprise product that needs sub-second latency and official league data, Sportradar is the standard. The key differentiators for a live-score architecture are:
Live endpoint latency: How quickly does a score appear in the API after it happens in the real world?
Rate limit headroom: Can you poll every 5-10 seconds without burning your quota?
Webhook or WebSocket support: Push delivery reduces polling cost and latency dramatically.
Historical data depth: You need past results for standings and head-to-head comparisons.
Multi-sport coverage: If you plan to add sports later, pick a multi-sport provider now.
Caching Layers
A live score app needs three cache tiers, each with a different TTL. The ingestion worker writes to all three on every poll. For the full tiered TTL strategy, read our caching guide.
// lib/cache.js — three-tier cache for a live score app
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
const TTL = {
live: 15, // in-progress scores: 15s (also used by pollers)
fixtures: 300, // today's fixtures: 5 min
standings: 3600, // standings: 1 hour
historical: 604800, // past results: 7 days
};
export async function writeScore(matchId, score) {
const key = `live:match:${matchId}`;
await redis.setex(key, TTL.live, JSON.stringify(score));
// Publish to the channel the WS gateway listens on
await redis.publish("scores", JSON.stringify({ matchId, ...score }));
}
export async function readScore(matchId) {
const raw = await redis.get(`live:match:${matchId}`);
return raw ? JSON.parse(raw) : null;
}WebSocket Integration
The WebSocket gateway is the only layer that talks to browsers. It subscribes to the Redis pub/sub channel and forwards score updates to connected clients. The critical pattern: the gateway never calls the sports API directly. It only reads from the cache and pub/sub, so it can scale to tens of thousands of connections without touching your API quota.
// server.js — WebSocket gateway with Redis pub/sub fan-out
import { createServer } from "http";
import { Server } from "socket.io";
import { createAdapter } from "@socket.io/redis-adapter";
import Redis from "ioredis";
const httpServer = createServer();
const io = new Server(httpServer, {
cors: { origin: process.env.APP_URL, methods: ["GET"] },
});
// Redis adapter lets multiple gateway instances share connections
const pubClient = new Redis(process.env.REDIS_URL);
const subClient = pubClient.duplicate();
io.adapter(createAdapter(pubClient, subClient));
// Subscribe to score updates published by the ingestion worker
const scoreChannel = new Redis(process.env.REDIS_URL);
scoreChannel.subscribe("scores");
scoreChannel.on("message", (channel, message) => {
if (channel !== "scores") return;
const update = JSON.parse(message);
// Fan out to every client watching this match's league
io.to(`league:${update.leagueId}`).emit("score:update", update);
});
io.on("connection", (socket) => {
// Client joins a room for the league they are viewing
socket.on("subscribe:league", (leagueId) => {
socket.join(`league:${leagueId}`);
});
socket.on("unsubscribe:league", (leagueId) => {
socket.leave(`league:${leagueId}`);
});
});
httpServer.listen(3001, () => {
console.log("WebSocket gateway on :3001");
});The client side is simple: open a connection, join the league room, and listen for updates. On reconnect, fetch the latest score from the REST endpoint first to catch any updates missed during the disconnection.
Database Schema
PostgreSQL stores historical results, standings, and team metadata — data that does not need to be in Redis but must persist. Live scores live in Redis only; they are written to PostgreSQL when the match ends. This separation keeps the database write load low. For the full schema design, see our sports database guide.
-- schema.sql — core tables for a live score app
CREATE TABLE leagues (
id SERIAL PRIMARY KEY,
api_ref VARCHAR(100) UNIQUE NOT NULL, -- provider's league ID
name VARCHAR(200) NOT NULL,
country VARCHAR(100),
season INT NOT NULL
);
CREATE TABLE teams (
id SERIAL PRIMARY KEY,
api_ref VARCHAR(100) UNIQUE NOT NULL,
name VARCHAR(200) NOT NULL,
logo_url TEXT
);
CREATE TABLE matches (
id SERIAL PRIMARY KEY,
api_ref VARCHAR(100) UNIQUE NOT NULL,
league_id INT REFERENCES leagues(id),
home_id INT REFERENCES teams(id),
away_id INT REFERENCES teams(id),
kickoff_ts TIMESTAMPTZ NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'scheduled', -- scheduled|in_progress|finished
home_score INT DEFAULT 0,
away_score INT DEFAULT 0,
minute INT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_matches_league_status ON matches(league_id, status);
CREATE INDEX idx_matches_kickoff ON matches(kickoff_ts DESC);Scaling Strategies
A live score app’s traffic is spiky: a Champions League final can multiply your normal load by 50x for two hours. The architecture above is designed to scale horizontally, but you need to apply these strategies as you grow:
Scale the WebSocket gateway, not the ingester
Connection count is the bottleneck, not ingestion. Run multiple WebSocket gateway instances behind a load balancer with the Redis adapter so they share rooms. Keep a single ingester (or a small fixed number) because API quota limits how many pollers you can run.
Use connection sharding by league
Group users by the league they are watching. When a score updates, you only fan out to clients in that league’s room, not every connected client. This keeps per-message fan-out cost proportional to interested users, not total users.
Degrade gracefully under load
When load spikes, increase the poll interval automatically (5s → 15s → 30s) and let the cache absorb the difference. Users prefer a 30-second delayed score over a connection error. Build adaptive polling that reads your own response latency and backs off before the API enforces it.
Pre-warm the cache before kick-off
For known major events, fetch fixtures, lineups, and standings 15 minutes before kick-off and cache them. When the spike hits, every request is a cache hit and the API is only hit by the single poller, not by thousands of new users loading the page.
Code Examples
Adaptive Polling Worker
This worker adjusts its poll interval based on whether matches are in progress. It polls every 5 seconds during live matches and every 5 minutes when no matches are active, which keeps the API quota cost proportional to actual event volume.
// worker.js — adaptive live score poller
import { writeScore } from "./lib/cache";
import { upsertMatch } from "./lib/db";
const API_URL = process.env.SPORTS_API_BASE;
const API_KEY = process.env.SPORTS_API_KEY;
async function pollLiveScores() {
const url = `${API_URL}/fixtures?live=all`;
const res = await fetch(url, {
headers: { "x-apisports-key": API_KEY },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { response } = await res.json();
const liveCount = response.length;
for (const fixture of response) {
const score = {
matchId: fixture.fixture.id,
leagueId: fixture.league.id,
home: fixture.teams.home.name,
away: fixture.teams.away.name,
homeScore: fixture.goals.home ?? 0,
awayScore: fixture.goals.away ?? 0,
minute: fixture.fixture.status.elapsed ?? 0,
status: fixture.fixture.status.short,
};
await writeScore(score.matchId, score); // cache + pub/sub
}
// Adaptive interval: 5s when live, 5 min when idle
return liveCount > 0 ? 5000 : 300000;
}
async function loop() {
while (true) {
try {
const delay = await pollLiveScores();
await new Promise((r) => setTimeout(r, delay));
} catch (e) {
console.error("Poll failed:", e.message);
await new Promise((r) => setTimeout(r, 10000)); // back off on error
}
}
}
loop();Best Practices
Never let the WebSocket gateway call the upstream API
The gateway’s job is fan-out, not data fetching. If it calls the sports API, every connected user indirectly consumes API quota, and a traffic spike becomes an API bill spike. The gateway reads only from cache and pub/sub.
Normalise once at the ingestion boundary
Convert the provider’s response schema into your own stable shape the moment data enters your system. Every downstream layer — cache, pub/sub, database, client — works with your normalised shape, so switching providers later only changes the ingester.
Handle WebSocket reconnection and gap-filling
Connections drop constantly on mobile networks. On reconnect, immediately fetch the latest score from the REST endpoint to fill the gap, then resume the WebSocket subscription. Never trust that the client has the latest state after a reconnection.
Persist final scores asynchronously
Write to PostgreSQL only when a match ends, not on every score change. Live data belongs in Redis; durable history belongs in the database. This keeps database write load low and avoids contention during high-traffic windows.
Monitor fan-out latency, not just API latency
Track the time from a score appearing in the API to it reaching the browser. If your API is fast but your pub/sub or gateway is slow, users see stale data. Alert on end-to-end latency, not just the upstream API response time.
Related Guides
WebSocket vs Polling for Sports Data
12 min readHow to Design a Sports Database
15 min readCaching Strategies for Sports API Data
12 min readStart building with the right API
Compare providers by live data support, WebSocket capabilities, and pricing to build your architecture on a solid foundation.