Caching Strategies for Web Apps: From Browser to CDN to Database
Caching is the single most effective performance optimization for web applications. Not code splitting. Not lazy loading. Not switching to a faster framework. Caching. A properly cached response takes 0ms of server time and 0ms of database time. Nothing is faster than not doing the work at all.
But caching is also where we see the most confusion. Teams either cache nothing (and wonder why their app is slow) or cache everything aggressively (and spend days debugging stale data). The truth is that caching is a layered system, and each layer has different trade-offs.
We have iterated on caching strategies across every project we have shipped. MindHyv serves business profile pages that get cached at the CDN level. Trackelio caches aggregated feedback analytics to avoid expensive queries. SpotsMexico caches location data that rarely changes but gets read thousands of times per day.
Here is how we think about caching, from the layer closest to the user all the way down to the database.
Layer 1: Browser Cache (HTTP Cache Headers)
The browser cache is the fastest cache because there is no network request at all. The browser checks its local cache, finds a valid entry, and uses it. Zero latency.
You control browser caching through HTTP response headers. The two that matter most are Cache-Control and ETag.
Cache-Control
Cache-Control: public, max-age=31536000, immutable
This header tells the browser (and any intermediary caches) to cache the response for one year and never revalidate it. This is the right setting for static assets with content hashes in their filenames — things like app.a1b2c3.js or styles.d4e5f6.css.
For dynamic content, you want something more conservative:
Cache-Control: private, max-age=0, must-revalidate
This tells the browser to always check with the server before using a cached version. Combined with ETags, this gives you fast cache hits (304 Not Modified) while ensuring users always see fresh data.
Here is how we set cache headers in different scenarios:
// Static assets with content hashes (fonts, JS bundles, images)
// Cache forever — the filename changes when the content changes
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
// API responses that change infrequently (user profile, settings)
// Cache for 5 minutes, allow CDN to serve stale while revalidating
res.setHeader('Cache-Control', 'public, max-age=300, stale-while-revalidate=60');
// API responses with real-time data (notifications, live feed)
// Never cache
res.setHeader('Cache-Control', 'no-store');
// Authenticated responses (user-specific data)
// Private means CDN/proxies must not cache, only the browser
res.setHeader('Cache-Control', 'private, max-age=60');
ETags for Conditional Requests
ETags let the server tell the browser “this content has not changed since you last asked.” The browser sends If-None-Match: <etag> and the server responds with 304 (no body) if the content is the same. This saves bandwidth and parsing time.
import { createHash } from 'crypto';
function generateETag(content: string | Buffer): string {
return createHash('md5').update(content).digest('hex');
}
export function handleWithETag(req: Request, content: string): Response {
const etag = `"${generateETag(content)}"`;
const clientETag = req.headers.get('If-None-Match');
if (clientETag === etag) {
return new Response(null, { status: 304 });
}
return new Response(content, {
headers: {
'Content-Type': 'application/json',
'ETag': etag,
'Cache-Control': 'public, max-age=0, must-revalidate',
},
});
}

