Skip to main content

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?

BenefitImpact
Latency200ms API call → <5ms cache hit
Cost50-80% fewer API calls = 50-80% lower API bill
ReliabilityServe cached data when API is down
Rate limitsFewer requests = stay under limits
User experienceInstant 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

DirectiveWhat It DoesUse When
public, max-age=60Cache everywhere for 60sStatic data, not user-specific
private, max-age=300Cache in browser only for 5minUser-specific data
no-storeNever cacheSensitive data (balances, auth)
stale-while-revalidate=60Serve stale, fetch fresh in backgroundMost API responses
s-maxage=300CDN 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 TypeTTLReason
✅ YesProduct catalogs5-60 minChanges infrequently
✅ YesSearch results30-300 secSame queries repeat
✅ YesUser profiles5-10 minRarely changes
✅ YesConfiguration/settings1-24 hoursNearly static
✅ YesPublic API data (weather, prices)Per API recommendationSave API calls
⚠️ CarefullyReal-time inventory5-30 secBalance freshness vs load
❌ NoFinancial transactionsNeverMust be real-time
❌ NoAuthentication tokensNever (except for sessions)Security risk
❌ NoOne-time data (OTP, verification)NeverSecurity risk
❌ NoRapidly changing dataUse WebSockets insteadCache would always be stale

Common Mistakes

MistakeImpactFix
Caching user-specific data publiclyData leaks between usersUse private or per-user cache keys
No cache invalidation strategyServing stale data indefinitelySet appropriate TTLs, invalidate on write
Caching error responsesUsers get errors from cacheOnly cache 2xx responses
Cache key doesn't include all paramsWrong data returnedInclude all query params in cache key
No fallback when cache is downError instead of slow responseFallback to direct API call
Over-caching real-time dataUsers see outdated infoShort 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.

Comments