DEV Community

Cover image for Stop Polling Every 2 Seconds — Here's What's Eating Your API Quota
orbistats
orbistats

Posted on

Stop Polling Every 2 Seconds — Here's What's Eating Your API Quota


You bumped your polling interval down to catch live updates faster, and now you're hitting 429 Too Many Requests mid-match. This is one of the most common ways teams accidentally burn through their entire monthly quota in the first week — let's do the actual math on why, and the code that fixes it.

Do the math on what 2-second polling actually costs
javascript
// Innocent-looking code
setInterval(fetchLiveScore, 2000); // "just" every 2 seconds

That's 1,800 requests per hour, per fixture you're tracking. Now scale it:

Polling interval Requests/hour (1 fixture) Requests/hour (20 fixtures) Requests/day (20 fixtures)
2 sec 1,800 36,000 864,000
5 sec 720 14,400 345,600
15 sec 240 4,800 115,200
60 sec 60 1,200 28,800

If your plan gives you 10,000 or even 100,000 requests/day, 2-second polling across a modest 20-fixture slate blows past it before lunch — and that's before you count retries.

The retry trap that makes it worse

This is the part most people miss: hitting a rate limit and retrying immediately doesn't just fail — it actively accelerates the problem. Rate-limiting documentation across providers consistently flags the same failure pattern: retrying API calls whenever they fail without noticing the error type causes an escalation in rate limits, alongside monitoring transactions via polling instead of webhooks as a top common mistake.

javascript
// This makes quota exhaustion WORSE, not better
async function badRetry(fixtureId) {
try {
return await fetchLiveScore(fixtureId);
} catch (err) {
if (err.status === 429) {
return badRetry(fixtureId); // fires again immediately — quota death spiral
}
}
}

Every immediate retry on a 429 is another request against a quota you've already exceeded. Documented best practice across multiple API platforms is consistent: wait for the Retry-After header, then apply exponential backoff, capped at 3-5 attempts — never retry instantly.

javascript
async function fetchWithBackoff(fixtureId, attempt = 0) {
const MAX_ATTEMPTS = 4;
try {
const res = await fetch(https://api.orbistats.com/v1/football/fixtures/${fixtureId}, {
headers: { Authorization: Bearer ${API_KEY} }
});

if (res.status === 429) {
  if (attempt >= MAX_ATTEMPTS) throw new Error("Max retries exceeded");
  const retryAfter = parseInt(res.headers.get("Retry-After") || "1", 10);
  const jitter = Math.random() * 0.3 * retryAfter; // spread out simultaneous clients
  await new Promise(r => setTimeout(r, (retryAfter + jitter) * 1000));
  return fetchWithBackoff(fixtureId, attempt + 1);
}

return await res.json();
Enter fullscreen mode Exit fullscreen mode

} catch (err) {
console.error(Fetch failed for ${fixtureId}:, err.message);
throw err;
}
}

Adding jitter matters specifically when multiple clients or workers share the same account — without it, they all retry at the exact same moment and collectively hit the limit again immediately.

Fix #1: cache what doesn't change every second

A huge share of "wasted" quota is re-fetching data that hasn't changed at all — standings, team rosters, league metadata. Best-practice guides on this consistently recommend caching aggressively for reference data since categories, attributes, and settings don't change often.

javascript
const cache = new Map();
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes — standings don't need to be fresher than this

async function getStandings(leagueId) {
const cached = cache.get(leagueId);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
return cached.data; // zero quota cost
}

const data = await fetchStandings(leagueId);
cache.set(leagueId, { data, timestamp: Date.now() });
return data;
}

This one change alone typically cuts a large share of total request volume, because most of what apps poll for isn't actually live-changing data.

Fix #2: batch instead of looping

If you're fetching 20 fixtures one request at a time, you're paying for 20 round-trips when one would do:

javascript
// Wasteful — 20 separate requests, 20x the quota cost
for (const id of fixtureIds) {
await fetchLiveScore(id);
}
javascript
// Better — one request, filtered server-side
const res = await fetch(
https://api.orbistats.com/v1/football/fixtures?ids=${fixtureIds.join(",")},
{ headers: { Authorization: Bearer ${API_KEY} } }
);
const allFixtures = await res.json();

Using list endpoints with filtering instead of many individual requests is repeatedly cited as a top best practice precisely because it's this simple and this effective.

Fix #3: the actual fix — stop polling for live data entirely

Caching and batching reduce the damage. They don't fix the core problem, because live scores genuinely do change every few seconds, and no cache TTL is honest about that. The real fix is architectural: subscribe instead of asking.

javascript
// Zero ongoing quota cost after connecting once
const ws = new WebSocket("wss://stream.orbistats.com/v1/live");

ws.onopen = () => {
ws.send(JSON.stringify({
action: "subscribe",
channel: "live_scores",
fixture_ids: fixtureIds // subscribe to all 20 in one connection
}));
};

ws.onmessage = (event) => {
const update = JSON.parse(event.data);
updateUI(update);
};

Compare quota impact directly: 20 fixtures polled every 2 seconds costs 36,000 requests/hour. The same 20 fixtures over one WebSocket subscription costs 1 connection — not 1 request per update, one connection for the entire match, regardless of how many updates flow through it. This is exactly why rate-limiting documentation across API platforms — payment processors, logistics APIs, blockchain infra, scheduling tools — all converge on the identical recommendation: use webhooks/streaming instead of polling to eliminate rate-limit risk entirely rather than just managing it.

For discrete events specifically (a scratch, a red card, a final result) rather than a constant stream, a webhook is the leaner option:

javascript
app.post("/webhooks/orbistats", (req, res) => {
const { event, fixture_id, data } = req.body;
if (event === "fixture.finished") {
finalizeFixture(fixture_id, data);
}
res.sendStatus(200); // acknowledge fast, process async if needed
});
The decision table
Your need Right tool Quota impact
Standings, rosters, rarely-changing data Cached REST Minimal — mostly cache hits
Live score during a match WebSocket subscription One connection, not per-update requests
"Notify me when X happens" (scratch, result) Webhook Zero polling, event-driven only
One-time historical pull REST, batched One request for many records
Where this fits with Orbistats

Our sports data API supports filtered batch requests so you're not looping per fixture, but for anything genuinely live, the WebSocket API and webhooks exist specifically to get you off the polling treadmill described above. Check your current usage patterns against your pricing tier's request limits, test the batch and subscription patterns in our public sandbox with no signup, and see exact rate-limit headers and retry guidance in the API reference and documentation. Our odds API and live scores API both support the same subscribe-once pattern shown above, and our status page shows current uptime if you're debugging a spike in 429s.

Top comments (0)