Skip to content
SP StackPractices
intermediate By StackPractices

Caching Strategies — From Browser to Database, a Complete Guide

A practical guide to caching strategies: browser caching, CDN edge caching, application caching with Redis, and database query caching. Learn when to use each and how to avoid cache invalidation nightmares.

Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.

Overview

Caching is one of the most effective ways to improve application performance and reduce infrastructure costs. By storing copies of frequently accessed data closer to where it is needed, you reduce latency, decrease database load, and improve user experience. But caching introduces complexity: stale data, invalidation logic, and consistency challenges.

This guide covers caching at every layer of the stack, from browser to database.

When to Use

  • Your database is under high read load and scaling vertically is expensive
  • API response times exceed your latency SLO
  • You serve static or semi-static content to many users
  • You have expensive computations that can be reused
  • Your application makes repeated identical queries or API calls

The Caching Hierarchy

User Browser
    ↓ (HTTP Cache-Control)
CDN Edge (Cloudflare, Fastly, CloudFront)
    ↓ (Cache rules, TTL)
Load Balancer / Reverse Proxy (Nginx, Varnish)
    ↓ (Proxy cache, rate limit cache)
Application Cache (Redis, Memcached)
    ↓ (Key-value, TTL, eviction)
Database Query Cache (PostgreSQL, MySQL)
    ↓ (Query plan cache, buffer pool)
Disk / Storage
LayerLatencyTypical Use Case
Browser0msStatic assets, API responses
CDN10-50msImages, CSS, JS, HTML pages
Reverse Proxy1-5msAPI endpoints, rendered pages
Application1-5msSession data, computed results
Database1-10msQuery results, frequently joined data

Step-by-Step Caching Implementation

1. Browser Caching

Leverage the browser’s built-in cache first:

# Nginx: Cache static assets aggressively
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    add_header Vary "Accept-Encoding";
}

# API responses: conditional caching
location /api/ {
    add_header Cache-Control "public, max-age=60, stale-while-revalidate=300";
}
// Client-side: Service Worker for offline caching
// sw.js
const CACHE_NAME = 'app-v1';
const urlsToCache = ['/static/app.js', '/static/styles.css', '/api/config'];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(urlsToCache))
  );
});

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      // Return cached or fetch from network
      return response || fetch(event.request);
    })
  );
});

Browser cache headers explained:

HeaderMeaningExample
Cache-Control: no-storeNever cacheSensitive data
Cache-Control: no-cacheRevalidate every timeSemi-dynamic content
Cache-Control: max-age=3600Cache for 1 hourStatic API responses
Cache-Control: immutableNever revalidateHashed asset filenames
ETagVersion identifier for conditional requestsAPI resources
Last-ModifiedTimestamp for conditional requestsFile-based resources

2. CDN Edge Caching

Cache at the edge to reduce origin load:

# Nginx with cache layer
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=app_cache:100m max_size=1g;

