API Response Caching

Intermediate25 min readLast updated: April 28, 2025

Introduction to API Response Caching

Caching API responses is a powerful technique that can significantly improve the performance, reliability, and efficiency of your applications. By storing and reusing API responses, you can:

  • Reduce latency and improve user experience
  • Decrease network usage and bandwidth costs
  • Reduce the load on API servers
  • Make your application more resilient to API outages
  • Stay within API rate limits

In this guide, we'll explore different caching strategies and implementations to help you effectively cache API responses in your applications.

Caching Fundamentals

Before diving into implementation details, let's understand some fundamental concepts of caching:

Cache Key

A unique identifier used to store and retrieve cached data. For API requests, this is typically derived from:

  • The request URL
  • Query parameters
  • Request headers (when relevant)
  • Request body (for POST/PUT requests)

Cache Lifetime (TTL)

The duration for which cached data is considered valid. After this period, the data is considered "stale" and should be refreshed.

Cache Invalidation

The process of removing or updating cached data when it's no longer valid. This can happen:

  • Automatically when the TTL expires
  • Manually when data is known to have changed
  • Based on specific events or triggers

Cache Hit/Miss

  • Cache Hit: The requested data is found in the cache
  • Cache Miss: The requested data is not in the cache and must be fetched from the API

Caching Strategies

Different caching strategies are appropriate for different types of data and use cases:

Cache-Aside (Lazy Loading)

The most common pattern where the application:

  1. Checks the cache first
  2. If the data is not in the cache (cache miss), fetches it from the API
  3. Stores the fetched data in the cache for future use

Write-Through

When data is updated:

  1. The application updates the API
  2. Then immediately updates the cache

This ensures the cache is always up-to-date but adds overhead to write operations.

Cache Invalidation

Strategies for keeping the cache fresh:

  • Time-Based Invalidation: Cache entries expire after a set time
  • Event-Based Invalidation: Cache is updated when specific events occur
  • Version-Based Invalidation: Cache entries are tagged with a version and invalidated when the version changes

Stale-While-Revalidate

A hybrid approach where:

  1. Return cached data immediately (even if stale)
  2. Asynchronously fetch fresh data from the API
  3. Update the cache with the fresh data for next time

This provides the best of both worlds: immediate responses and eventually fresh data.

Client-Side Caching Implementations

Let's explore different ways to implement caching in client-side applications:

In-Memory Cache

The simplest form of caching, storing data in memory:

// Simple in-memory cache implementation
class InMemoryCache {
  constructor(maxAge = 60000) {
    this.cache = new Map();
    this.maxAge = maxAge; // Default: 1 minute in milliseconds
  }
  
  set(key, value) {
    this.cache.set(key, {
      value,
      timestamp: Date.now()
    });
  }
  
  get(key) {
    const item = this.cache.get(key);
    
    // Return null if item doesn't exist
    if (!item) {
      return null;
    }
    
    // Check if the item has expired
    if (Date.now() - item.timestamp > this.maxAge) {
      this.cache.delete(key); // Remove expired item
      return null;
    }
    
    return item.value;
  }
  
  delete(key) {
    this.cache.delete(key);
  }
  
  clear() {
    this.cache.clear();
  }
}

// Usage example
const apiCache = new InMemoryCache(5 * 60 * 1000); // 5 minutes

async function fetchWithCache(url) {
  // Check cache first
  const cachedData = apiCache.get(url);
  if (cachedData) {
    console.log('Cache hit for:', url);
    return cachedData;
  }
  
  // If not in cache, fetch from API
  console.log('Cache miss for:', url);
  const response = await fetch(url);
  const data = await response.json();
  
  // Store in cache
  apiCache.set(url, data);
  
  return data;
}

Advantages:

  • Very fast access
  • Simple implementation
  • No external dependencies

Limitations:

  • Data is lost when the page is refreshed or closed
  • Limited by available memory
  • Not shared between tabs or windows

Local Storage Cache

Using the browser's localStorage API for persistent caching:

// Local Storage cache implementation
class LocalStorageCache {
  constructor(prefix = 'api_cache_', maxAge = 60 * 60 * 1000) {
    this.prefix = prefix;
    this.maxAge = maxAge; // Default: 1 hour in milliseconds
  }
  
  getKey(key) {
    return `${this.prefix}${key}`;
  }
  
  set(key, value) {
    const item = {
      value,
      timestamp: Date.now()
    };
    
    localStorage.setItem(this.getKey(key), JSON.stringify(item));
  }
  