Layer 2: CDN Cache
A CDN (Cloudflare, Vercel Edge Network, AWS CloudFront) sits between your users and your origin server. It caches responses at edge nodes around the world, so users get responses from a nearby node instead of your origin.
CDN caching is controlled by the same Cache-Control headers, but with an important addition: s-maxage.
Cache-Control: public, s-maxage=3600, max-age=60
This tells the CDN to cache for 1 hour (s-maxage), but tells the browser to cache for only 1 minute (max-age). This is a powerful pattern: the CDN absorbs most of the traffic, and users get reasonably fresh data.
Stale-While-Revalidate
This is the most useful cache directive for dynamic content:
Cache-Control: public, s-maxage=300, stale-while-revalidate=600
This means: serve from CDN cache for 5 minutes. After 5 minutes, serve the stale version immediately to the user AND fetch a fresh version in the background for the next request. The user never waits for the origin server.
We use this heavily for SpotsMexico. Location listings change maybe once a week, but they get viewed constantly. A 5-minute CDN cache with stale-while-revalidate means the origin database handles a fraction of the actual traffic.
Cache Keys and Variants
CDNs cache based on the URL by default. But what if the same URL returns different content based on headers (like Accept-Language or Accept for content negotiation)?
Vary: Accept-Language, Accept-Encoding
The Vary header tells the CDN to maintain separate cached versions for each combination of the listed headers. Use this sparingly — each combination multiplies your cache storage and reduces hit rates.
Purging CDN Cache
When content changes, you need to invalidate the CDN cache. There are three approaches:
1. Time-based expiry. Set a short s-maxage and wait. Simple, but users see stale data until the cache expires.
2. Tag-based purging. Tag cached responses and purge by tag when content changes.
// When serving a business profile page
res.setHeader('Cache-Tag', `business:${businessId}`);
res.setHeader('Cache-Control', 'public, s-maxage=86400');
// When the business updates their profile
await fetch('https://api.cloudflare.com/client/v4/zones/{zone}/purge_cache', {
method: 'POST',
headers: { 'Authorization': `Bearer ${CF_TOKEN}` },
body: JSON.stringify({
tags: [`business:${businessId}`],
}),
});
3. URL-based purging. Purge specific URLs when you know exactly what changed.
We prefer tag-based purging when the CDN supports it (Cloudflare does on Enterprise, Vercel does not). For simpler setups, short TTLs with stale-while-revalidate are the pragmatic choice.

Layer 3: Application Cache
Between your CDN and your database sits your application code. This is where you cache the results of expensive computations, external API calls, and database query results.
In-Memory Cache
For single-server deployments or serverless functions with warm instances, a simple in-memory cache works surprisingly well:
// lib/cache.ts
interface CacheEntry<T> {
value: T;
expiresAt: number;
}
class MemoryCache {
private store = new Map<string, CacheEntry<unknown>>();
private maxSize: number;
constructor(maxSize = 1000) {
this.maxSize = maxSize;
}
get<T>(key: string): T | null {
const entry = this.store.get(key) as CacheEntry<T> | undefined;
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
return entry.value;
}
set<T>(key: string, value: T, ttlSeconds: number): void {
// Simple eviction: delete oldest entries when at capacity
if (this.store.size >= this.maxSize) {
const firstKey = this.store.keys().next().value;
if (firstKey) this.store.delete(firstKey);
}
this.store.set(key, {
value,
expiresAt: Date.now() + ttlSeconds * 1000,
});
}
invalidate(pattern: string): void {
for (const key of this.store.keys()) {
if (key.startsWith(pattern)) {
this.store.delete(key);
}
}
}
}
export const cache = new MemoryCache();
Redis Cache
For multi-server deployments or when you need shared cache state, Redis is the standard choice. We use it for caching aggregated analytics in Trackelio:
// lib/redis-cache.ts
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
export async function cached<T>(
key: string,
ttlSeconds: number,
fetcher: () => Promise<T>
): Promise<T> {
// Try cache first
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached) as T;
}
// Cache miss — fetch fresh data
const fresh = await fetcher();
// Store in cache (non-blocking)
redis.setex(key, ttlSeconds, JSON.stringify(fresh)).catch(console.error);
return fresh;
}
// Usage
const analytics = await cached(
`analytics:${projectId}:${dateRange}`,
300, // 5 minutes
() => computeAnalytics(projectId, dateRange)
);
The cached wrapper function is our most reused caching utility. It encapsulates the cache-aside pattern: check cache, on miss fetch from source, store in cache, return. Simple, but it eliminates 90% of redundant database queries.
Cache Stampede Prevention
When a popular cache entry expires, multiple requests can hit the origin simultaneously — all finding the cache empty, all fetching the same data. This is a cache stampede.
The fix is a lock:
export async function cachedWithLock<T>(
key: string,
ttlSeconds: number,
fetcher: () => Promise<T>
): Promise<T> {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached) as T;
// Try to acquire a lock
const lockKey = `lock:${key}`;
const acquired = await redis.set(lockKey, '1', 'EX', 10, 'NX');
if (!acquired) {
// Another process is refreshing — wait and retry
await new Promise((resolve) => setTimeout(resolve, 100));
return cachedWithLock(key, ttlSeconds, fetcher);
}
try {
// Double-check after acquiring lock
const rechecked = await redis.get(key);
if (rechecked) return JSON.parse(rechecked) as T;
const fresh = await fetcher();
await redis.setex(key, ttlSeconds, JSON.stringify(fresh));
return fresh;
} finally {
await redis.del(lockKey);
}
}
Layer 4: Database Query Cache
The final layer is caching at the database level. PostgreSQL has its own internal caching (shared buffers, OS page cache), but you can also cache query results explicitly.
Materialized Views
For expensive analytical queries that do not need real-time freshness, PostgreSQL materialized views are excellent:
-- Create a materialized view for dashboard analytics
create materialized view mv_project_analytics as
select
p.id as project_id,
count(f.id) as total_feedback,
count(f.id) filter (where f.created_at > now() - interval '7 days') as feedback_last_7d,
avg(f.sentiment_score) as avg_sentiment,
count(distinct f.user_id) as unique_users
from projects p
left join feedback f on f.project_id = p.id
group by p.id;
-- Create an index on the materialized view
create unique index idx_mv_project_analytics_id
on mv_project_analytics(project_id);
-- Refresh it periodically (we run this every 5 minutes via pg_cron)
refresh materialized view concurrently mv_project_analytics;
The concurrently keyword is important — it allows reads while the view is being refreshed. Without it, the view is locked during refresh.
Prepared Statements and Connection Pooling
This is not caching in the traditional sense, but it has a similar effect. Prepared statements let PostgreSQL reuse query plans across executions:
// Using pg driver with prepared statements
const result = await pool.query({
name: 'get-user-uploads',
text: 'SELECT * FROM uploads WHERE user_id = $1 AND status = $2 ORDER BY created_at DESC LIMIT $3',
values: [userId, 'complete', 20],
});
By naming the query, PostgreSQL caches the execution plan and reuses it on subsequent calls with different parameter values.

