Caching Strategies for Web Applications: From Browser to Database
There are only two hard problems in computer science: cache invalidation, naming things, and off-by-one errors. The joke is old but the truth remains: caching is simultaneously the highest-impact performance optimization and the most common source of bugs in web applications.
I have debugged more caching issues than any other category of bug in my career. Stale data shown to users. Cache poisoning attacks. Thundering herds crashing databases. Inconsistent state between cache and source of truth. Each incident taught me something about doing caching properly.
The Caching Layers
A modern web application has six potential caching layers. Understanding each one prevents surprises:
Browser cache: the user's browser caches static assets (JS, CSS, images, fonts) and API responses based on HTTP cache headers. You control this via Cache-Control and ETag headers.
CDN/Edge cache: Cloudflare, Vercel Edge, AWS CloudFront cache responses at edge locations worldwide. Reduces latency by serving from a location near the user instead of your origin server.
Reverse proxy cache: Nginx, Varnish, or your load balancer can cache responses before they reach your application server. Reduces load on your application.
Application cache: your application maintains an in-memory or Redis-backed cache of computed results, database queries, or expensive operations.
ORM/Query cache: Prisma, Drizzle, or your database driver may cache query results within a request or across requests.
Database cache: the database itself caches query plans, frequently accessed rows, and index data in memory (PostgreSQL's shared_buffers, MySQL's innodb_buffer_pool).
Browser Caching Strategy
For static assets (JS, CSS, images) with content hashes in filenames (main.a1b2c3.js): set Cache-Control: public, max-age=31536000, immutable. These files never change (new content gets a new hash), so they can be cached forever.
For HTML documents: set Cache-Control: public, max-age=0, must-revalidate or no-cache. HTML should always be fresh because it references the current asset hashes. The browser checks with the server (using ETag/If-None-Match) but gets a fast 304 Not Modified if nothing changed.
For API responses: varies by endpoint. User-specific data: Cache-Control: private, no-store. Public data that changes infrequently: Cache-Control: public, max-age=300, stale-while-revalidate=600 (serve cached for 5 minutes, then revalidate in the background for the next 10 minutes).
The stale-while-revalidate pattern is powerful for API responses. The user gets an instant cached response while the browser fetches a fresh version in the background. The next request gets the fresh data. Users see fast responses without stale data lasting long.
CDN Caching
CDNs cache responses at edge locations. For AIDToolStack, our tool pages are cached at the CDN for 5 minutes. This means the first user in each region waits for the origin to respond; subsequent users in that region get an instant edge response.
Key CDN caching rules:
Cache by URL and relevant headers (Accept-Language, Accept-Encoding). Do not cache by Cookie header (this creates one cache entry per user, defeating the purpose).
Set appropriate Surrogate-Control or CDN-specific cache headers. These tell the CDN how long to cache, independent of the Cache-Control header sent to browsers.
Implement cache purging for content updates. When we publish a new blog post, we purge the /blog and /blog/[slug] paths from the CDN so users see the new content immediately.
Never cache responses with Set-Cookie headers. This can serve one user's session cookie to another user — a critical security vulnerability.
Application-Level Caching With Redis
For expensive computations and database queries, Redis provides a fast, shared cache accessible by all application instances.
The cache-aside pattern: check the cache first. If the data is there (cache hit), return it. If not (cache miss), compute the data, store it in the cache with a TTL, and return it.
The key design decisions:
TTL (Time To Live): how long before cached data expires. Too short and you get minimal benefit. Too long and users see stale data. For our tool pages: 5 minutes. For user-specific data: 60 seconds. For rarely-changing data (categories, site config): 1 hour.
Cache key design: include all parameters that affect the result. If the same endpoint returns different data for different users, include the user ID in the cache key. If results depend on query parameters, include them.
Serialization: Redis stores strings. You need to serialize objects to JSON (or MessagePack for efficiency) when writing and deserialize when reading. Account for the serialization cost in your performance calculations.
Cache Invalidation Strategies
The hard part. When source data changes, cached copies become stale. Options:
TTL-based expiration (simplest): set a TTL on every cache entry. After the TTL expires, the next request fetches fresh data. Acceptable staleness is bounded by the TTL. Simple to implement, no invalidation logic needed.
Event-based invalidation: when data changes, explicitly delete or update the related cache entries. More complex but provides instant freshness. Requires knowing which cache keys are affected by a given data change.
Versioned keys: include a version number in the cache key. When data changes, increment the version. Old cache entries with the old version are never accessed again and expire naturally. Avoids the complexity of explicit deletion.
We use TTL-based expiration for most things (simplicity wins) and event-based invalidation for user-facing data where staleness is noticeable (profile updates, new reviews appearing on tool pages).
The Thundering Herd Problem
When a popular cache key expires, dozens of requests simultaneously find a cache miss and all try to recompute the value. This can overwhelm your database.
Solution 1: Cache stampede protection (lock-based). When a cache miss occurs, the first request acquires a lock, computes the value, and populates the cache. Subsequent requests wait for the lock (or use a stale value if available).
Solution 2: Early expiration (probabilistic). Instead of expiring at exactly the TTL, each request has a small probability of refreshing the cache before it expires. This ensures the cache is refreshed before it goes stale, avoiding the thundering herd.
Solution 3: Background refresh. A background process refreshes popular cache keys before they expire. The cache never goes empty for high-traffic keys.
Common Caching Mistakes
Caching user-specific data without user context in the key. User A's dashboard cached without their user ID in the key gets served to User B. This is a data leak and a security incident.
Caching error responses. A temporary database error returns a 500, which gets cached. Now every user sees the error for the TTL duration even though the database recovered. Always check the response status before caching.
Not accounting for cache warmup. When you deploy a new version, the cache is empty. All requests hit the database simultaneously. Plan for cold-start scenarios: pre-warm the cache during deployment, implement gradual traffic shifting, or ensure your database can handle the full load temporarily.
Caching too aggressively during development. Your local Redis cache shows stale data and you spend 30 minutes debugging code that is actually correct. Always have a way to bypass cache during development (a header, a query parameter, or just disabling the cache entirely in dev mode).
Monitoring Cache Health
Track these metrics:
Cache hit ratio: the percentage of requests served from cache. Below 80% indicates your TTLs are too short or your cache is too small. Above 99% might indicate your TTLs are too long (data might be stale).
Cache latency: p50 and p99 latency for cache reads. Redis should respond in under 1ms. If latency spikes, investigate network issues or Redis memory pressure.
Eviction rate: how often Redis evicts keys due to memory pressure. High eviction rates mean your cache is too small for your working set. Either increase memory or reduce TTLs to shrink the working set.
Miss penalty: how long cache misses take to resolve (database query time). This is the latency users experience on a cache miss. If it is high, consider pre-warming popular keys.
Our Caching Architecture
At AIDToolStack, we cache at three layers:
CDN (Vercel Edge Network): all pages are edge-cached with ISR (Incremental Static Regeneration). Tool pages revalidate every 5 minutes. Blog posts revalidate every hour. This handles the majority of traffic without hitting our application server.
Application (Redis via Upstash): database query results for expensive operations (search results, comparison data, leaderboard rankings) are cached in Redis with 5-minute TTLs. This protects our database during traffic spikes.
Database (MySQL query cache): the database handles its own query plan caching. We optimize queries with proper indexes so even uncached queries are fast.
This layered approach means: edge-cached traffic never touches our server (95% of requests). Application-cached traffic never touches the database (4% of requests). Only 1% of requests actually query the database — and those queries are fast because of proper indexing.
Written by AIDToolStack Editorial
The AIDToolStack editorial team reviews developer tools, AI products, and MCP servers with hands-on testing and honest assessments. We use the tools we review every day in production.
References & Further Reading
Related Posts
Sponsor Our Newsletter
Reach thousands of developers who are actively evaluating AI tools, MCP servers, and dev infrastructure. Our weekly newsletter goes to engaged technical decision-makers.
All sponsored content is clearly labeled per our editorial policy.