How to Cache API Responses for Better Performance
·APIScout Team
cachingperformanceapi integrationrediscdn
How to Cache API Responses for Better Performance
The fastest API call is the one you don't make. Caching API responses reduces latency, lowers costs, improves reliability, and keeps your app fast when the API is slow. But cache wrong and you serve stale data, break real-time features, or introduce bugs.
Why Cache API Responses?
| Benefit | Impact |
|---|---|
| Latency | 200ms API call → <5ms cache hit |
| Cost | 50-80% fewer API calls = 50-80% lower API bill |
| Reliability | Serve cached data when API is down |
| Rate limits | Fewer requests = stay under limits |
| User experience | Instant responses feel native |
Caching Layers
User Request
│
▼
┌──────────────────┐
│ Browser Cache │ Cache-Control headers, Service Worker
│ (0ms latency) │
└────────┬─────────┘
│ miss
▼
┌──────────────────┐
│ CDN / Edge Cache │ Cloudflare, CloudFront, Fastly
│ (5-20ms latency) │
└────────┬─────────┘
│ miss
▼
┌──────────────────┐
│ Application Cache│ Redis, Memcached, in-memory
│ (1-10ms latency) │
└────────┬─────────┘
│ miss
▼
┌──────────────────┐
│ API Call │ Third-party API
│ (50-500ms) │
└──────────────────┘
Layer 1: HTTP Caching
Use Cache-Control headers — the browser and CDN do the work for you.
// Your API route that proxies a third-party API
export async function GET(request: Request) {
const data = await fetch('https://api.example.com/products');
const products = await data.json();
return Response.json(products, {
headers: {
// Cache in browser for 60 seconds
'Cache-Control': 'public, max-age=60',
// Cache at CDN for 5 minutes, serve stale while revalidating
'CDN-Cache-Control': 'public, max-age=300, stale-while-revalidate=600',
// ETag for conditional requests
'ETag': `"${hashResponse(products)}"`,
},
});
}
Cache-Control Directives
| Directive | What It Does | Use When |
|---|---|---|
public, max-age=60 | Cache everywhere for 60s | Static data, not user-specific |
private, max-age=300 | Cache in browser only for 5min | User-specific data |
no-store | Never cache | Sensitive data (balances, auth) |
stale-while-revalidate=60 | Serve stale, fetch fresh in background | Most API responses |
s-maxage=300 | CDN caches for 5min (browser uses max-age) | CDN-specific TTL |
Layer 2: Application Cache (Redis)
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
class APICache {
constructor(private redis: Redis) {}
async getOrFetch<T>(
key: string,
fetchFn: () => Promise<T>,
ttlSeconds: number = 300
): Promise<T> {
// Try cache first
const cached = await this.redis.get(key);
if (cached) {
return JSON.parse(cached);
}
// Cache miss — fetch from API
const data = await fetchFn();
// Store in cache (non-blocking)
this.redis.set(key, JSON.stringify(data), 'EX', ttlSeconds).catch(console.error);
return data;
}
async invalidate(pattern: string): Promise<void> {
const keys = await this.redis.keys(pattern);
if (keys.length > 0) {
await this.redis.del(...keys);
}
}
}
// Usage
const cache = new APICache(redis);
const products = await cache.getOrFetch(
'api:products:all',
() => fetch('https://api.example.com/products').then(r => r.json()),
300 // 5 minutes
);
Stale-While-Revalidate Pattern
class SWRCache {
async getOrFetch<T>(
key: string,
fetchFn: () => Promise<T>,
options: { maxAge: number; staleAge: number }
): Promise<T & { _fromCache?: boolean; _stale?: boolean }> {
const cached = await this.redis.get(key);
if (cached) {
const { data, timestamp } = JSON.parse(cached);
const age = (Date.now() - timestamp) / 1000;
if (age < options.maxAge) {
// Fresh cache — return immediately
return { ...data, _fromCache: true };
}
if (age < options.staleAge) {
// Stale cache — return immediately, refresh in background
this.refreshInBackground(key, fetchFn, options.maxAge);
return { ...data, _fromCache: true, _stale: true };
}
}
// No cache or expired — fetch synchronously
const data = await fetchFn();
await this.store(key, data, options.staleAge);
return data;
}
private async refreshInBackground<T>(key: string, fetchFn: () => Promise<T>, maxAge: number) {
try {
const data = await fetchFn();
await this.store(key, data, maxAge * 3);
} catch (error) {
console.error(`Background refresh failed for ${key}:`, error);
}
}
private async store(key: string, data: any, ttl: number) {
await this.redis.set(key, JSON.stringify({ data, timestamp: Date.now() }), 'EX', ttl);
}
}
// Usage: fresh for 5 min, stale for 1 hour
const products = await swrCache.getOrFetch(
'products',
fetchProducts,
{ maxAge: 300, staleAge: 3600 }
);
Layer 3: Edge Caching
Cache API responses at CDN edge locations for global low-latency:
// Cloudflare Worker — cache at edge
export default {
async fetch(request: Request): Promise<Response> {
const cacheKey = new Request(request.url, request);
const cache = caches.default;
// Check edge cache
let response = await cache.match(cacheKey);
if (response) return response;
// Cache miss — fetch from origin
response = await fetch('https://api.example.com/products');
// Clone and cache at edge
const cachedResponse = new Response(response.body, response);
cachedResponse.headers.set('Cache-Control', 'public, max-age=300');
await cache.put(cacheKey, cachedResponse.clone());
return cachedResponse;
},
};
Cache Invalidation Strategies
Time-Based (TTL)
// Simple but effective for most cases
const CACHE_TTLS = {
products: 300, // 5 min — changes infrequently
prices: 60, // 1 min — changes occasionally
inventory: 10, // 10 sec — changes frequently
user_profile: 600, // 10 min — user-specific, rarely changes
search_results: 30, // 30 sec — balances freshness and performance
static_config: 3600, // 1 hour — almost never changes
};
Event-Based
// Invalidate cache when data changes
async function updateProduct(productId: string, data: ProductUpdate) {
// Update in database
await db.products.update(productId, data);
// Invalidate related caches
await cache.invalidate(`products:${productId}`);
await cache.invalidate('products:list:*');
await cache.invalidate('products:search:*');
}
// Or via webhooks
async function handleWebhook(event: WebhookEvent) {
if (event.type === 'product.updated') {
await cache.invalidate(`products:${event.data.id}`);
}
}
Tag-Based
// Tag cache entries for group invalidation
class TaggedCache {
async set(key: string, data: any, tags: string[], ttl: number) {
await this.redis.set(key, JSON.stringify(data), 'EX', ttl);
// Store key under each tag
for (const tag of tags) {
await this.redis.sadd(`tag:${tag}`, key);
}
}
async invalidateTag(tag: string) {
const keys = await this.redis.smembers(`tag:${tag}`);
if (keys.length > 0) {
await this.redis.del(...keys);
await this.redis.del(`tag:${tag}`);
}
}
}
// Usage
await taggedCache.set('product:123', productData, ['products', 'category:electronics'], 300);
await taggedCache.set('product:456', productData, ['products', 'category:books'], 300);
// Invalidate all products
await taggedCache.invalidateTag('products');
// Or just electronics
await taggedCache.invalidateTag('category:electronics');
What to Cache (and What Not To)
| Cache? | Data Type | TTL | Reason |
|---|---|---|---|
| ✅ Yes | Product catalogs | 5-60 min | Changes infrequently |
| ✅ Yes | Search results | 30-300 sec | Same queries repeat |
| ✅ Yes | User profiles | 5-10 min | Rarely changes |
| ✅ Yes | Configuration/settings | 1-24 hours | Nearly static |
| ✅ Yes | Public API data (weather, prices) | Per API recommendation | Save API calls |
| ⚠️ Carefully | Real-time inventory | 5-30 sec | Balance freshness vs load |
| ❌ No | Financial transactions | Never | Must be real-time |
| ❌ No | Authentication tokens | Never (except for sessions) | Security risk |
| ❌ No | One-time data (OTP, verification) | Never | Security risk |
| ❌ No | Rapidly changing data | Use WebSockets instead | Cache would always be stale |
Common Mistakes
| Mistake | Impact | Fix |
|---|---|---|
| Caching user-specific data publicly | Data leaks between users | Use private or per-user cache keys |
| No cache invalidation strategy | Serving stale data indefinitely | Set appropriate TTLs, invalidate on write |
| Caching error responses | Users get errors from cache | Only cache 2xx responses |
| Cache key doesn't include all params | Wrong data returned | Include all query params in cache key |
| No fallback when cache is down | Error instead of slow response | Fallback to direct API call |
| Over-caching real-time data | Users see outdated info | Short TTL or no cache for real-time |
Compare API caching strategies and CDN options on APIScout — find the best edge caching solutions for your API integrations.