server {
    location /api/public/ {
        proxy_cache app_cache;
        proxy_cache_valid 200 5m;
        proxy_cache_use_stale error timeout updating;
        proxy_cache_key "$scheme$request_method$host$request_uri";
        add_header X-Cache-Status $upstream_cache_status;
    }
}
# CloudFront CDN distribution with caching
resource "aws_cloudfront_distribution" "cdn" {
  enabled = true

  default_cache_behavior {
    allowed_methods  = ["GET", "HEAD"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = "app_origin"

    forwarded_values {
      query_string = true
      cookies { forward = "none" }
    }

    viewer_protocol_policy = "redirect-to-https"
    min_ttl                = 0
    default_ttl            = 3600
    max_ttl                = 86400
  }
}

CDN cache rules:

  • Cache static assets (images, CSS, JS) for 1 year with hashed filenames
  • Cache API responses based on URL patterns and query parameters
  • Use stale-while-revalidate for user-facing content (show stale, refresh in background)
  • Purge selectively using cache tags or surrogate keys

3. Application Caching with Redis

The workhorse of application caching:

# Example: Python with Redis for application caching
import redis
import json
import hashlib
from functools import wraps

r = redis.Redis(host='redis', port=6379, db=0)

def cache_with_ttl(ttl_seconds=300):
    """Decorator to cache function results in Redis."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            # Create a deterministic cache key
            key_data = json.dumps({"func": func.__name__, "args": args, "kwargs": kwargs})
            cache_key = f"cache:{func.__name__}:{hashlib.sha256(key_data.encode()).hexdigest()[:16]}"
            
            # Try to get from cache
            cached = r.get(cache_key)
            if cached:
                return json.loads(cached)
            
            # Compute and store
            result = func(*args, **kwargs)
            r.setex(cache_key, ttl_seconds, json.dumps(result))
            return result
        return wrapper
    return decorator

@cache_with_ttl(ttl_seconds=600)
def get_product_details(product_id):
    """Expensive database query."""
    return db.query(Product).get(product_id).to_dict()

@cache_with_ttl(ttl_seconds=60)
def get_dashboard_stats(user_id):
    """Expensive aggregation."""
    return compute_dashboard_stats(user_id)
// Example: Spring Boot with Redis cache
@Configuration
@EnableCaching
public class CacheConfig {
    
    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
        return RedisCacheManager.builder(factory)
            .cacheDefaults(
                RedisCacheConfiguration.defaultCacheConfig()
                    .entryTtl(Duration.ofMinutes(10))
                    .serializeValuesWith(
                        RedisSerializationContext.SerializationPair.fromSerializer(
                            new GenericJackson2JsonRedisSerializer()
                        )
                    )
            )
            .build();
    }
}

@Service
public class ProductService {
    
    @Cacheable(value = "products", key = "#id")
    public Product getProduct(String id) {
        return productRepository.findById(id).orElseThrow();
    }
    
    @CacheEvict(value = "products", key = "#product.id")
    public Product updateProduct(Product product) {
        return productRepository.save(product);
    }
    
    @CacheEvict(value = "products", allEntries = true)
    public void clearProductCache() {
        // Bulk invalidation
    }
}

Redis caching patterns:

PatternWhen to UseRisk
Cache-AsideRead-heavy, simple invalidationStale data if invalidation fails
Read-ThroughComplex cache warmingCache becomes required dependency
Write-ThroughStrong consistency neededWrite latency increases
Write-BehindWrite-heavy, eventual consistencyData loss if cache fails before flush

4. Database Query Caching

Let the database cache for you:

-- PostgreSQL: Enable and tune query cache settings
-- postgresql.conf
shared_buffers = 4GB                  # 25% of RAM for buffer pool
effective_cache_size = 12GB           # Total OS + PostgreSQL cache
work_mem = 256MB                      # Per-query sort/hash memory

-- Create a materialized view for expensive aggregations
CREATE MATERIALIZED VIEW daily_sales_summary AS
SELECT 
    date_trunc('day', created_at) as day,
    sum(amount) as total_sales,
    count(*) as order_count
FROM orders
WHERE created_at > now() - interval '90 days'
GROUP BY 1;

-- Refresh on a schedule (or use pg_cron)
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_sales_summary;
-- MySQL: Query cache (removed in 8.0; use ProxySQL or application cache instead)
-- For MySQL 5.7 and earlier:
query_cache_type = 1
query_cache_size = 256M
query_cache_limit = 8M

Database caching best practices:

  • Tune shared_buffers (PostgreSQL) or innodb_buffer_pool_size (MySQL)
  • Use materialized views for expensive aggregations that do not need real-time data
  • Create covering indexes so queries are served entirely from index pages
  • Monitor cache hit ratio (should be >99% for OLTP)

Cache Invalidation Strategies

The hardest problem in caching:

StrategyHow It WorksBest For
TTL (Time to Live)Expire after fixed durationData that can be stale briefly
Active InvalidationDelete/update cache on writeStrong consistency requirements
Event-DrivenListen to change events (CDC)Distributed systems
Versioned KeysInclude version/hash in keyImmutable deployments
Cache WarmingPre-populate before peak loadPredictable traffic patterns
# Example: Event-driven invalidation with Redis Pub/Sub
import redis

r = redis.Redis(host='redis', port=6379)
p = r.pubsub()

def handle_invalidation(message):
    key = message['data']
    r.delete(f"cache:product:{key}")
    print(f"Invalidated cache for product {key}")

p.subscribe(**{'product-updates': handle_invalidation})
p.run_in_thread(sleep_time=0.001)

# On product update, publish event
r.publish('product-updates', product_id)

Best Practices

  • Cache at multiple layers. Browser + CDN + Redis + database buffer pool.
  • Use TTLs appropriate to data volatility. User profile: 1 hour. Product catalog: 1 day. Session: 15 minutes.
  • Design for cache failure. If Redis is down, your app should still work (degraded, not broken).
  • Monitor cache hit rates. Target >90% for application cache, >99% for database buffer pool.
  • Avoid caching everything. Small, frequently accessed data benefits most. Large, rarely accessed data wastes memory.
  • Use consistent hashing for distributed caches. Redis Cluster or client-side sharding prevents hotspotting.

Common Mistakes

  • Cache stampede (thundering herd). Many requests hit the backend simultaneously when cache expires. Use locks or single-flight patterns.
  • Storing non-serializable objects. Cache simple types (strings, JSON), not ORM objects or file handles.
  • No eviction strategy. Unbounded cache growth leads to OOM. Set maxmemory-policy in Redis.
  • Ignoring cache warm-up. A cold cache after restart causes latency spikes. Warm gradually.
  • Over-caching. Every layer of caching adds complexity. Measure before adding each layer.

Variants

  • Local in-process cache: Caffeine (Java), LRU-cache (Python) — fastest, no network, but per-instance
  • Distributed cache: Redis, Memcached — shared across instances, requires network
  • Hierarchical cache: Local L1 + Redis L2 — best of both worlds, complex invalidation
  • CDN with edge logic: Cloudflare Workers, Fastly VCL — cache and compute at the edge

FAQ

Q: Should I use Redis or Memcached? Redis is more feature-rich (data structures, persistence, pub/sub). Memcached is simpler and slightly faster for pure key-value. Use Redis unless you have a specific reason not to.

Q: How do I prevent cache stampede? Use a locking mechanism (Redis SET NX EX) so only one process regenerates the cache. Alternatively, stagger TTLs or use probabilistic early expiration.

Q: What is a good cache hit rate? Application cache: >85% is good, >95% is excellent. Database buffer pool: >99% is expected for OLTP.

Q: Should I cache writes (write-behind)? Only if you can tolerate brief data loss and have a retry mechanism. Write-through or cache-aside are safer for most applications.

Conclusion

Effective caching transforms application performance. By layering caches from browser to database, choosing appropriate invalidation strategies, and monitoring hit rates, you reduce latency and infrastructure cost while maintaining data consistency.