Caching Explained

Browser, CDN, Server & Database — the complete guide to caching at every layer

API DevelopmentJuly 3, 202620 min readBy Keyur Patel

Caching is the #1 performance lever you have. Not code splitting. Not lazy loading. Not switching to a faster framework. Caching. A single well-placed cache layer can turn a 2-second API call into a 5-millisecond response — and it works at every level of your stack.

The challenge is knowing where to cache, what strategy to use, and how to invalidate without serving stale data. This guide breaks down all four layers of caching, the strategies behind them, and the mistakes that trip up even experienced developers.

What Is Caching?

Caching is storing the result of an expensive operation so you can serve it again without repeating the work. The "expensive operation" could be a database query, an API call, a computation, or even rendering an entire page.

Think of it like a sticky note on your monitor. Instead of looking up your team's standup time every day, you check the sticky note. The information hasn't changed — so why look it up again?

Every caching system has the same core flow:

1. Request comes in
2. Check cache → HIT? Return cached result (fast path)
3. MISS? Execute the expensive operation
4. Store result in cache with TTL
5. Return result to caller

The key decisions are: what to cache, where to cache it, how long to keep it, and how to remove it when it's wrong. The rest of this guide covers exactly that.

The 4 Layers of Caching Explained

Caching isn't a single technique — it's a strategy applied at multiple levels. Each layer catches different types of redundant work, and they stack on top of each other.

1. Browser Cache

  • What it is: The browser stores assets (JS, CSS, images, fonts) and API responses locally on the user's device.
  • Why it matters: Eliminates network requests entirely. A cached asset loads in under 1ms from disk — no server contact needed.
  • How it works: Controlled by Cache-Control headers, ETag/Last-Modified for conditional requests, and service workers for offline-first strategies.
  • TTL range: Seconds to years. Static assets with hashed filenames get max-age=31536000 (1 year). HTML pages typically use no-cache with ETag validation.
  • Invalidation: Change the filename (content hash), update the ETag, or set no-store to bypass entirely.

2. CDN Cache

  • What it is: Content Delivery Networks cache your responses at edge servers distributed globally — physically close to your users.
  • Why it matters: Reduces latency from 200ms (cross-continent) to 20ms (nearby edge). Also shields your origin server from traffic spikes.
  • How it works: CDN reads Cache-Control and s-maxage headers. Supports stale-while-revalidate for instant responses while refreshing in the background.
  • TTL range: Typically 60 seconds to 24 hours for dynamic content, up to 1 year for static assets.
  • Invalidation: Purge by URL, purge by tag/surrogate-key, or wait for TTL expiry. Most CDNs (Cloudflare, Fastly, CloudFront) offer instant purge APIs.

3. Server / Application Cache

  • What it is: Your application stores computed results in memory (process-level) or in an external store like Redis/Memcached.
  • Why it matters: Avoids repeated database queries, API calls, or heavy computations. A Redis lookup takes ~0.5ms vs 50ms+ for a database query.
  • How it works: Application checks cache before executing logic. In-memory caches use LRU eviction. Redis provides shared cache across multiple server instances with built-in TTL.
  • TTL range: 1 second to 1 hour depending on data freshness requirements. User profiles might cache for 5 minutes; product catalogs for 1 hour.
  • Invalidation: Event-driven (update cache when data changes), TTL expiry, or versioned keys (increment version to invalidate all related entries).

4. Database Cache

  • What it is: Caching at the database level — either built-in query caches, materialized views, or an external Redis layer sitting in front of the database.
  • Why it matters: Complex queries with JOINs across millions of rows can take seconds. Caching the result avoids re-executing expensive query plans.
  • How it works: MySQL/PostgreSQL have internal query caches (though MySQL deprecated theirs). Materialized views precompute query results. Redis as read-aside stores serialized query results with TTL.
  • TTL range: Seconds to hours. Materialized views refresh on schedule (every 5 minutes). Redis read-aside typically uses 30s–5min TTL.
  • Invalidation: Refresh materialized views on write, delete Redis keys on data mutation, or use database triggers to clear related cache entries.

Comparison: All 4 Caching Layers

Here's how the layers stack up side by side:

