Build a live football score app with a free API

A complete walkthrough: choosing endpoints, caching so traffic does not grow your bill, polling live matches, handling the UTC date boundary, and shipping match pages that never open onto nothing.

Updated 2026-10-23 11 min read

A live score app looks simple and then meets reality: quota evaporating, matches vanishing at midnight, tabs that open onto nothing, a key leaked in the browser. This guide walks the whole build, in the order the problems actually arrive.

1. Decide what your product is

This choice sets every later one:

  • A watch product — people come to find something to watch. Use matches, which returns only fixtures with a stream. Smaller, and on the free tier.
  • A score product — people come to check results. Use scores, which returns every fixture and flags the watchable ones.

Most products are the second with a bit of the first: scores for the list, a stream icon where has_stream is true. See matches vs scores for the full comparison.

2. Call the API from your server, never the browser

Two reasons, and the second is the one that bites later.

Your key leaks. Anything a browser sends is visible in devtools. A key in front-end code is a public key.

Your quota scales with traffic. Browser calls mean one thousand concurrent visitors make one thousand requests. Server calls mean they make one, and everyone reads the cache.

Browser  ->  your server  ->  cache  ->  SportSRC
                                  ^
                        every visitor stops here

3. Build the cache before the features

This is the single decision that keeps a free tier viable at scale. The pattern is small:

// Fetch at most once per window, no matter who asks.
const TTL = 30_000;
let cache = { at: 0, data: null };

async function fixtures() {
  if (Date.now() - cache.at < TTL && cache.data) return cache.data;

  const res = await fetch(
    'https://api.sportsrc.org/v3/?type=scores',
    { headers: { 'X-API-KEY': process.env.SPORTSRC_KEY } }
  );
  if (!res.ok) return cache.data;        // serve stale rather than nothing

  cache = { at: Date.now(), data: await res.json() };
  return cache.data;
}

Note the failure line. When upstream has a hiccup, returning the last good answer is better than returning an error: a score thirty seconds old beats an empty screen.

What this actually costs

One call every 30 seconds, for the 16 hours a day when matches are being played somewhere, is about 1,900 requests. That fits inside a free 1,000/day plan if you widen the interval a little, and comfortably inside Starter. Crucially, it does not change when your traffic does.

4. Poll only while something is live

A fast interval overnight buys nothing. Check whether any fixture is in play and back off when none is:

function pollInterval(list) {
  const live = list.some(m => m.status === 'inprogress');
  return live ? 30_000 : 300_000;   // 30s live, 5 min idle
}

On a quiet weekday this alone can halve your daily usage.

5. Handle the UTC date boundary

Fixture dates are UTC. A match kicking off at 23:30 UTC belongs to that date but is still being played after midnight — so a list built from “today” alone will drop a live match mid-second-half.

South American and North American evening fixtures are where this shows up. The fix is to carry live fixtures across rather than trusting the date window:

// Keep anything in play, whichever UTC date it belongs to.
const merged = [...today, ...yesterday.filter(m => m.status === 'inprogress')];

If a match ever disappears from your UI around midnight UTC, this is almost certainly why.

6. Render tabs from flags, not from hope

Coverage differs per competition. Decide which tabs exist before you render:

const tabs = ['summary'];
if (match.has_stream)     tabs.push('watch');
if (match.has_stats)      tabs.push('lineups', 'stats', 'incidents');
if (league.has_standings) tabs.push('table');
if (league.has_odds)      tabs.push('odds');

The flags arrive inside the fixture list you already fetched, so this costs nothing. Full detail in the has_stream guide.

7. Fetch match detail lazily

Do not pre-fetch line-ups and statistics for every fixture on the list. Most visitors open a handful of matches; fetching all of them turns a 50-fixture page into 50 wasted requests.

Fetch when the user opens the match, and cache per match:

// Cached per match id, short TTL while in play, longer once finished.
const ttl = match.status === 'inprogress' ? 60_000 : 600_000;

A finished match's data stops changing, so there is no reason to keep asking for it.

8. Switch on status, not on text

status is a fixed vocabulary. Compare against these exact strings and your UI will not break when a label changes:

ValueRender
notstartedKick-off time. Timer is empty.
inprogressLive score and timer.
finishedFinal score.
canceledCalled off, postponed or abandoned.

9. Watch your own usage

Every response carries the running total, so monitoring is free. Log it and alert on it:

const remaining = Number(res.headers.get('X-RateLimit-Remaining'));
if (remaining < 100) console.warn('[sportsrc] quota low:', remaining);

Finding out you ran dry because users complained is the expensive way to learn it.

10. Be honest about streams in your UI

Stream sources come from third parties. They appear close to kick-off, sometimes after it, and they can drop mid-match. Two things follow:

  • Let a Watch tab appear while the page is open. A viewer who arrived early is the one most likely to still be there at kick-off.
  • Offer every available source rather than only the first. When one drops, switching is one click instead of a dead end.

A checklist

  1. Pick matches or scores to match your product.
  2. Call from the server; keep the key out of the browser.
  3. Cache first, features second.
  4. Back off the poll when nothing is live.
  5. Carry live fixtures across the UTC boundary.
  6. Build tabs from the flags.
  7. Fetch match detail lazily, cache per match.
  8. Switch on the fixed status strings.
  9. Log X-RateLimit-Remaining.
  10. Treat stream availability as live, not fixed.

Frequently asked

How many API requests does a live score app need per day?

Far fewer than most people expect, if you cache on the server. Polling one fixture list every 30 seconds for 16 active hours is roughly 1,900 requests a day regardless of how many visitors you have, because every visitor is served from your cache.

Should I call the API from the browser or from my server?

From your server. A browser call exposes your API key to anyone who opens devtools, and it multiplies requests by your visitor count instead of keeping them flat.

How often should I poll for live scores?

Every 20 to 60 seconds while matches are in play, and much less often when nothing is live. Scores do not change faster than that in a way users notice, and backing off overnight saves most of a day quota.

Do I need a paid plan to launch?

No. The free tier gives 1,000 requests a day, which is enough to run a cached, watch-focused app. You move to a paid plan when you need the full fixture list or deep match data.