Cache Invalidation Strategy
Phil Karlton’s famous quote about cache invalidation being one of the two hard things in computer science is overblown. Cache invalidation is straightforward if you follow two rules:
1. Use time-based expiry as your default. Most data can tolerate being 1-5 minutes stale. Set a TTL and stop worrying.
2. Use event-driven invalidation for data that must be fresh. When a user updates their profile, invalidate the profile cache. Do not try to invalidate every possible cache entry that might contain profile data.
// Event-driven cache invalidation
async function updateBusinessProfile(businessId: string, data: ProfileData) {
// Update the database
await supabase.from('businesses').update(data).eq('id', businessId);
// Invalidate caches
await Promise.all([
redis.del(`business:${businessId}`),
redis.del(`business:${businessId}:profile`),
purgecdn(`business:${businessId}`),
]);
}
The pattern we keep coming back to: short TTLs for freshness, aggressive CDN caching for performance, event-driven invalidation for writes. This handles 95% of real-world caching needs without exotic tooling.
Putting It All Together
Here is the full cache flow for a typical page load in one of our applications:
- Browser cache checks if it has a valid cached version. If
max-agehas not expired, it uses the local copy. Zero network requests. - CDN cache receives the request if the browser cache misses. If the CDN has a valid cached version, it returns it. No traffic reaches your server.
- Application cache (Redis or in-memory) handles the request if the CDN misses. If the data is cached at this layer, no database query runs.
- Database executes the query only if all three cache layers miss. The result is stored back through the layers on the way out.
Each layer reduces the load on the next. A well-cached application can handle 100x the traffic with the same infrastructure.
For details on how this interacts with our edge function architecture, see our post on edge functions vs serverless.
The most common mistake we see is teams jumping straight to Redis without first getting their HTTP cache headers right. Start at the browser. Work your way down. You will be surprised how much traffic you can absorb with just proper Cache-Control headers and a CDN.
If you are running into performance problems and want help designing a caching strategy, reach out at [email protected]. We have tuned caching for apps handling millions of requests, and the approach is usually simpler than you think.