mdashikjs/blog
All posts
Caching at Every Layer: Browser, CDN, API, and Database
System Design

Caching at Every Layer: Browser, CDN, API, and Database

System Design5 min

Caching at Every Layer: Browser, CDN, API, and Database

Caching is not a single decision. It is a stack of decisions at every layer, each with different tradeoffs. Here is how I think about caching from the browser all the way down to the database.

CachingRedisCDNPerformance
Share:

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:

  1. Prefer TTL over explicit invalidation unless staleness is unacceptable
  2. Invalidate on write, not on read — the writer knows the data changed
  3. Use cache tags when one event should invalidate multiple keys
  4. 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.

MA

Written by Md Ashik

Senior Software Engineer building reliable backends. I write about the practical tradeoffs behind shipping software that holds up in production.