StackPractices
intermediate By Mathias Paulenko

Proxy Pattern for API Response Caching

How to implement a caching proxy that intercepts API calls and stores responses to reduce latency and avoid redundant network requests

The Proxy pattern intercepts access to an object to add behavior without changing the original implementation. When applied to API clients, it becomes a capable caching layer that stores responses, reduces latency, and shields downstream services from redundant requests.

When to Use This

  • API responses are expensive to compute but read frequently
  • You want to avoid hitting rate limits on third-party APIs
  • Response freshness can be controlled by TTL rather than real-time requirements

Problem

Every call to an external API triggers a network request, serialization, and deserialization. For frequently accessed but slowly changing data — like currency rates, product catalogs, or user permissions — this is wasteful and slow.

Solution

Implement a proxy that wraps the real API client and stores responses in a cache with configurable expiration.

// api/WeatherClient.ts
interface WeatherClient {
  getForecast(city: string): Promise<Forecast>;
}

// api/OpenWeatherClient.ts
class OpenWeatherClient implements WeatherClient {
  async getForecast(city: string): Promise<Forecast> {
    const res = await fetch(`https://api.openweathermap.org/data/2.5/forecast?q=${city}`);
    return res.json();
  }
}

// proxy/CachedWeatherClient.ts
class CachedWeatherClient implements WeatherClient {
  private cache = new Map<string, { data: Forecast; expiry: number }>();

  constructor(
    private client: WeatherClient,
    private ttlMs: number = 300_000
  ) {}

  async getForecast(city: string): Promise<Forecast> {
    const key = city.toLowerCase();
    const cached = this.cache.get(key);

    if (cached && cached.expiry > Date.now()) {
      return cached.data;
    }

    const data = await this.client.getForecast(city);
    this.cache.set(key, { data, expiry: Date.now() + this.ttlMs });
    return data;
  }

  invalidate(city: string): void {
    this.cache.delete(city.toLowerCase());
  }
}

Usage

const realClient = new OpenWeatherClient();
const cachedClient = new CachedWeatherClient(realClient, 600_000);

const forecast = await cachedClient.getForecast('London');

Variations

  • Redis Proxy: Store cache in Redis for distributed systems
  • Smart Proxy: Add metrics, logging, and circuit breaker alongside caching
  • Lazy Proxy: Defer initialization of expensive connections until first use

What Works

  • Set TTL based on data volatility, not a fixed value for everything. See cache invalidation patterns.
  • Implement cache invalidation hooks for write-through consistency
  • Use a decorator or composition to layer multiple proxies

Common Mistakes

  • Caching POST/PUT responses without understanding side effects
  • Not handling cache eviction when memory pressure grows
  • Returning stale data silently without logging
  • Setting TTL too long for volatile data
  • Not implementing cache size limits
  • Caching sensitive data without encryption
  • Ignoring cache warm-up time
  • Not monitoring cache hit/miss ratios
  • Using cache as primary storage instead of as optimization
  • Not handling cache failures gracefully

Best Practices

  1. Set appropriate TTL based on data volatility. Use short TTL for frequently changing data and longer TTL for stable data. Never use a one-size-fits-all TTL.

  2. Implement cache size limits. Unbounded caches can cause memory issues. Use LRU eviction or similar strategies to manage memory.

  3. Monitor cache performance. Track hit rates, miss rates, and eviction patterns to optimize cache configuration.

  4. Handle cache failures gracefully. If the cache fails, fall back to the original client rather than breaking the application.

  5. Document cache invalidation strategies. Clearly document when and how cache entries should be invalidated.

  6. Use cache keys consistently. Ensure cache keys are deterministic and include all relevant parameters.

  7. Consider cache warm-up. Pre-populate cache with frequently accessed data to avoid cold starts.

  8. Implement cache metrics. Add logging and metrics to understand cache behavior and identify issues.

  9. Don’t cache POST/PUT/DELETE responses. These operations have side effects and should not be cached without careful consideration.

  10. Encrypt sensitive cached data. If caching sensitive information, ensure it’s encrypted at rest.

Frequently Asked Questions

How is this different from a simple wrapper function?

The Proxy pattern implements the same interface as the real object, so callers do not know or care whether they are using the cache or the original client.

Can I combine this with the Decorator pattern?

Yes. A Decorator adds behavior; a Proxy controls access. They are often used together in practice.

How do I handle cache invalidation?

Implement explicit invalidation methods for write-through consistency, or use TTL-based expiration for eventual consistency.

Should I use in-memory cache or distributed cache?

Use in-memory cache for single-instance applications. Use distributed cache (Redis, Memcached) for multi-instance deployments.

How do I prevent cache stampede?

Implement request coalescing or use cache locks to prevent multiple simultaneous requests for the same uncached data.

Can I cache POST requests?

Generally no. POST requests have side effects and should not be cached unless you fully understand the implications.

How do I handle cache serialization?

Use JSON serialization for simple objects. Consider MessagePack or Protocol Buffers for high-performance scenarios.

Should I cache errors?

Cache errors with short TTL to prevent repeated failing requests from overwhelming downstream services.

How do I implement cache warming?

Pre-populate cache during application startup or scheduled jobs to avoid cold starts for frequently accessed data.

Can I use this pattern with GraphQL?

Yes. Implement caching at the resolver level or use DataLoader for batched caching.