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
-
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.
-
Implement cache size limits. Unbounded caches can cause memory issues. Use LRU eviction or similar strategies to manage memory.
-
Monitor cache performance. Track hit rates, miss rates, and eviction patterns to optimize cache configuration.
-
Handle cache failures gracefully. If the cache fails, fall back to the original client rather than breaking the application.
-
Document cache invalidation strategies. Clearly document when and how cache entries should be invalidated.
-
Use cache keys consistently. Ensure cache keys are deterministic and include all relevant parameters.
-
Consider cache warm-up. Pre-populate cache with frequently accessed data to avoid cold starts.
-
Implement cache metrics. Add logging and metrics to understand cache behavior and identify issues.
-
Don’t cache POST/PUT/DELETE responses. These operations have side effects and should not be cached without careful consideration.
-
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.
Related Resources
Decorator Pattern
Add new functionality to objects dynamically by wrapping them. A structural design pattern for flexible behavior extension.
PatternAdapter Pattern
Convert the interface of a class into another interface clients expect. A structural design pattern for interface compatibility.
RecipeImplement Cache Invalidation Strategies
How to keep caches consistent with databases using TTL, write-through, write-behind, and event-driven invalidation patterns.
RecipeCaching Strategies
Implement useful caching strategies for databases, APIs, and frontends using Redis, CDNs, and browser caches.
PatternBuilder Pattern for Complex Configuration Objects
Use the Builder pattern to construct complex configuration objects with optional parameters and sensible defaults without telescoping constructors
PatternDecorator Pattern for HTTP Request Pipelines
Use the Decorator pattern to compose cross-cutting concerns like logging, metrics, and retries into HTTP request pipelines without modifying core logic