LayerWhat It CachesTypical TTLInvalidationHit RatioBest For
BrowserAssets, API responses, pagesMinutes to 1 yearFilename hash, ETag, no-store90%+ for staticRepeat visits, static assets
CDNHTML, API responses, media60s to 24hPurge API, TTL, surrogate keys80-95% for popular contentGlobal latency, traffic spikes
Server/AppComputed results, API data1s to 1hEvent-driven, TTL, versioned keys70-90% depending on access patternsExpensive computations, shared data
DatabaseQuery results, aggregations30s to 1hRefresh on write, triggers, TTL60-85% for read-heavy workloadsComplex queries, reporting

Caching Strategies

Not all caches work the same way. The strategy determines who's responsible for populating and updating the cache. Here are the four main patterns:

Cache-Aside (Lazy Loading)

The application manages the cache directly. On read, it checks the cache first. On miss, it fetches from the database, stores the result in cache, then returns it. On write, the application invalidates (or updates) the cache entry.

// Cache-Aside pattern
async function getUser(userId) {
  // 1. Check cache
  const cached = await redis.get(`user:${userId}`)
  if (cached) return JSON.parse(cached)

  // 2. Cache miss — fetch from DB
  const user = await db.users.findById(userId)

  // 3. Populate cache with TTL
  await redis.setex(`user:${userId}`, 300, JSON.stringify(user))

  return user
}

Most common pattern. You control exactly what gets cached and when.

Read-Through

The cache sits between the application and the database. On miss, the cache itself fetches from the database — the application doesn't need to know about the data source. Libraries like cacheable or AWS DAX implement this pattern.

Benefit: cleaner application code. Downside: less control over what happens on a miss.

Write-Through

Every write goes to the cache first, then the cache writes to the database synchronously. The cache is always up-to-date, so reads never see stale data. The trade-off is higher write latency since both stores must confirm.

Good for read-heavy workloads where consistency matters (user sessions, account balances).

Write-Behind (Write-Back)

Writes go to the cache immediately, but the cache flushes to the database asynchronously in batches. This gives you very fast writes, but introduces a window where the database is behind the cache. If the cache crashes before flushing, you lose data.

Used for high-throughput writes where temporary inconsistency is acceptable (analytics, view counters, activity feeds).

Cache Invalidation Strategies

"There are only two hard things in Computer Science: cache invalidation and naming things." Phil Karlton wasn't joking. Here are the approaches that actually work:

TTL-Based Expiration

Set a time-to-live on every cache entry. After it expires, the next request fetches fresh data. Simple and predictable. The downside: data can be stale for up to the entire TTL duration.

// Redis TTL — expires after 5 minutes
await redis.setex('product:123', 300, JSON.stringify(product))

// HTTP Cache-Control — browser caches for 1 hour
res.setHeader('Cache-Control', 'public, max-age=3600')

Event-Driven Invalidation

When data changes, immediately delete or update the related cache entries. This gives you near-zero staleness but requires you to know exactly which cache keys are affected by each write operation.

// After updating a user
await db.users.update(userId, newData)
await redis.del(`user:${userId}`)        // Delete specific key
await redis.del('users:list:page:1')     // Invalidate related list

Versioned Keys

Include a version number in the cache key. When data changes, increment the version. Old entries naturally expire via TTL while new requests use the updated key.

// Version-based cache key
const version = await redis.get('products:version') // e.g., "7"
const cacheKey = `products:v${version}:page:${page}`

// On product update — increment version
await redis.incr('products:version')
// All old cache keys (v7) become orphaned and expire via TTL

Purge / Manual Invalidation

Explicitly purge specific URLs or cache entries through an API or admin interface. CDNs like Cloudflare and Fastly support instant purge by URL, tag, or entire zone. Useful for content publishing workflows where editors need changes live immediately.

Code Examples

Redis Caching in Node.js

A complete cache-aside implementation with error handling and TTL:

import Redis from 'ioredis'

const redis = new Redis(process.env.REDIS_URL)

async function cached(key, ttlSeconds, fetchFn) {
  try {
    const hit = await redis.get(key)
    if (hit) {
      console.log(`Cache HIT: ${key}`)
      return JSON.parse(hit)
    }
  } catch (err) {
    // Cache failure shouldn't break the app
    console.warn('Cache read error:', err.message)
  }

  // Cache miss — execute the expensive operation
  const result = await fetchFn()

  try {
    await redis.setex(key, ttlSeconds, JSON.stringify(result))
    console.log(`Cache SET: ${key} (TTL: ${ttlSeconds}s)`)
  } catch (err) {
    console.warn('Cache write error:', err.message)
  }

  return result
}