  get(key) {
    try {
      const itemStr = localStorage.getItem(this.getKey(key));
      
      // Return null if item doesn't exist
      if (!itemStr) {
        return null;
      }
      
      const item = JSON.parse(itemStr);
      
      // Check if the item has expired
      if (Date.now() - item.timestamp > this.maxAge) {
        this.delete(key); // Remove expired item
        return null;
      }
      
      return item.value;
    } catch (error) {
      console.error('Error retrieving from cache:', error);
      return null;
    }
  }
  
  delete(key) {
    localStorage.removeItem(this.getKey(key));
  }
  
  clear() {
    // Only clear items with our prefix
    for (let i = 0; i < localStorage.length; i++) {
      const key = localStorage.key(i);
      if (key.startsWith(this.prefix)) {
        localStorage.removeItem(key);
      }
    }
  }
}

// Usage example
const apiCache = new LocalStorageCache('api_v1_');

async function fetchWithCache(url) {
  // Create a cache key from the URL
  const cacheKey = encodeURIComponent(url);
  
  // Check cache first
  const cachedData = apiCache.get(cacheKey);
  if (cachedData) {
    console.log('Cache hit for:', url);
    return cachedData;
  }
  
  // If not in cache, fetch from API
  console.log('Cache miss for:', url);
  const response = await fetch(url);
  const data = await response.json();
  
  // Store in cache
  apiCache.set(cacheKey, data);
  
  return data;
}

Advantages:

  • Data persists across page refreshes and browser sessions
  • Simple API
  • Available in all modern browsers

Limitations:

  • Limited storage space (typically 5-10MB)
  • Synchronous API can block the main thread
  • Only supports string data (requires serialization)
  • Not suitable for sensitive data

IndexedDB Cache

Using the browser's IndexedDB for larger and more complex data:

// IndexedDB cache implementation
class IndexedDBCache {
  constructor(dbName = 'apiCache', storeName = 'responses', maxAge = 24 * 60 * 60 * 1000) {
    this.dbName = dbName;
    this.storeName = storeName;
    this.maxAge = maxAge; // Default: 24 hours in milliseconds
    this.db = null;
  }
  
  async open() {
    if (this.db) return this.db;
    
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, 1);
      
      request.onerror = (event) => {
        console.error('IndexedDB error:', event);
        reject('Error opening IndexedDB');
      };
      
      request.onsuccess = (event) => {
        this.db = event.target.result;
        resolve(this.db);
      };
      
      request.onupgradeneeded = (event) => {
        const db = event.target.result;
        
        // Create object store if it doesn't exist
        if (!db.objectStoreNames.contains(this.storeName)) {
          db.createObjectStore(this.storeName, { keyPath: 'key' });
        }
      };
    });
  }
  
  async set(key, value) {
    const db = await this.open();
    
    return new Promise((resolve, reject) => {
      const transaction = db.transaction([this.storeName], 'readwrite');
      const store = transaction.objectStore(this.storeName);
      
      const item = {
        key,
        value,
        timestamp: Date.now()
      };
      
      const request = store.put(item);
      
      request.onerror = (event) => {
        console.error('Error storing in cache:', event);
        reject('Error storing in cache');
      };
      
      request.onsuccess = (event) => {
        resolve();
      };
    });
  }
  
  async get(key) {
    const db = await this.open();
    
    return new Promise((resolve, reject) => {
      const transaction = db.transaction([this.storeName], 'readonly');
      const store = transaction.objectStore(this.storeName);
      
      const request = store.get(key);
      
      request.onerror = (event) => {
        console.error('Error retrieving from cache:', event);
        reject('Error retrieving from cache');
      };
      
      request.onsuccess = (event) => {
        const item = request.result;
        
        // Return null if item doesn't exist
        if (!item) {
          resolve(null);
          return;
        }
        
        // Check if the item has expired
        if (Date.now() - item.timestamp > this.maxAge) {
          this.delete(key); // Remove expired item
          resolve(null);
          return;
        }
        
        resolve(item.value);
      };
    });
  }
  
  async delete(key) {
    const db = await this.open();
    
    return new Promise((resolve, reject) => {
      const transaction = db.transaction([this.storeName], 'readwrite');
      const store = transaction.objectStore(this.storeName);
      
      const request = store.delete(key);
      
      request.onerror = (event) => {
        console.error('Error deleting from cache:', event);
        reject('Error deleting from cache');
      };
      
      request.onsuccess = (event) => {
        resolve();
      };
    });
  }
  
  async clear() {
    const db = await this.open();
    
    return new Promise((resolve, reject) => {
      const transaction = db.transaction([this.storeName], 'readwrite');
      const store = transaction.objectStore(this.storeName);
      
      const request = store.clear();
      
      request.onerror = (event) => {
        console.error('Error clearing cache:', event);
        reject('Error clearing cache');
      };
      
      request.onsuccess = (event) => {
        resolve();
      };
    });
  }
}

