DEV Community

Cover image for Surviving a 150-Requests-a-Day Free Tier: Caching Patterns for Sports APIs
orbistats
orbistats

Posted on

Surviving a 150-Requests-a-Day Free Tier: Caching Patterns for Sports APIs

Surviving a 150-Requests-a-Day Free Tier: Caching Patterns for Sports APIs

You sign up for a free sports API key, make your first request, and it works. Then you read the limit: 150 requests per day.

In a terminal, that feels generous. In a real app, it disappears fast.

Here is the math. 150 calls spread over 24 hours is one call every 9.6 minutes. If your app gets 10,000 page views a day and each view calls the API, your quota is gone in about 90 seconds of traffic. To survive, your cache hit rate must be at least 98.5%.

That is not a tuning problem. It is an architecture problem, and this guide solves it step by step in plain Node.js.

A note on limits. 150/day is a common free-tier ceiling across sports-data providers, and it is the constraint this guide designs for. Limits differ by provider and plan. The Orbistats Free plan, for example, is a time-boxed access window rather than a daily counter. The discipline is the same: every call is precious.

Prerequisites: Node.js 18+ (built-in fetch), basic JavaScript, and an API key. The quickstart guide takes you from signup to your first request.

About endpoints: the paths below follow the pattern in the Orbistats docs (https://api.orbistats.com/v1/{sport}/... with Bearer auth). Confirm exact paths and query parameters in the API reference before shipping.

Step 1: Know the shelf life of your data

The most common mistake with a small quota is using one TTL for everything. Sports data has very different shelf lives:

Data Changes Sensible TTL
Countries, competitions Almost never 7 days
Teams, player profiles Rarely 24 hours
Fixtures A few times a day 3-6 hours
Standings After each match ends 30-60 minutes
Live score Every few seconds 30-60 seconds
Finished match Never Forever
Historical seasons Never Forever

The last two rows are where free tiers are won. A finished match is immutable. Asking for it twice wastes a call.

Step 2: Budget your 150 calls like money

Write the budget before you write any cache code. Here is one for an app covering all 13 sports, including football, basketball and cricket:

Bucket Calls/day Why
Live reserve 40 Spent only when a match is on
Fixtures (13 sports x 1) 13 Schedules barely move
Standings (13 sports x 2) 26 Morning and late evening
Results sync (13 sports x 2) 26 Save finished matches forever
Cache-miss buffer 45 Retries and surprises
Total 150

That is about 11.5 calls per sport per day.

Notice what is missing: “one call per user request.” Users never talk to the API. Users talk to your cache. Only your background jobs talk to the API.

Covering fewer sports? Your budget is much roomier. Start with the sports your audience actually watches.

Step 3: Build a TTL cache with LRU eviction

The foundation is an in-memory cache that tracks freshness and staleness separately. Later steps depend on that split.

js
// cache.js
export class TTLCache {
constructor({ maxEntries = 500 } = {}) {
this.store = new Map();
this.maxEntries = maxEntries;
}

get(key) {
const entry = this.store.get(key);
if (!entry) return null;

// LRU bump: re-insert so recently used keys sit at the end
this.store.delete(key);
this.store.set(key, entry);

const now = Date.now();
return {
  value: entry.value,
  fresh: now < entry.freshUntil,   // serve with zero network
  usable: now < entry.staleUntil,  // serve, but refresh in background
};
Enter fullscreen mode Exit fullscreen mode

}

set(key, value, ttlMs, staleMs = 0) {
const now = Date.now();
this.store.delete(key);
this.store.set(key, {
value,
freshUntil: now + ttlMs,
staleUntil: now + ttlMs + staleMs,
});
if (this.store.size > this.maxEntries) {
this.store.delete(this.store.keys().next().value); // evict LRU
}
}
}

Each entry has two clocks:

Fresh: serve instantly, no call.
Stale but usable: serve instantly and refresh quietly.
Expired: keep it only as an emergency fallback (Step 5).

Step 4: Stop cache stampedes with single-flight

Here is a quiet quota killer. Your football standings entry expires at 12:00:00. At 12:00:01, 200 users load your page. Every request sees an empty cache and fires its own API call. You just spent 200 calls on one piece of data.

The fix is single-flight: if a request for key X is already running, everyone else waits for the same promise.

js
refresh(key, url, ttlMs, staleMs, kind) {
// Someone is already fetching this key? Join them.
if (this.inflight.has(key)) return this.inflight.get(key);

const promise = (async () => {
// budget check + fetch + cache.set (full version in Step 7)
})().finally(() => this.inflight.delete(key));

this.inflight.set(key, promise);
return promise;
}

200 simultaneous users now cost 1 call. This one pattern often saves more quota than any TTL tweak.

Step 5: Serve stale data on purpose

Stale-while-revalidate (SWR) works like this:

The entry is a bit old, so return it immediately.
Start one background refresh.
The next visitor gets fresh data.

Stale-if-error is its cousin. If the API fails or your quota is spent, serve the last known value instead of an error page. A ten-minute-old standings table beats a 500 every time.

When something looks wrong, check the provider’s status page first. It is the fastest way to learn whether the problem is your cache or their infrastructure.

Step 6: Add a budget guard

Caching reduces calls. A budget guard guarantees you never exceed the limit, even when you ship a bug. It also reserves part of the quota for live data, so a runaway standings job cannot starve a live match.

js
// budget.js
export class DailyBudget {
constructor({ limit = 150, reserveForLive = 40 } = {}) {
this.limit = limit;
this.reserveForLive = reserveForLive;
this.day = this.today();
this.used = 0;
}

today() {
// Check your provider: UTC midnight reset, or a rolling 24h window?
return new Date().toISOString().slice(0, 10);
}

roll() {
const t = this.today();
if (t !== this.day) { this.day = t; this.used = 0; }
}

canSpend(kind = 'static') {
this.roll();
const ceiling = kind === 'live' ? this.limit : this.limit - this.reserveForLive;
return this.used < ceiling;
}

spend() { this.roll(); this.used += 1; }
remaining() { this.roll(); return this.limit - this.used; }
}

This counter lives in memory, so it resets on restart. Step 14 shows how to persist it.

Step 7: Assemble the cached client

Now combine everything: cache, single-flight, SWR, stale-if-error and the budget guard.

js
// client.js
import { TTLCache } from './cache.js';
import { DailyBudget } from './budget.js';

export class SportsClient {
constructor({
apiKey,
baseUrl = 'https://api.orbistats.com/v1',
cache = new TTLCache(),
budget = new DailyBudget(),
}) {
Object.assign(this, { apiKey, baseUrl, cache, budget });
this.inflight = new Map();
this.stats = { hits: 0, stale: 0, network: 0, errors: 0 };
}

buildUrl(path, params = {}) {
const url = new URL(this.baseUrl + path);
// Sorted params: ?a=1&b=2 and ?b=2&a=1 share one cache key
Object.keys(params).sort().forEach((k) => url.searchParams.set(k, params[k]));
return url;
}

async get(path, { params, ttlMs, staleMs = 0, kind = 'static' } = {}) {
const url = this.buildUrl(path, params);
const key = url.pathname + url.search;
const hit = this.cache.get(key);

if (hit?.fresh) {                       // 1) fresh: free answer
  this.stats.hits++;
  return { data: hit.value, source: 'cache' };
}

if (hit?.usable) {                      // 2) stale: answer now, refresh quietly
  this.stats.stale++;
  this.refresh(key, url, ttlMs, staleMs, kind).catch(() => {});
  return { data: hit.value, source: 'stale' };
}

try {                                   // 3) miss: network, fall back on failure
  const data = await this.refresh(key, url, ttlMs, staleMs, kind);
  return { data, source: 'network' };
} catch (err) {
  this.stats.errors++;
  if (hit) return { data: hit.value, source: 'stale-if-error' };
  throw err;
}
Enter fullscreen mode Exit fullscreen mode

}

refresh(key, url, ttlMs, staleMs, kind) {
if (this.inflight.has(key)) return this.inflight.get(key);

const promise = (async () => {
  if (!this.budget.canSpend(kind)) throw new Error('BUDGET_EXHAUSTED');
  this.budget.spend();
  this.stats.network++;

  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${this.apiKey}` },
  });

  if (res.status === 429) {
    this.budget.used = this.budget.limit; // our count was wrong; stop spending
    throw new Error('RATE_LIMITED');
  }
  if (!res.ok) throw new Error(`HTTP_${res.status}`);

  const data = await res.json();
  this.cache.set(key, data, ttlMs, staleMs);
  return data;
})().finally(() => this.inflight.delete(key));

this.inflight.set(key, promise);
return promise;
Enter fullscreen mode Exit fullscreen mode

}

hitRate() {
const { hits, stale, network } = this.stats;
const total = hits + stale + network;
return total ? (hits + stale) / total : 0;
}
}

Usage:

js
const client = new SportsClient({ apiKey: process.env.ORBISTATS_KEY });

const { data, source } = await client.get('/football/standings', {
params: { competition: 'premier-league' },
ttlMs: 2_700_000, // fresh for 45 minutes
staleMs: 10_800_000, // then usable for 3 more hours
});

Every call site declares its own TTL. Standings and live scores never share a lifetime by accident.

Step 8: Store finished matches forever

An in-memory cache vanishes on restart. For data that can never change, use a permanent store. SQLite is perfect: no server, one file, very fast.

js
// finished.js
import Database from 'better-sqlite3';

const db = new Database('sports.db');
db.exec(
CREATE TABLE IF NOT EXISTS finished_matches (
id TEXT PRIMARY KEY,
sport TEXT NOT NULL,
payload TEXT NOT NULL,
saved_at INTEGER NOT NULL
)
);

const selectOne = db.prepare('SELECT payload FROM finished_matches WHERE id = ?');
const upsert = db.prepare(
'INSERT OR REPLACE INTO finished_matches VALUES (?, ?, ?, ?)'
);

export const getFinished = (id) => {
const row = selectOne.get(id);
return row ? JSON.parse(row.payload) : null;
};

export function saveFinished(match, sport) {
// Confirm the exact "finished" status value in the API reference
if (match.status !== 'finished') return false;
upsert.run(match.match_id, sport, JSON.stringify(match), Date.now());
return true;
}

A nightly results-sync job now makes one call per sport, saves every finished match, and never asks for it again.

The same idea powers model training. If you are backtesting on past seasons, download once and keep your own copy. The Historical Sports Data API is built for exactly that, and the Sports Statistics API pairs well with it for player and team aggregates.

Step 9: Warm the cache on a schedule

This is the mental shift that makes 150 calls viable.

Before: user opens page, your server calls the API, user waits.
After: a scheduled job calls the API, the cache stays warm, the user gets an instant answer with zero API calls.
js
// warmer.js
const SPORTS = [
'football', 'basketball', 'american-football', 'cricket', 'tennis',
'baseball', 'esports', 'combat-sports', 'volleyball', 'handball',
'ice-hockey', 'golf', 'horse-racing',
];

const ONE_HOUR = 3_600_000;
const SIX_HOURS = 21_600_000;
const TWELVE_HOURS = 43_200_000;

async function warm(client, resource, ttlMs, staleMs) {
for (const sport of SPORTS) {
try {
await client.get(/${sport}/${resource}, { ttlMs, staleMs });
} catch (err) {
console.warn(warm ${sport}/${resource} failed:, err.message);
}
}
}

export function startWarmers(client) {
warm(client, 'fixtures', SIX_HOURS, TWELVE_HOURS); // on boot
setInterval(() => warm(client, 'fixtures', SIX_HOURS, TWELVE_HOURS), SIX_HOURS);
setInterval(() => warm(client, 'standings', ONE_HOUR, SIX_HOURS), TWELVE_HOURS);
}

Public routes then read only from the cache and never fall through to the network:

js
app.get('/api/standings/:sport', (req, res) => {
const hit = client.cache.get(/v1/${req.params.sport}/standings);
if (!hit) return res.status(503).json({ error: 'Warming up, retry shortly' });
res.set('Cache-Control', 'public, max-age=60'); // CDN/browser = second cache layer
res.json(hit.value);
});

That Cache-Control header gives you a free second cache layer at your CDN and in the browser.

Step 10: Fetch wide, slice locally

Per-item endpoints drain quota:

text
GET /football/matches/101 <- 1 call
GET /football/matches/102 <- 1 call
GET /football/matches/103 <- 1 call

List endpoints are cheap:

text
GET /football/fixtures <- 1 call, filter in your own code

Whenever you write a loop that makes one API call per item, stop and look for a list endpoint. The Sports Data API covers fixtures, results, standings, teams and competitions, so a list is usually available. Check the documentation for date and competition filters.

Fetch the day’s fixtures once, then slice by team, league or kickoff time in memory.

Step 11: Push instead of poll for live data

Live scores are the hardest data to cache, because a 30-second TTL on a busy match still means two calls a minute. Poll one match for 2 hours and you have spent 240 calls on a single game.

Polling live data on a small quota does not work. Switch to push.

Webhooks: the provider calls your endpoint when something happens (goal, card, full time).
WebSocket API: one persistent connection streams events as they occur.
Live Scores API: the REST endpoint for snapshots when you need current state.

A webhook receiver that writes straight into your cache:

js
import crypto from 'node:crypto';
import express from 'express';

const app = express();

// Verify signatures against the RAW body, so use express.raw here
app.post('/webhooks/sports', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.get('X-Signature') || ''; // header name: confirm in the webhooks docs
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(req.body)
.digest('hex');

const ok =
sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!ok) return res.sendStatus(401);

const event = JSON.parse(req.body.toString('utf8'));
client.cache.set(live:${event.match_id}, event, 60_000, 300_000);

res.sendStatus(200); // acknowledge fast, do heavy work elsewhere
});

Pushed events do not count as polling calls, so your live reserve stays untouched. Verify the real signature scheme in the webhook docs before trusting this code.

If you only need a score box on a page, a drop-in widget can remove your live-data work entirely.

Step 12: Measure your hit rate

You cannot protect a quota you do not measure. Expose three numbers:

js
app.get('/internal/metrics', (_req, res) => {
res.json({
hitRate: client.hitRate(), // target: above 0.985
callsRemaining: client.budget.remaining(),
...client.stats,
});
});

Check them like this:

Hit rate below 95%? A TTL is too short, or a cache key is unstable (unsorted params, timestamps in the URL).
callsRemaining falling fast in the morning? A warmer is running too often.
Many stale-if-error responses? Check the upstream status page, then your network.

Step 13: Test without spending a single call

Here is the most useful habit on a small quota: never spend real calls on testing your cache. Stub fetch and assert on call counts.

js
import assert from 'node:assert';
import { SportsClient } from './client.js';

const calls = [];
globalThis.fetch = async (url) => {
calls.push(String(url));
return new Response(JSON.stringify({ table: [] }), { status: 200 });
};

const client = new SportsClient({ apiKey: 'test' });

// 200 simultaneous users, one standings table
await Promise.all(
Array.from({ length: 200 }, () =>
client.get('/football/standings', { ttlMs: 60_000 })
)
);

assert.equal(calls.length, 1, 'single-flight should collapse 200 calls into 1');
console.log('OK, network calls:', calls.length);

One practical tip: Orbistats access works in time windows. The Free plan is one hour with no card, and the timer starts when you open your activation link. Starter is 24 hours and Growth is five days, with every sport and endpoint on every plan. Current details are on the pricing page.

So build and test everything above locally, with stubbed responses, before you start the clock. Steps 1 to 12 need no live data. Then spend your window verifying real payloads and field names, using the sandbox and SDKs, and let one real match prove the pipeline.

Step 14: Production hardening checklist

The tutorial version is correct. A production version is also operable:

Persist the budget in Redis. An in-memory counter resets on every deploy. Use an atomic increment:
js
const key = budget:${new Date().toISOString().slice(0, 10)};
const used = await redis.incr(key);
if (used === 1) await redis.expire(key, 129_600); // 36 hours
if (used > ceiling) { await redis.decr(key); return false; }
Move the cache to Redis too if you run more than one server. Otherwise each instance warms its own copy and multiplies your calls.
Run warmers in a single process. Two warmers means double spend. Use one worker or a distributed lock.
Add jitter to TTLs. If 13 sports all expire at the same second, you create your own stampede. Add 5-10% random variation.
Alert at 80% quota. Page yourself before the limit, not after.
Cache errors briefly. If an endpoint returns 404 or 5xx, remember that for 30-60 seconds so retries do not burn calls.
Log the source of every response (cache, stale, network). It makes debugging trivial.
Watch the changelog. Field changes can silently break cached payload shapes. Follow the changelog and version your cache keys (v1:).
Know when to upgrade. If your hit rate is above 98% and you still run out, you have outgrown the free tier. That is a good problem, and the comparison pages show how providers stack up.

Wrapping up

Surviving a tiny quota is not about clever tricks. It is about never asking the same question twice:

Give each data type its own TTL
Collapse duplicate requests with single-flight
Serve stale data on purpose
Guard the budget with a live reserve
Store immutable data forever
Warm caches from schedules, not users
Push, don’t poll, for live data

Do these and 150 calls a day can comfortably serve thousands of users.

Want to try it on real data? Request access, and keep the glossary and odds converter tools handy if you plan to add the Odds API later.

What caching trick saved your quota? Tell me in the comments.

Top comments (0)