// Usage
const user = await cached(
  `user:${userId}`,
  300, // 5 minutes
  () => db.users.findById(userId)
)

HTTP Cache-Control Headers

Setting proper cache headers in Express/Next.js:

// Static assets — cache for 1 year (use content hash in filename)
app.use('/static', express.static('public', {
  maxAge: '1y',
  immutable: true
}))

// API response — private, short cache, revalidate in background
app.get('/api/products', (req, res) => {
  res.setHeader('Cache-Control', 'private, max-age=60, stale-while-revalidate=300')
  res.setHeader('ETag', computeETag(products))
  res.json(products)
})

// HTML pages — always revalidate, but use ETag for 304 responses
app.get('/dashboard', (req, res) => {
  res.setHeader('Cache-Control', 'no-cache') // must revalidate every time
  res.setHeader('ETag', `"${pageVersion}"`)
  res.render('dashboard')
})

// Sensitive data — never cache
app.get('/api/user/billing', (req, res) => {
  res.setHeader('Cache-Control', 'no-store')
  res.json(billingData)
})

React Query / TanStack Query — Stale Time

Client-side caching with automatic background refetching:

import { useQuery } from '@tanstack/react-query'

function ProductList() {
  const { data, isLoading } = useQuery({
    queryKey: ['products'],
    queryFn: () => fetch('/api/products').then(r => r.json()),
    staleTime: 5 * 60 * 1000,        // Consider fresh for 5 minutes
    gcTime: 30 * 60 * 1000,           // Keep in cache for 30 minutes
    refetchOnWindowFocus: true,        // Refetch when user returns to tab
    refetchOnReconnect: true,          // Refetch after network recovery
  })

  // Data is served instantly from cache on subsequent renders
  // Background refetch happens silently after staleTime expires
}

Common Caching Mistakes

These trip up developers at every level. Avoid them:

1. Over-Caching User-Specific Data

Caching personalized responses with a generic key means users see each other's data. Always include the user ID in cache keys for private data, and set Cache-Control: private on personalized HTTP responses.

2. Serving Stale Data Without Knowing

Long TTLs without invalidation means users see outdated prices, incorrect inventory, or old content. If data changes frequently, use short TTLs or event-driven invalidation — not "cache forever and hope."

3. Cache Stampede (Thundering Herd)

A popular cache entry expires and 500 requests simultaneously hit the database. Solutions: use a lock so only one request refills the cache, add jitter to TTLs, or serve stale content while revalidating in the background.

4. Not Warming the Cache

After a deployment or cache flush, every request is a cold miss. For critical paths, pre-warm the cache on startup by fetching frequently-accessed data before traffic hits the new instance.

5. Caching Error Responses

If your API returns a 500 error and you cache it, every subsequent request gets the cached error for the entire TTL. Always check the response status before caching — only cache successful results.

6. Ignoring Cache in Development

Developing with caching disabled means you never catch caching bugs until production. Test with cache enabled locally. Reproduce "user sees old data" bugs before they reach users.

Caching Best Practices

1.

Cache at the right layer. Static assets → CDN/browser. Computed results → Redis. Query results → database level. Don't cache everything in the same place.

2.

Always set a TTL. Cache entries without expiration live forever. Even if you have event-driven invalidation, a TTL acts as a safety net for edge cases.

3.

Use content hashes for static assets. Name files like app.a3b2c1.js so you can cache them for a year. When you deploy new code, the filename changes and the browser fetches the new version.

4.

Make cache failures non-fatal. If Redis is down, your app should still work — just slower. Wrap cache operations in try/catch and fall through to the source on failure.

5.

Monitor hit rates. A cache with 30% hit rate isn't helping much. Track hits, misses, and evictions. If hit rate is low, you're either caching the wrong things or your TTL is too short.

6.

Add jitter to TTLs. If 1000 keys all expire at the same second, you get a stampede. Add random jitter: TTL = baseTTL + random(0, 60) spreads expiration across time.

7.

Log cache operations in development. Add logging that shows HIT/MISS/SET/DEL for every cache operation during development. This makes it obvious when the cache isn't behaving as expected.

8.

Think about cold starts. New deployments, new instances, and Redis restarts all mean cold caches. Have a plan: pre-warming scripts, gradual traffic shifting, or stale-while-revalidate strategies.

