Layer 1: Browser cache
The fastest request is one that never leaves the browser.
Cache-Control: public, max-age=31536000, immutable
Use this for static assets with hashed filenames (JS bundles, CSS, images). The immutable flag tells the browser not even to revalidate — the hash in the filename is the version.
For HTML and API responses, use shorter TTLs with revalidation:
Cache-Control: public, max-age=0, must-revalidate
ETag: "abc123"
The browser caches the response but checks with the server on every request. If the ETag matches, the server returns 304 (no body), saving bandwidth.
Layer 2: CDN cache
Put a CDN in front of your API for read-heavy endpoints. Product listings, blog posts, user profiles — anything that does not change per-user.
Cache-Control: public, s-maxage=300, stale-while-revalidate=60
s-maxage tells the CDN to cache for 5 minutes. stale-while-revalidate serves the stale response while fetching a fresh one in the background. Users never wait for a cache miss.
Layer 3: Application cache (Redis)
For data that is expensive to compute or query:
async function getPopularPosts() {
const cached = await redis.get('popular-posts');
if (cached) return JSON.parse(cached);
const posts = await db.query(expensiveAggregation);
await redis.setEx('popular-posts', 300, JSON.stringify(posts));
return posts;
}
The key decision: TTL vs explicit invalidation. TTL is simpler and safer. Explicit invalidation is more responsive but harder to get right.
Layer 4: Database query cache
Most databases cache query plans and frequently accessed data pages automatically. You do not need to manage this directly, but you should be aware of it.
What you can control: materialized views for expensive aggregations.
CREATE MATERIALIZED VIEW popular_posts AS
SELECT post_id, COUNT(*) as views
FROM page_views
WHERE created_at > NOW() - INTERVAL '7 days'
GROUP BY post_id
ORDER BY views DESC
LIMIT 100;
REFRESH MATERIALIZED VIEW CONCURRENTLY popular_posts;
Refresh it on a cron job. The query that powers your "trending" section goes from 2 seconds to 5 milliseconds.
Cache invalidation
The hard part. My rules:
- Prefer TTL over explicit invalidation unless staleness is unacceptable
- Invalidate on write, not on read — the writer knows the data changed
- Use cache tags when one event should invalidate multiple keys
- Log cache hits and misses — a cache that never hits is wasted memory
The anti-pattern: caching everything
Caching adds complexity. Every cached value is a potential source of stale data. Only cache what is slow, expensive, or frequently accessed. If a query takes 5ms and runs 10 times per second, caching it saves 50ms per second. Not worth the complexity.