// Usage example
const apiCache = new IndexedDBCache();

async function fetchWithCache(url) {
  // Create a cache key from the URL
  const cacheKey = encodeURIComponent(url);
  
  try {
    // Check cache first
    const cachedData = await apiCache.get(cacheKey);
    if (cachedData) {
      console.log('Cache hit for:', url);
      return cachedData;
    }
    
    // If not in cache, fetch from API
    console.log('Cache miss for:', url);
    const response = await fetch(url);
    const data = await response.json();
    
    // Store in cache
    await apiCache.set(cacheKey, data);
    
    return data;
  } catch (error) {
    console.error('Cache error:', error);
    
    // Fallback to direct API call if cache fails
    const response = await fetch(url);
    return response.json();
  }
}

Advantages:

  • Larger storage capacity (typically 50-100MB or more)
  • Supports complex data structures
  • Asynchronous API doesn't block the main thread
  • Better performance for large datasets

Limitations:

  • More complex API
  • Not suitable for sensitive data

HTTP Caching

Leveraging the browser's built-in HTTP cache:

// Using HTTP caching headers
async function fetchWithHttpCaching(url) {
  const response = await fetch(url, {
    // Use cache: 'default' to respect HTTP cache headers
    cache: 'default'
  });
  
  // Log cache-related headers
  console.log('Cache-Control:', response.headers.get('Cache-Control'));
  console.log('ETag:', response.headers.get('ETag'));
  console.log('Last-Modified:', response.headers.get('Last-Modified'));
  
  return response.json();
}

// Making a conditional request with If-None-Match
async function fetchWithETag(url, etag) {
  const headers = {};
  
  if (etag) {
    headers['If-None-Match'] = etag;
  }
  
  const response = await fetch(url, { headers });
  
  // If the resource hasn't changed, we'll get a 304 Not Modified
  if (response.status === 304) {
    console.log('Resource not modified, using cached data');
    return null; // Signal to use cached data
  }
  
  // Store the new ETag for future requests
  const newETag = response.headers.get('ETag');
  if (newETag) {
    console.log('New ETag:', newETag);
    // Save this ETag for future requests
  }
  
  return response.json();
}

Advantages:

  • Built into the browser
  • Respects standard HTTP caching headers
  • Can be controlled by the server
  • Works with the browser's back/forward cache

Limitations:

  • Less control over caching behavior
  • Depends on proper cache headers from the API
  • Can be difficult to debug

Server-Side Caching

For applications with a server component, server-side caching can be more efficient:

Memory Caching (Redis, Memcached)

Using in-memory data stores for high-performance caching:

// Example using Redis with Node.js
const redis = require('redis');
const { promisify } = require('util');

// Create Redis client
const client = redis.createClient({
  host: 'localhost',
  port: 6379
});

// Promisify Redis commands
const getAsync = promisify(client.get).bind(client);
const setexAsync = promisify(client.setex).bind(client);

async function fetchWithRedisCache(url, ttlSeconds = 300) {
  // Create a cache key from the URL
  const cacheKey = `api:${url}`;
  
  try {
    // Check cache first
    const cachedData = await getAsync(cacheKey);
    if (cachedData) {
      console.log('Cache hit for:', url);
      return JSON.parse(cachedData);
    }
    
    // If not in cache, fetch from API
    console.log('Cache miss for:', url);
    const response = await fetch(url);
    const data = await response.json();
    
    // Store in cache with TTL
    await setexAsync(cacheKey, ttlSeconds, JSON.stringify(data));
    
    return data;
  } catch (error) {
    console.error('Cache error:', error);
    
    // Fallback to direct API call if cache fails
    const response = await fetch(url);
    return response.json();
  }
}

Response Caching Middleware

For frameworks like Express.js, you can use middleware for caching:

// Example using express-cache-middleware
const express = require('express');
const cacheMiddleware = require('express-cache-middleware');
const cacheManager = require('cache-manager');

const app = express();

// Create cache instance
const memoryCache = cacheManager.caching({
  store: 'memory',
  max: 100,
  ttl: 60 // seconds
});

// Initialize cache middleware
const cacheMiddlewareInstance = new cacheMiddleware({
  cacheManager: memoryCache
});

cacheMiddlewareInstance.attach(app);