Frequently Asked Questions

What is caching in web development?+
Caching is storing the result of an expensive operation so future requests for the same data can be served faster without recomputing or refetching. It exists at every layer — browser, CDN, server, and database — and is the single most impactful performance optimization for most applications.
What are the 4 layers of caching?+
The four layers are: (1) Browser cache — stores assets and API responses on the user's device, (2) CDN cache — stores content at edge servers close to users, (3) Server/application cache — stores computed results in memory or Redis, (4) Database cache — stores query results via query cache, materialized views, or Redis as a read-aside layer.
What is cache invalidation and why is it hard?+
Cache invalidation is the process of removing or updating stale cached data when the source data changes. It's hard because you need to know exactly which cached entries are affected by a change, and distributed systems make coordination difficult. Phil Karlton famously said there are only two hard things in CS: cache invalidation and naming things.
What is the difference between Cache-Aside and Read-Through?+
With Cache-Aside, the application code checks the cache first, and on a miss, fetches from the database and populates the cache itself. With Read-Through, the cache layer handles this automatically — on a miss, the cache fetches from the database and stores the result without the application needing to manage it.
What is stale-while-revalidate?+
Stale-while-revalidate is a caching strategy where the cache serves a stale (expired) response immediately while fetching a fresh copy in the background. The user gets instant responses while the cache stays fresh. It's used in HTTP Cache-Control headers and libraries like SWR and React Query.
What is a cache stampede?+
A cache stampede (or thundering herd) occurs when a popular cache entry expires and hundreds of concurrent requests all miss the cache simultaneously, overwhelming the database. Solutions include lock-based revalidation (only one request refills the cache), stale-while-revalidate, and early expiration with jitter.
When should I use Redis vs in-memory caching?+
Use in-memory caching (like a Map or LRU cache) for single-server apps with small datasets that can be lost on restart. Use Redis when you need shared cache across multiple servers, persistence across restarts, built-in TTL management, or when your cache dataset exceeds available process memory.
How do I set Cache-Control headers correctly?+
For static assets (JS, CSS, images), use Cache-Control: public, max-age=31536000, immutable with content hashing in filenames. For HTML pages, use no-cache or short max-age. For API responses, use private, max-age=60 or no-store for user-specific data. Always pair with ETag for conditional requests.

When NOT to Cache

Not everything benefits from caching. Some data should always come fresh from the source:

Real-time data

Stock prices, live sports scores, chat messages — data that changes every second loses value the instant it's cached. Use WebSockets or server-sent events instead.

Security-sensitive operations

Authentication tokens, permission checks, and access control decisions should never be cached at the application level. A stale permission cache means a revoked user still has access.

Write-heavy data with low read frequency

If data is written more often than it's read, caching adds complexity without benefit. You'll spend more time invalidating than serving from cache.

Large, unique datasets

If every request is for a unique piece of data (search queries, user-specific reports), cache hit rates will be near zero. You'll burn memory storing entries nobody reads twice.

Measuring Cache Effectiveness

A cache you can't measure is a cache you can't trust. Track these metrics:

MetricWhat It Tells YouTarget
Hit RatePercentage of requests served from cache> 80% for most workloads
Miss RatePercentage of requests that go to origin< 20% (inverse of hit rate)
Eviction CountEntries removed due to memory pressureLow — high means cache is undersized
Latency (p50/p99)Response time from cache vs originCache: < 5ms, Origin: 50-500ms
Stale Serve RateHow often stale data is servedDepends on tolerance — 0% for payments
Memory UsageHow much RAM your cache consumesStay under 80% of allocated memory

Redis provides these metrics via INFO stats (keyspace_hits, keyspace_misses). For CDNs, check your provider's analytics dashboard. For browser caching, use Chrome DevTools Network tab and look for "(disk cache)" or "(memory cache)" indicators.

Related Articles & Tools

Conclusion

Caching is not optional for production applications. It's the difference between a 2-second response and a 5ms response, between handling 100 requests/second and 10,000. But it's not magic — you need to choose the right layer, pick the right strategy, and have a plan for invalidation.

Start simple: add Cache-Control headers to your static assets and use Redis for your most expensive database queries. Measure hit rates, watch for stale data issues, and layer in more caching as you understand your access patterns. The fastest request is the one that never hits your server.