Caching Strategies Explained: CDN, Redis & DB Cache
A practical guide to caching strategies: browser cache, CDN, in-process memory, and Redis. Learn which layer to use, cache-aside patterns, and invalidation.
On this page
Ask a developer to speed up a slow app and the first word out of their mouth is usually "Redis". That instinct isn't wrong, but it's incomplete. Redis solves one specific problem (shared, fast key-value lookups across multiple servers), and a huge number of performance issues never need to touch it at all.
Here's the framing that actually helps: there isn't one cache, there are four. A request from a browser passes through the browser's own cache, then a CDN, then your application's in-memory or Redis layer, and finally the database's own cache, before any of your code runs. Even your CI pipeline caches things: the GitHub Actions tutorial covers caching node_modules between runs, which is really just an in-process cache for your build environment. Picking the right layer for the right data is most of the job.
The Four Caching Layers
Think of a single page load as a request travelling through four checkpoints. Each one can either answer the request immediately (a cache hit) or pass it along to the next layer (a cache miss).
The browser checks its own local cache first. If that misses, the request hits a CDN edge node. If the CDN doesn't have it, the request reaches your application server, which might have the answer in memory or in Redis. Only if every layer misses does the request hit your database, which has its own internal caching for query plans and frequently accessed pages.
The closer a cache sits to the user, the cheaper and faster a hit is, but the harder it is to keep accurate. That tradeoff drives almost every decision in this guide.
| Layer | Where it lives | What it's for |
|---|---|---|
| Browser cache | User's device | Static assets, fonts, images: anything that doesn't change per request |
| CDN | Edge servers near the user | Static files and public, non-personalized responses served globally |
| Application cache (in-memory or Redis) | Your server process or a separate cache server | Computed values, session data, frequently read database rows |
| Database cache | Inside the database engine | Query plans, buffer pools, and recently accessed pages, managed automatically |
Browser Caching
Browser caching is the cheapest cache you'll ever get: zero infrastructure, zero network round trip, and it's controlled entirely by one HTTP header. Cache-Control tells the browser how long it can reuse a response without checking back with the server.
For assets with a content hash in the filename (like app.a3f9c2.js), you can cache forever. The filename only changes when the content changes, so there's no staleness risk. The immutable directive tells the browser not to even bother revalidating during the max-age window.
Cache-Control: max-age=31536000, immutableFor content that changes per request but where you still want to avoid re-downloading unchanged bytes, no-cache is the right call. It doesn't mean "don't cache": it means "cache it, but revalidate with the server every time using an ETag or Last-Modified header before using it".
no-store is the actual "don't cache anything, anywhere" directive. Use it for responses containing sensitive data, like authentication tokens or account details, that should never persist on disk.
Cache-Control: no-cacheCDN Caching
A CDN sits between your users and your origin server, caching responses at edge locations close to wherever the request comes from. The classic use case is static assets: JS bundles, CSS, images, fonts. These are identical for every visitor, so caching them at the edge is a pure win.
CDNs also cache full HTML pages and public API responses, as long as the response is the same for every user. A marketing homepage, a blog post, a product listing page: all good CDN candidates. The moment a response includes a user's name, their cart contents, or anything from a session cookie, it stops being a candidate for shared CDN caching.
What CDNs don't handle well is authenticated, per-user, or real-time data. Caching /api/me at the edge means the next user who hits that edge node could get someone else's profile back. That's not a hypothetical: misconfigured CDN rules that cache personalized responses are a recurring source of real data leaks.
Invalidation is the other half of CDN caching. When you deploy a new version of a static asset with the same filename (no content hash), every edge node serving the old cached copy needs to be told to drop it.
Most CDNs offer a purge API for exactly this. If your build process produces hashed filenames, you mostly avoid this problem: new content gets a new URL, and the old cached URL simply stops being requested.
In-Process / Application Cache
An in-process cache lives inside your application's memory. It's the fastest possible cache, since there's no network call at all, just a lookup in a Map or a dedicated LRU (least recently used) data structure.
The classic pattern here is cache-aside: check the cache first, and if it's empty, fetch from the source of truth (usually a database) and store the result for next time.
import { LRUCache } from "lru-cache";
const userCache = new LRUCache({
max: 5000, // store at most 5,000 users
ttl: 1000 * 60 * 5, // 5 minutes
});
async function getUserById(userId) {
const cached = userCache.get(userId);
if (cached) return cached;
const user = await db.query("SELECT * FROM users WHERE id = ?", [userId]);
userCache.set(userId, user);
return user;
}This works great as long as you have one server instance, or you don't mind each instance having a slightly different cache. The moment you scale horizontally, in-process caches stop being reliable.
Each instance has its own copy. If a user's data changes and one instance updates its cache, the other three instances are still serving stale data until their entries expire. Worse, if a user's requests get load-balanced across different instances, they might see different, inconsistent results from one request to the next. This is exactly the scaling problem that pushes teams toward a shared cache like Redis.
If you're newer to Node.js memory management and event loop behavior, which directly affect how large an in-process cache can safely grow, our Node.js interview questions guide covers those fundamentals in more depth.
Redis and Distributed Caching
Redis (or any distributed cache) earns its place when you need state shared across multiple application instances. Three cases come up constantly: user sessions, rate limiting counters, and expensive computed results that should only be calculated once, no matter which server handles the request.
There are three common patterns for keeping a distributed cache in sync with your database, and they trade off differently.
Cache-aside is the same pattern from the in-process example, just with Redis instead of an in-memory Map. The application is responsible for both reading from the cache and writing back to it on a miss.
import json
import redis
r = redis.Redis(host="localhost", port=6379)
def get_user(user_id):
cache_key = f"user:{user_id}"
cached = r.get(cache_key)
if cached:
return json.loads(cached)
user = db.query("SELECT * FROM users WHERE id = %s", (user_id,))
r.setex(cache_key, 300, json.dumps(user)) # expire after 5 minutes
return userWrite-through flips the write path: every time the application writes data, it writes to the cache and the database in the same operation, before returning. Reads are always cache hits after the first write, but every write pays the latency of updating both stores.
Write-behind (sometimes called write-back) writes to the cache immediately and queues the database write to happen asynchronously, later. This makes writes feel instant, but introduces a window where the cache and database disagree, and a crash before the queued write completes can lose data.
- Cache-aside - simplest to implement, cache can go stale between writes and the next read, most common pattern for read-heavy data
- Write-through - cache and database stay consistent, writes are slower since both stores update together
- Write-behind - fastest writes, risk of data loss or temporary inconsistency if the async write fails
Before you provision a Redis instance, it helps to know roughly how much memory your workload actually needs. Redis keeps everything in RAM, so under-provisioning leads to evictions and cache misses, while over-provisioning wastes money every month. If you're estimating session storage, rate-limit counters, or cached query results, use this calculator to estimate how much memory you'll need based on your key count and average value size.
Cache Invalidation
There's an old joke in computer science: there are only two hard problems, cache invalidation and naming things (and off-by-one errors). Invalidation is hard because a cache is, by definition, a copy of data that lives somewhere else, and copies drift out of sync.
Three strategies cover most real-world cases, and they're often used together rather than as alternatives.
- TTL expiry - every cached entry gets a time-to-live. After it expires, the next read is a cache miss and refetches fresh data. Simple, self-healing, but means stale data can be served for up to the TTL window.
- Event-driven invalidation - when the underlying data changes, the write path actively deletes or updates the corresponding cache entry. More accurate than TTL alone, but requires every write path to remember to invalidate the right keys.
- Versioned keys - instead of invalidating, change the cache key itself (
user:42:v3instead ofuser:42) whenever the underlying schema or data shape changes. Old entries simply age out via TTL while new reads use the new key, avoiding the need to hunt down and delete every stale entry.
In practice, most production systems combine TTL as a safety net with event-driven invalidation for the writes that matter most. A short TTL bounds how stale data can ever get, even if an invalidation path is missed somewhere.
A Decision Framework for Choosing a Cache
With all four layers and three invalidation strategies on the table, here's how they map onto common types of data you'll actually be caching.
| Data type | Cache layer | Pattern | Invalidation |
|---|---|---|---|
| JS/CSS bundles | Browser + CDN | Cache forever with hashed filenames | New filename on every deploy (no invalidation needed) |
| Public HTML pages | CDN | Cache full response | Purge on publish, or short TTL |
| User session | Redis | Cache-aside or write-through | TTL matching session length |
| User profile | In-process or Redis | Cache-aside | Event-driven on profile update, short TTL fallback |
| Database query results | Redis | Cache-aside | TTL for read-heavy, rarely-changing aggregates |
| Rate limit counters | Redis | Write-through (atomic increment) | TTL matching the rate limit window |
| Images and fonts | Browser + CDN | Cache forever, immutable | New URL on content change |
Notice that the database row in this table is mostly empty: caching reduces database load, but it doesn't fix a database that's slow because of bad indexes or unnecessary queries in the first place. If query performance is still a bottleneck after adding a cache layer, that's a sign to look at the queries themselves. The Drizzle ORM guide covers schema and migration practices that keep queries fast as your data grows.
And if Redis is part of your stack, sizing it correctly matters: an undersized instance evicts data constantly and defeats the purpose of caching. RedisCalc can help you work out a realistic memory budget before you provision anything.
Frequently Asked Questions
What is the cache-aside pattern?
Cache-aside (also called lazy loading) is a pattern where the application checks the cache before going to the source of truth. On a cache miss, it fetches the data from the database, stores a copy in the cache, and returns it.
- Check the cache - look up the key in Redis or in-memory storage first.
- On a hit, return immediately - no database call needed.
- On a miss, fetch from the database - then write the result into the cache before returning it.
- Subsequent reads - hit the cache until the TTL expires or the entry is invalidated.
When should I use Redis vs a CDN?
| Redis | CDN | |
|---|---|---|
| Latency | Sub-millisecond, but requires a network call from your app | Near-zero from the edge node closest to the user |
| Scope | Shared across your application servers | Shared across every user globally |
| Best for | Per-user or per-session data: sessions, rate limits, computed results | Data identical for everyone: static assets, public pages, public API responses |
| Cost driver | Memory size of the instance, billed continuously | Bandwidth and request volume at the edge |
Use a CDN whenever the response would be identical regardless of who's asking. Reach for Redis when the data varies per user, per session, or needs to be shared and updated quickly across multiple application servers.
What is the difference between write-through and write-behind caching?
Both patterns update the cache on a write, but they differ in when the database gets updated.
- Write-through - the cache and database are updated together, in the same request. Reads are always consistent, but every write is slower because it waits on both stores.
- Write-behind - the cache is updated immediately and the database write is queued for later, asynchronously. Writes feel instant, but there's a brief window of inconsistency, and a crash before the queued write runs can lose that update.
How does browser caching work?
When a browser receives a response, it checks the Cache-Control header to decide whether and how long it can reuse that response without making another request.
- Fresh response - if
max-agehasn't expired, the browser uses the cached copy directly, with no network request at all. - Stale response with a validator - if the cache entry is past
max-agebut has anETagorLast-Modifiedheader, the browser sends a conditional request asking "has this changed?" - Server responds 304 Not Modified - if nothing changed, the server returns an empty 304 response and the browser reuses its cached copy, saving the download.
- Server responds 200 with new content - if the resource changed, the browser downloads the new version and updates its cache.
What is cache invalidation and why is it hard?
Cache invalidation is the process of removing or updating cached data so it doesn't go stale after the underlying source changes. It's considered one of the hardest problems in computer science because a cache is, by design, a duplicate of data living somewhere else.
Keeping every copy in sync, across every layer (browser, CDN, application, Redis), without missing an edge case or invalidating too aggressively (which defeats the point of caching), is genuinely difficult at scale. Most teams manage it with a combination of TTL expiry as a safety net, event-driven invalidation for critical writes, and versioned cache keys when data shapes change.
Related Articles
GitHub Actions Tutorial: CI/CD from Push to Deploy (2026)
Learn GitHub Actions: write your first workflow, run tests automatically, use secrets safely, deploy via SSH, cache dependencies, and run matrix builds.
Drizzle ORM Migrations: A Practical drizzle-kit Guide
Learn the full Drizzle ORM migration workflow: push vs migrate, drizzle-kit setup, Turso/libSQL config, team conflicts, and production best practices.
30 Node.js Interview Questions and Answers (2026)
30 Node.js interview questions with full answers: event loop, streams, clustering, worker threads, memory leaks, and security. Updated for 2026.