// This route will be cached
app.get('/api/data', async (req, res) => {
  // Expensive operation or external API call
  const data = await fetchDataFromExternalAPI();
  res.json(data);
});

app.listen(3000, () => {
  console.log('Server running on port 3000');
});

CDN Caching

For public, static, or semi-static content, Content Delivery Networks (CDNs) provide efficient caching:

  • Distribute content across global edge locations
  • Reduce latency for users worldwide
  • Offload traffic from your origin servers
  • Provide DDoS protection

Configure your API responses with appropriate cache headers to leverage CDN caching:

// Example setting cache headers in Express.js
app.get('/api/public-data', (req, res) => {
  // Set cache headers
  res.set({
    'Cache-Control': 'public, max-age=3600', // Cache for 1 hour
    'Surrogate-Control': 'max-age=86400'     // CDN cache for 24 hours
  });
  
  // Send response
  res.json(publicData);
});

Advanced Caching Techniques

Cache Stampede Prevention

When a cached item expires, multiple concurrent requests might try to refresh it simultaneously, causing a "cache stampede." Prevent this with:

// Preventing cache stampede with a mutex
const locks = new Map();

async function fetchWithStampedeProtection(url, ttl = 300000) {
  const cacheKey = `cache:${url}`;
  const lockKey = `lock:${url}`;
  
  // Check cache first
  const cachedData = cache.get(cacheKey);
  if (cachedData) {
    return cachedData;
  }
  
  // If another request is already refreshing the cache, wait for it
  if (locks.has(lockKey)) {
    console.log('Waiting for existing request to complete');
    await locks.get(lockKey);
    
    // Check cache again after waiting
    const cachedDataAfterWait = cache.get(cacheKey);
    if (cachedDataAfterWait) {
      return cachedDataAfterWait;
    }
  }
  
  // Create a promise that will resolve when this request completes
  let resolveLock;
  const lockPromise = new Promise(resolve => {
    resolveLock = resolve;
  });
  
  // Set the lock
  locks.set(lockKey, lockPromise);
  
  try {
    // Fetch fresh data
    const response = await fetch(url);
    const data = await response.json();
    
    // Update cache
    cache.set(cacheKey, data, ttl);
    
    return data;
  } finally {
    // Release the lock
    resolveLock();
    locks.delete(lockKey);
  }
}

Background Refresh

Refresh cache items in the background before they expire:

// Background refresh implementation
class CacheWithBackgroundRefresh {
  constructor(ttl = 300000, refreshThreshold = 0.8) {
    this.cache = new Map();
    this.ttl = ttl;
    this.refreshThreshold = refreshThreshold; // Refresh when 80% of TTL has elapsed
  }
  
  async get(key, fetchFn) {
    const now = Date.now();
    const cachedItem = this.cache.get(key);
    
    // If item exists in cache
    if (cachedItem) {
      const age = now - cachedItem.timestamp;
      const shouldRefresh = age > this.ttl * this.refreshThreshold;
      
      // If item is still fresh enough, return it
      if (!shouldRefresh) {
        return cachedItem.value;
      }
      
      // If item should be refreshed but is still valid, refresh in background
      if (age < this.ttl) {
        console.log('Background refreshing:', key);
        this.refreshInBackground(key, fetchFn);
        return cachedItem.value;
      }
    }
    
    // If item doesn't exist or is expired, fetch it synchronously
    return this.fetchAndCache(key, fetchFn);
  }
  
  async fetchAndCache(key, fetchFn) {
    try {
      const value = await fetchFn();
      
      this.cache.set(key, {
        value,
        timestamp: Date.now()
      });
      
      return value;
    } catch (error) {
      // If fetch fails and we have a stale value, return it
      const cachedItem = this.cache.get(key);
      if (cachedItem) {
        console.warn('Fetch failed, returning stale data for:', key);
        return cachedItem.value;
      }
      
      throw error;
    }
  }
  
  async refreshInBackground(key, fetchFn) {
    // Use setTimeout to make this non-blocking
    setTimeout(async () => {
      try {
        await this.fetchAndCache(key, fetchFn);
        console.log('Background refresh completed for:', key);
      } catch (error) {
        console.error('Background refresh failed for:', key, error);
      }
    }, 0);
  }
}

// Usage
const cache = new CacheWithBackgroundRefresh(5 * 60 * 1000); // 5 minutes TTL

async function getData(url) {
  return cache.get(url, () => fetch(url).then(res => res.json()));
}

Tiered Caching

Implement multiple layers of caching for optimal performance:

