Overview
Sports data has a unique characteristic: some of it changes every second (live scores), some changes daily (standings, fixtures), and some never changes at all (historical match results from last season). A one-size-fits-all cache TTL wastes either money or freshness. The key is tiered caching with data-type-specific expiration strategies.
A well-designed caching layer can reduce API calls by 80-95%, which directly translates to lower costs and higher rate limit headroom. For a typical live score app serving 10,000 users, effective caching can be the difference between a $50/month plan and a $500/month plan.
Pro tip: Use our cost calculator to estimate how much you can save by implementing caching for your specific API and usage pattern.
Architecture
A tiered caching architecture assigns different TTLs based on data volatility:
| Data Type | Volatility | Recommended TTL |
|---|---|---|
| Live scores (in-progress) | Seconds | 5-15 seconds |
| Fixtures (today) | Minutes | 60 seconds |
| Standings | Hours | 1-6 hours |
| Fixtures (future) | Hours | 6-12 hours |
| Historical results | Never | Permanent (30+ days) |
| Team/league metadata | Rarely | 24 hours |
| Odds | Seconds to minutes | 30-60 seconds |
Implementation
Redis-Based Cache Layer (JavaScript)
Here is a cache wrapper that applies different TTLs based on the endpoint type. It uses Redis for distributed caching across multiple server instances:
// cache.js
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
// TTL mapping by data type (in seconds)
const TTL_STRATEGY = {
live: 10, // Live scores: 10 seconds
fixtures_today: 60, // Today's fixtures: 1 minute
fixtures_future: 21600, // Future fixtures: 6 hours
standings: 3600, // Standings: 1 hour
historical: 2592000, // Historical: 30 days
metadata: 86400, // Team/league info: 24 hours
odds: 30, // Odds: 30 seconds
};
function categorizeEndpoint(endpoint) {
if (endpoint.includes("live=")) return "live";
if (endpoint.includes("odds")) return "odds";
if (endpoint.includes("standings")) return "standings";
if (endpoint.includes("lineups") || endpoint.includes("statistics"))
return "live";
if (endpoint.includes("fixtures")) {
// Check if it's historical or future
if (endpoint.match(/date=20[12][0-9]/)) return "historical";
return "fixtures_future";
}
if (endpoint.includes("teams") || endpoint.includes("leagues"))
return "metadata";
return "fixtures_today"; // Default
}
export async function cachedFetch(url, apiKey) {
const category = categorizeEndpoint(url);
const ttl = TTL_STRATEGY[category];
const cacheKey = `api:${Buffer.from(url).toString("base64")}`;
// Try cache first
const cached = await redis.get(cacheKey);
if (cached) {
return {
data: JSON.parse(cached),
source: "cache",
ttl,
};
}
// Cache miss — fetch from API
const response = await fetch(url, {
headers: { "x-apisports-key": apiKey },
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const data = await response.json();
// Store in cache with appropriate TTL
await redis.setex(cacheKey, ttl, JSON.stringify(data));
return {
data,
source: "api",
ttl,
};
}Stale-While-Revalidate Pattern (Python)
The stale-while-revalidate (SWR) pattern serves stale data immediately while fetching fresh data in the background. This gives users instant responses while keeping data reasonably fresh:
# swr_cache.py
import json
import time
import asyncio
import httpx
import redis
r = redis.Redis.from_url("redis://localhost:6379")
# Store both the data and its "stale" timestamp
# stale_after < ttl, so we serve stale data while refreshing
CACHE_CONFIG = {
"live": {"ttl": 30, "stale_after": 10},
"standings": {"ttl": 7200, "stale_after": 3600},
"fixtures": {"ttl": 3600, "stale_after": 300},
"default": {"ttl": 60, "stale_after": 30},
}
def get_config(endpoint):
if "live=" in endpoint:
return CACHE_CONFIG["live"]
if "standings" in endpoint:
return CACHE_CONFIG["standings"]
if "fixtures" in endpoint:
return CACHE_CONFIG["fixtures"]
return CACHE_CONFIG["default"]
async def swr_fetch(url, api_key, background_tasks=None):
config = get_config(url)
cache_key = f"swr:{url}"
raw = r.get(cache_key)
if raw:
entry = json.loads(raw)
age = time.time() - entry["fetched_at"]
if age < config["stale_after"]:
# Fresh — return immediately
return {"data": entry["data"], "source": "cache-fresh"}
if age < config["ttl"]:
# Stale but usable — return and refresh in background
if background_tasks:
background_tasks.add_task(
refresh_cache, url, api_key, cache_key, config
)
return {"data": entry["data"], "source": "cache-stale"}
# No cache or expired — fetch synchronously
data = await fetch_from_api(url, api_key)
r.setex(cache_key, config["ttl"], json.dumps({
"data": data,
"fetched_at": time.time(),
}))
return {"data": data, "source": "api"}
async def fetch_from_api(url, api_key):
async with httpx.AsyncClient() as client:
resp = await client.get(
url,
headers={"x-apisports-key": api_key},
timeout=10,
)
resp.raise_for_status()
return resp.json()
async def refresh_cache(url, api_key, cache_key, config):
"""Background task to refresh stale cache entries."""
try:
data = await fetch_from_api(url, api_key)
r.setex(cache_key, config["ttl"], json.dumps({
"data": data,
"fetched_at": time.time(),
}))
print(f"Refreshed cache for {url}")
except Exception as e:
print(f"Background refresh failed for {url}: {e}")Code Examples
Next.js Route Handler with Caching
In a Next.js App Router application, you can combine the native revalidate option with your own cache layer for maximum efficiency:
// app/api/scores/route.js
import { NextResponse } from "next/server";
import { cachedFetch } from "@/lib/cache";
export async function GET(request) {
const { searchParams } = new URL(request.url);
const league = searchParams.get("league") || "39"; // Premier League
const season = searchParams.get("season") || "2025";
const url = `https://v3.football.api-sports.io/fixtures?live=all&league=${league}&season=${season}`;
try {
const result = await cachedFetch(url, process.env.SPORTS_API_KEY);
return NextResponse.json(
{
data: result.data,
source: result.source,
cacheTtl: result.ttl,
},
{
headers: {
// Let the browser cache for a few seconds too
"Cache-Control": "public, s-maxage=10, stale-while-revalidate=30",
},
}
);
} catch (error) {
return NextResponse.json(
{ error: error.message },
{ status: 503 }
);
}
}Best Practices
Never cache without a TTL
Every cache entry must have an expiration. Even historical data should eventually be revalidated in case the provider corrects an error. Use a 30-day TTL as a maximum ceiling.
Use cache invalidation for critical updates
When a match starts or ends, proactively invalidate the cache for related endpoints (fixtures, standings, odds). This ensures users see fresh data immediately rather than waiting for the TTL to expire.
Add a stale indicator to responses
Include a source field in your API responses so clients know whether data came from cache or the upstream API. This helps with debugging and lets the UI show a freshness indicator.
Monitor cache hit ratio
Track your cache hit ratio over time. A healthy ratio is 85% or higher. If it drops, your TTLs may be too short or your cache keys may be too granular (including unnecessary query parameters).
Related Guides
Handling API Rate Limits
13 min readHow to Build a Live Score App
18 min readHow to Design a Sports Database
15 min readCalculate your caching savings
Estimate how much you can save with smart caching strategies.