HTTP Caching: Reverse Proxy Configuration
Welcome to TopperBlog! 👋
I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.
🎯 What I Write About:
• AI/ML Engineering & LLMs
• Web3 & Blockchain Development
• System Design & Architecture
• Interview Preparation (FAANG)
• Freelancing & Remote Work
• Modern Tech Stacks (Next.js, React, Rust, TypeScript)
• Performance Optimization & Best Practices
💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.
📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.
🌐 Let's connect and grow together in this amazing tech journey!
#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering
Metadata
SEO Title: HTTP Caching with Reverse Proxies: Complete 2025 Guide
Meta Description: Learn production-ready HTTP caching strategies using NGINX, Caddy, and Cloudflare. Optimize cache headers, invalidation, and CDN integration for modern apps.
Primary Keyword: reverse proxy caching strategies
Secondary Keywords: HTTP cache headers configuration, CDN cache invalidation, NGINX caching best practices, cache-control directives, stale-while-revalidate implementation, reverse proxy performance optimization, edge caching architecture
Tags: caching, reverse-proxy, nginx, performance, cdn, web-architecture, devops
Search Intent: guide
Content Role: pillar
Article
Modern web applications serve millions of requests daily, yet many engineering teams struggle with response times exceeding 500ms for cacheable content. The culprit isn't always database queries or complex business logic—it's misconfigured or absent HTTP caching at the reverse proxy layer. When your application regenerates the same product listing, API response, or static asset thousands of times per minute instead of serving it from cache, you're burning infrastructure budget and degrading user experience.
HTTP caching through reverse proxies represents the first line of defense in your performance architecture. A properly configured reverse proxy like NGINX, Caddy, or Cloudflare Workers can reduce origin server load by 70-90% while cutting response times from hundreds of milliseconds to single digits. Yet most teams either skip reverse proxy caching entirely, relying solely on browser caching, or implement it incorrectly, leading to stale content, cache stampedes, and inconsistent behavior across their infrastructure.
The stakes are higher in 2025. Users expect sub-second page loads, mobile networks demand bandwidth efficiency, and cloud infrastructure costs scale linearly with compute time. A misconfigured cache doesn't just slow your application—it directly impacts conversion rates, SEO rankings, and operational expenses.
Why Traditional Caching Approaches Fail Modern Applications
Legacy caching strategies were designed for simpler architectures: monolithic applications serving mostly static content with infrequent updates. These approaches break down when confronted with modern requirements.
Time-based expiration alone creates false choices. Setting a 1-hour max-age means users might see stale data for 59 minutes after an update. Setting it to 1 minute means your cache hit rate plummets, defeating the purpose. Modern applications need dynamic content that updates frequently but still benefits from caching.
Ignoring cache keys leads to incorrect responses. Many teams configure reverse proxies to cache based solely on URL path, ignoring query parameters, headers, or cookies. This causes personalized API responses to be served to wrong users, or mobile-optimized content to be delivered to desktop browsers.
Lack of programmatic invalidation creates deployment bottlenecks. When you can't selectively purge cache entries, you're forced to either wait for TTL expiration or flush the entire cache, losing all performance benefits temporarily.
Missing cache warming strategies cause thundering herds. After a cache flush or cold start, thousands of requests simultaneously hit your origin servers for the same resources, creating CPU spikes and potential outages.
Modern Reverse Proxy Caching Architecture
A production-grade caching strategy requires coordinating multiple layers: reverse proxy cache, CDN edge cache, and browser cache. Each layer serves a specific purpose with distinct configuration requirements.
Layer 1: Origin Reverse Proxy Configuration
Your origin reverse proxy (NGINX, Caddy, or similar) sits between your application servers and the public internet or CDN. Here's a production-ready NGINX configuration implementing modern caching patterns:
# /etc/nginx/nginx.conf
proxy_cache_path /var/cache/nginx/api
levels=1:2
keys_zone=api_cache:100m
max_size=10g
inactive=60m
use_temp_path=off;
proxy_cache_path /var/cache/nginx/static
levels=1:2
keys_zone=static_cache:50m
max_size=5g
inactive=7d
use_temp_path=off;
# Cache key includes method, host, URI, and selected headers
map $request_method$http_accept$http_accept_encoding $cache_key_suffix {
default "$request_method|$http_accept|$http_accept_encoding";
}
upstream app_backend {
least_conn;
server app1.internal:3000 max_fails=3 fail_timeout=30s;
server app2.internal:3000 max_fails=3 fail_timeout=30s;
keepalive 32;
}
server {
listen 80;
server_name api.example.com;
# API endpoints with stale-while-revalidate
location /api/v1/products {
proxy_pass http://app_backend;
proxy_cache api_cache;
proxy_cache_key "$host$uri$is_args$args|$cache_key_suffix";
# Cache successful responses for 5 minutes
proxy_cache_valid 200 5m;
proxy_cache_valid 404 1m;
# Serve stale content while revalidating
proxy_cache_use_stale error timeout updating http_500 http_502 http_503;
proxy_cache_background_update on;
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
# Add cache status header for debugging
add_header X-Cache-Status $upstream_cache_status;
add_header Cache-Control "public, max-age=300, stale-while-revalidate=60";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
# Static assets with long-term caching
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff2)$ {
proxy_pass http://app_backend;
proxy_cache static_cache;
proxy_cache_key "$host$uri";
proxy_cache_valid 200 30d;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header X-Cache-Status $upstream_cache_status;
proxy_ignore_headers Set-Cookie;
proxy_hide_header Set-Cookie;
}
# Cache purge endpoint (restrict to internal IPs)
location ~ /purge(/.*) {
allow 10.0.0.0/8;
deny all;
proxy_cache_purge api_cache "$host$1$is_args$args|$cache_key_suffix";
}
}
This configuration implements several critical patterns:
Stale-while-revalidate allows serving cached content immediately while fetching fresh data in the background. Users get instant responses, and your cache stays current.
Cache locking prevents multiple simultaneous requests for the same uncached resource from hitting your backend. Only one request proceeds; others wait for the cached response.
Granular cache keys ensure different content variations (mobile vs. desktop, gzip vs. brotli) are cached separately.
Layer 2: Programmatic Cache Invalidation
Modern applications need selective cache invalidation. Here's a TypeScript service that manages cache purging across your infrastructure:
// cache-invalidation.service.ts
import { Injectable, Logger } from '@nestjs/common';
import axios from 'axios';
import { Redis } from 'ioredis';
interface CacheInvalidationOptions {
paths: string[];
tags?: string[];
surrogate_keys?: string[];
}
@Injectable()
export class CacheInvalidationService {
private readonly logger = new Logger(CacheInvalidationService.name);
private readonly redis: Redis;
private readonly nginxHosts: string[];
private readonly cloudflareZoneId: string;
private readonly cloudflareToken: string;
constructor() {
this.redis = new Redis(process.env.REDIS_URL);
this.nginxHosts = process.env.NGINX_HOSTS?.split(',') || [];
this.cloudflareZoneId = process.env.CLOUDFLARE_ZONE_ID;
this.cloudflareToken = process.env.CLOUDFLARE_TOKEN;
}
async invalidateCache(options: CacheInvalidationOptions): Promise<void> {
const tasks = [
this.purgeNginxCache(options.paths),
this.purgeCloudflareCache(options.paths, options.tags),
this.recordInvalidation(options),
];
try {
await Promise.allSettled(tasks);
this.logger.log(`Cache invalidated for paths: ${options.paths.join(', ')}`);
} catch (error) {
this.logger.error('Cache invalidation failed', error);
throw error;
}
}
private async purgeNginxCache(paths: string[]): Promise<void> {
const purgePromises = this.nginxHosts.flatMap(host =>
paths.map(path =>
axios.request({
method: 'PURGE',
url: `http://${host}/purge${path}`,
timeout: 5000,
}).catch(err => {
this.logger.warn(`NGINX purge failed for ${host}${path}: ${err.message}`);
})
)
);
await Promise.allSettled(purgePromises);
}
private async purgeCloudflareCache(
paths: string[],
tags?: string[]
): Promise<void> {
if (!this.cloudflareZoneId || !this.cloudflareToken) {
return;
}
const purgePayload: any = {};
if (paths.length > 0) {
purgePayload.files = paths.map(p =>
`https://api.example.com${p}`
);
}
if (tags && tags.length > 0) {
purgePayload.tags = tags;
}
try {
await axios.post(
`https://api.cloudflare.com/client/v4/zones/${this.cloudflareZoneId}/purge_cache`,
purgePayload,
{
headers: {
'Authorization': `Bearer ${this.cloudflareToken}`,
'Content-Type': 'application/json',
},
timeout: 10000,
}
);
} catch (error) {
this.logger.error('Cloudflare purge failed', error);
throw error;
}
}
private async recordInvalidation(options: CacheInvalidationOptions): Promise<void> {
const record = {
timestamp: new Date().toISOString(),
paths: options.paths,
tags: options.tags,
};
await this.redis.lpush(
'cache:invalidations',
JSON.stringify(record)
);
await this.redis.ltrim('cache:invalidations', 0, 999);
}
async warmCache(paths: string[]): Promise<void> {
const warmPromises = paths.map(async path => {
try {
await axios.get(`https://api.example.com${path}`, {
headers: { 'X-Cache-Warm': 'true' },
timeout: 30000,
});
} catch (error) {
this.logger.warn(`Cache warming failed for ${path}`);
}
});
await Promise.allSettled(warmPromises);
this.logger.log(`Cache warmed for ${paths.length} paths`);
}
}
Integrate this service into your application lifecycle:
// product.service.ts
@Injectable()
export class ProductService {
constructor(
private readonly cacheInvalidation: CacheInvalidationService,
private readonly productRepository: ProductRepository,
) {}
async updateProduct(id: string, data: UpdateProductDto): Promise<Product> {
const product = await this.productRepository.update(id, data);
// Invalidate specific product and list caches
await this.cacheInvalidation.invalidateCache({
paths: [
`/api/v1/products/${id}`,
`/api/v1/products`,
`/api/v1/categories/${product.categoryId}/products`,
],
tags: [`product-${id}`, `category-${product.categoryId}`],
});
return product;
}
async deployNewVersion(): Promise<void> {
const criticalPaths = [
'/api/v1/products',
'/api/v1/categories',
'/api/v1/featured',
];
// Warm cache before switching traffic
await this.cacheInvalidation.warmCache(criticalPaths);
}
}
Layer 3: CDN Edge Cache Configuration
For global applications, CDN edge caching provides the final performance layer. Here's a Cloudflare Workers example implementing sophisticated caching logic:
// cloudflare-worker.ts
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const cacheKey = new Request(url.toString(), request);
const cache = caches.default;
// Check cache first
let response = await cache.match(cacheKey);
if (response) {
const age = Date.now() - new Date(response.headers.get('Date')).getTime();
const maxAge = 300000; // 5 minutes
// Serve stale content while revalidating
if (age > maxAge) {
const revalidatePromise = fetch(request)
.then(freshResponse => {
if (freshResponse.ok) {
cache.put(cacheKey, freshResponse.clone());
}
return freshResponse;
});
// Don't wait for revalidation
return new Response(response.body, {
status: response.status,
headers: {
...Object.fromEntries(response.headers),
'X-Cache': 'STALE',
'Age': Math.floor(age / 1000).toString(),
},
});
}
return new Response(response.body, {
status: response.status,
headers: {
...Object.fromEntries(response.headers),
'X-Cache': 'HIT',
'Age': Math.floor(age / 1000).toString(),
},
});
}
// Cache miss - fetch from origin
response = await fetch(request);
if (response.ok && request.method === 'GET') {
const shouldCache = this.shouldCacheResponse(url, response);
if (shouldCache) {
const cacheResponse = response.clone();
await cache.put(cacheKey, cacheResponse);
}
}
return new Response(response.body, {
status: response.status,
headers: {
...Object.fromEntries(response.headers),
'X-Cache': 'MISS',
},
});
},
shouldCacheResponse(url: URL, response: Response): boolean {
// Don't cache authenticated requests
if (response.headers.has('Set-Cookie')) {
return false;
}
// Cache API responses with appropriate headers
if (url.pathname.startsWith('/api/v1/')) {
const cacheControl = response.headers.get('Cache-Control');
return cacheControl?.includes('public') || false;
}
// Always cache static assets
return /\.(jpg|jpeg|png|gif|css|js|woff2)$/.test(url.pathname);
},
};
Common Pitfalls and Edge Cases
Caching authenticated content: Never cache responses containing user-specific data without proper cache key segmentation. Use Vary: Cookie or exclude authentication cookies from cache keys entirely.
Cache stampede during deployments: When invalidating cache during deployments, warm critical paths before directing traffic to new instances. Implement gradual rollouts with cache warming.
Inconsistent cache behavior across regions: CDN edge locations may have different cache states. Use cache tags or surrogate keys for atomic, global invalidation rather than path-based purging.
Ignoring cache size limits: Reverse proxy caches have finite storage. Monitor cache hit rates and eviction patterns. Implement tiered caching where hot content stays in memory and cold content moves to disk.
Missing cache validation: Always include ETag or Last-Modified headers in origin responses. This enables conditional requests (If-None-Match, If-Modified-Since) and reduces bandwidth even when cache validation is required.
Compression mismatch: Cache compressed and uncompressed variants separately. Use Vary: Accept-Encoding to ensure clients receive appropriately encoded responses.
Query parameter ordering: URLs with different query parameter orders (?a=1&b=2 vs ?b=2&a=1) create separate cache entries. Normalize query parameters in your cache key logic.
Best Practices Checklist
✅ Implement stale-while-revalidate for dynamic content that changes infrequently but requires freshness
✅ Use cache tags or surrogate keys for granular, programmatic invalidation across distributed systems
✅ Configure appropriate Vary headers to cache different content variations correctly
✅ Enable cache locking to prevent thundering herd problems during cache misses
✅ Monitor cache hit rates and set alerts for sudden drops indicating configuration issues
✅ Implement cache warming as part of deployment pipelines to prevent cold start performance degradation
✅ Set different TTLs per content type: 1 year for immutable assets, 5-15 minutes for API responses, 1 hour for semi-static pages
✅ Add cache status headers (X-Cache-Status, Age) for debugging and monitoring
✅ Use immutable directive for versioned static assets to maximize cache efficiency
✅ Implement circuit breakers in cache invalidation logic to prevent cascading failures
✅ Test cache behavior in staging environments with production-like traffic patterns
Frequently Asked Questions
How do I prevent serving stale data after database updates?
Implement event-driven cache invalidation. When your application updates data, immediately trigger cache purges for affected resources. Use message queues (Redis Pub/Sub, RabbitMQ) to ensure invalidation happens even if the update request fails partway through.
What's the difference between Cache-Control max-age and s-maxage?
max-age controls browser and shared cache TTL, while s-maxage applies only to shared caches (reverse proxies, CDNs). Use s-maxage to cache content longer at edge locations while keeping browser caches shorter for faster updates.
Should I cache POST requests in my reverse proxy?
Generally no. POST requests typically modify state and shouldn't be cached. However, some read-heavy POST endpoints (GraphQL queries) can benefit from caching if you implement proper cache key generation based on request body content.
How do I handle cache invalidation across multiple data centers?
Use a centralized invalidation service that broadcasts purge commands to all reverse proxy instances. Implement eventual consistency patterns where brief cache inconsistency is acceptable, or use cache tags for atomic global invalidation via your CDN.
What cache hit rate should I target?
Aim for 80-95% cache hit rate for static assets and 60-80% for dynamic API responses. Lower rates indicate misconfigured TTLs, poor cache key design, or content that genuinely can't be cached effectively.
How do I debug cache misses in production?
Add X-Cache-Status headers showing HIT/MISS/STALE status. Log cache keys and response headers. Use CDN analytics dashboards to identify paths with low hit rates. Implement distributed tracing to track requests through cache layers.
Can I cache personalized content safely?
Yes, with proper cache key segmentation. Include user ID or session ID in cache keys, or use edge computing (Cloudflare Workers, Lambda@Edge) to assemble personalized responses from cached fragments. Never cache full personalized responses without user-specific keys.
Conclusion
Effective reverse proxy caching requires coordinating multiple layers—origin reverse proxy, CDN edge cache, and browser cache—