// Tiered caching example
class TieredCache {
  constructor() {
    // In-memory cache (fastest, but volatile)
    this.memoryCache = new Map();
    
    // Initialize other cache tiers
    this.initializeLocalStorage();
    this.initializeIndexedDB();
  }
  
  async initializeLocalStorage() {
    // Initialize localStorage wrapper
    this.localStorage = {
      get: (key) => {
        const item = localStorage.getItem(key);
        if (!item) return null;
        
        try {
          const parsed = JSON.parse(item);
          if (parsed.expiry && Date.now() > parsed.expiry) {
            localStorage.removeItem(key);
            return null;
          }
          return parsed.value;
        } catch (e) {
          return null;
        }
      },
      set: (key, value, ttl = 3600000) => {
        const item = {
          value,
          expiry: Date.now() + ttl
        };
        localStorage.setItem(key, JSON.stringify(item));
      }
    };
  }
  
  async initializeIndexedDB() {
    // Initialize IndexedDB (implementation omitted for brevity)
    this.indexedDB = {
      get: async (key) => { /* ... */ },
      set: async (key, value, ttl) => { /* ... */ }
    };
  }
  
  async get(key) {
    // Try memory cache first (fastest)
    if (this.memoryCache.has(key)) {
      const item = this.memoryCache.get(key);
      if (Date.now() < item.expiry) {
        console.log('Memory cache hit:', key);
        return item.value;
      }
      this.memoryCache.delete(key);
    }
    
    // Try localStorage next
    const localStorageValue = this.localStorage.get(key);
    if (localStorageValue) {
      console.log('LocalStorage cache hit:', key);
      // Promote to memory cache
      this.memoryCache.set(key, {
        value: localStorageValue,
        expiry: Date.now() + 60000 // 1 minute in memory
      });
      return localStorageValue;
    }
    
    // Try IndexedDB last
    const indexedDBValue = await this.indexedDB.get(key);
    if (indexedDBValue) {
      console.log('IndexedDB cache hit:', key);
      // Promote to higher cache tiers
      this.localStorage.set(key, indexedDBValue);
      this.memoryCache.set(key, {
        value: indexedDBValue,
        expiry: Date.now() + 60000
      });
      return indexedDBValue;
    }
    
    // Cache miss
    console.log('Cache miss:', key);
    return null;
  }
  
  async set(key, value, ttl = 3600000) {
    // Set in all cache tiers
    this.memoryCache.set(key, {
      value,
      expiry: Date.now() + Math.min(ttl, 300000) // Max 5 minutes in memory
    });  {
      value,
      expiry: Date.now() + Math.min(ttl, 300000) // Max 5 minutes in memory
    });
    
    this.localStorage.set(key, value, ttl);
    await this.indexedDB.set(key, value, ttl);
  }
  
  async invalidate(key) {
    // Remove from all cache tiers
    this.memoryCache.delete(key);
    localStorage.removeItem(key);
    await this.indexedDB.delete(key);
  }
}

// Usage
const tieredCache = new TieredCache();

async function fetchWithTieredCache(url) {
  const cacheKey = `api:${url}`;
  
  // Try to get from cache first
  const cachedData = await tieredCache.get(cacheKey);
  if (cachedData) {
    return cachedData;
  }
  
  // Fetch fresh data
  const response = await fetch(url);
  const data = await response.json();
  
  // Store in cache
  await tieredCache.set(cacheKey, data);
  
  return data;
}

Caching Best Practices

Follow these best practices to implement effective caching:

Cache Invalidation Strategies

  • Time-Based Invalidation: Set appropriate TTLs based on data volatility
  • Event-Based Invalidation: Invalidate cache when data changes (e.g., after mutations)
  • Selective Invalidation: Only invalidate affected cache entries, not the entire cache

Security Considerations

  • Never cache sensitive data in client-side storage
  • Be cautious with caching authenticated responses
  • Consider encryption for cached data when appropriate
  • Be aware of cache poisoning attacks

Performance Optimization

  • Use the fastest available storage for frequently accessed data
  • Implement cache warming for critical data
  • Monitor cache hit rates and adjust strategies accordingly
  • Consider the memory/storage impact of your cache

Offline Support

  • Use caching as part of your offline strategy
  • Implement service workers for more advanced offline capabilities
  • Provide clear indicators when serving cached data

Conclusion

Effective API response caching is a powerful technique for improving application performance, reducing costs, and enhancing user experience. By implementing the appropriate caching strategies and techniques for your specific use case, you can significantly optimize your API integrations.

Remember that caching is a trade-off between freshness and performance. The right balance depends on your specific requirements, the nature of your data, and your users' expectations.

Further Reading