Skip to main content

Command Palette

Search for a command to run...

HTTP Caching: Reverse Proxy Configuration

Published
10 min read
T

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—