Layered Caching Strategies for Web Applications
Caching is the Swiss-army knife of performance optimization, but misuse produces stale data. This article covers the web's multi-layer caching — browser (HTTP caching / Service Worker), CDN, server (Redis / in-memory), and database (query cache) — what each layer caches, invalidation strategies, and how to maintain cache consistency.
Key Points
- Topic Focus: Next.js、React
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Caching is the cheapest and most dangerous lever in performance optimization. Cheap, because often a single line of Cache-Control can cut response time from 200ms to 5ms; dangerous, because once the invalidation strategy isn't designed well, what users see is the previous balance, stale inventory, or already-delisted products. I've seen too many teams treat caching as a silver bullet — Redis first without thinking, then TTL without thinking, and finally getting paged in the middle of the night in production to investigate "why the order status is wrong."
This article isn't about what caching is; it's about how to layer, how to choose, how to invalidate, and how not to crash under concurrency.
1. The Full Picture of Layered Caching
A typical web request, from the user's click to the data return, passes through several layers. Each layer can cache, but what it caches, the cost of invalidation, and the hit rate are completely different. First, the full picture:
User ── Browser(SW / HTTP)── CDN(edge)── Server(in-memory / Redis)── Database(query cache / MV)
↑ Cheapest, highest hit rate ↑ Most expensive, most authoritative
└──────── The closer to the user, the bigger the payoff, the harder the invalidation ────────┘
The core principle in one line: the closer a cache is to the user, the bigger the payoff on a hit, but the harder the invalidation and the worse the consistency. So when layering, always ask yourself first: can this layer skip caching? If it can, don't cache — caching is a liability, not an asset. Every extra cache layer adds another ledger of "stale-data debugging."
2. The Browser Layer: HTTP Caching and Service Workers
The browser is the most cost-effective cache layer — the request never leaves home, saving bandwidth, latency, and server pressure all at once.
HTTP strong caching relies on Cache-Control (Expires is obsolete, stop using it). Static resources with hashes (JS / CSS / images) go straight to immutable:
Cache-Control: public, max-age=31536000, immutable
immutable is an underrated field; it tells the browser "this thing will never change," saving even the conditional request (304 validation). Paired with filename hashing, when a new version ships and the hash changes, the old one invalidates naturally — a perfect closed loop.
Negotiated caching goes through ETag / If-None-Match: the server compares the hash, and returns 304 if they match. Suitable for scenarios like index.html that can't be strongly cached but where you don't want to fully transfer every time.
Service Worker is an "offline proxy" inside the browser that can intercept fetches. Suitable for PWA and offline scenarios. But note: the SW's caching strategy (CacheFirst / NetworkFirst / StaleWhileRevalidate) directly determines whether the user sees the new or the old version. For content sites, StaleWhileRevalidate is the most comfortable — return the stale version first, then update in the background.
// sw.js — APIs use StaleWhileRevalidate, static resources use CacheFirst
workbox.routing.registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new workbox.strategies.StaleWhileRevalidate()
);
3. The CDN Layer: Edge Caching
The CDN pushes caching to nodes hundreds of kilometers from the user. It caches two kinds of things: static resources (CSS / JS / images / fonts) and cacheable dynamic responses.
The latter is a commonly overlooked gold mine. If your /api/articles is public content that doesn't update often, you can totally use s-maxage=60, stale-while-revalidate=600 to let the CDN cache it for a minute — origin pressure drops by an order of magnitude.
Cache-Control: public, s-maxage=60, stale-while-revalidate=600
s-maxage only applies to CDNs / shared caches and doesn't affect the browser, so you can keep two strategies side by side: "browser strongly caches forever, CDN caches for one minute." The pitfall of CDN caching is invalidation: most CDNs support API-driven purges, but purges have propagation latency — don't count on them for strong consistency.
4. The Server Layer: In-Memory Cache and Redis
Once you're inside the server, that's the real main battlefield for caching.
In-process in-memory cache (node-cache, lru-cache): nanosecond access, zero network overhead. Suitable for single-process or computationally expensive pure-function results. Where's the pitfall? Under multi-instance deployment, each process caches its own, and both consistency and memory usage explode. So in-memory caching should only cache "doesn't matter if it's stale" stuff (like compiled templates, config snapshots, enum dictionaries).
Redis: cross-process, shareable, persistable — the default choice for distributed caching. It's suitable for caching:
- Computationally expensive: aggregate statistics (today's PV, trending lists), search results (secondary cache for ES queries)
- High-traffic: user profiles, product details, homepage recommendations
- Heavy DB pressure: list pages, counters
// Cache-Aside pattern: read-and-fill, invalidate on write
async function getUser(id: string) {
const key = `user:${id}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const user = await db.user.findUnique({ where: { id } });
await redis.set(key, JSON.stringify(user), 'EX', 300); // TTL 5 min fallback
return user;
}
Note that EX 300 is mandatory — the TTL here isn't a performance optimization, it's a consistency fallback. If active invalidation misses one spot, the data is stale for at most 5 minutes, not stale forever.
5. The Database Layer: Query Cache and Materialized Views
The DB layer cache is the last line. MySQL's query cache was long ago advised against by the official docs (removed outright in 8.0), because under write-heavy scenarios it's actually a negative optimization — every write invalidates the entire cache. PostgreSQL has no native query cache at all.
The real DB-layer caching is materialized views (Materialized View) and pre-aggregated tables: compute complex joins and window functions ahead of time, and read directly at query time. Particularly suitable for "minute-level latency acceptable" scenarios like reports, dashboards, and leaderboards.
CREATE MATERIALIZED VIEW daily_sales AS
SELECT date_trunc('day', created_at) AS day, SUM(amount) AS total
FROM orders GROUP BY 1;
-- Scheduled task: REFRESH MATERIALIZED VIEW CONCURRENTLY daily_sales;
Materialized-view invalidation is the simplest and crudest: refresh on a schedule, don't think about real time. The CONCURRENTLY keyword lets you refresh without locking the table, at the cost of requiring a unique index.
6. Invalidation Strategies: The Cache's Three Weapons
It's easy to write into a cache, hard to clear. The invalidation strategy decides whether you'll be woken up in the middle of the night.
- TTL (expiration time): passive invalidation, simple and crude, used as a fallback. The problem is the time window of stale data; it can only guarantee "eventual consistency."
- Active invalidation: clear as soon as data changes (
DEL/PURGE). Accurate, but requires the application layer to remember to clear on every write path — miss one and it's stale. A code refactor easily causes misses. - Version numbers: the cache key carries a version (
user:42:v7); when the data changes, the version increments, and the old key expires naturally. Suitable for strong-consistency scenarios (like config toggles). - Cache-Aside: on read, check the cache and fill from the DB on a miss; on write, update the DB and then delete the cache. The de facto industry standard.
The practical combo: Cache-Aside + TTL fallback + active invalidation on critical paths. All three are indispensable — with only TTL, you get too much stale data; with only active invalidation, once you miss one, it's stale forever.
7. Cache Consistency: Stale Data Under Concurrency
This is the part most likely to come up in interviews and most likely to crash in production. The naive approach: write the DB first, then delete the cache.
Write thread: UPDATE DB ──► DEL cache
Read thread: read cache(miss) ──► read DB ──► SET cache
Looks fine, but there's a classic concurrency trap:
- The read thread misses the cache and reads the stale value from the DB
- The write thread updates the DB and deletes the cache
- The read thread writes the stale value back to the cache
- The cache stays stale until the next TTL
The fix is delayed double deletion: after writing to the DB, delete once, then delay a few hundred milliseconds (covering the read thread's fill window) and delete a second time.
async function updateUser(id: string, data: UserInput) {
await db.user.update({ where: { id }, data });
await redis.del(`user:${id}`); // First deletion
await sleep(500); // Wait for the read thread's fill to complete
await redis.del(`user:${id}`); // Second deletion
}
How long to delay depends on your read path's P99. 500ms is a relatively stable rule of thumb; the cost is a slower write path, and a process crash loses the second deletion. For extreme scenarios, you can go with binlog subscription (Canal / Debezium + Kafka), making cache invalidation asynchronous, reliable, and lossless — the standard setup at big companies.
8. The Three Big Cache Pitfalls: Breakdown, Avalanche, and Penetration
This overlaps with the Redis article; here we only give the conclusions:
- Breakdown (a hot key gets hammered the instant it expires): add a mutex lock (distributed lock /
singleflight), letting only one request go back to the source while the others wait for the result. - Avalanche (a large number of keys expire at once): add random jitter to the TTL (
ttl + random(0, 60)), so they don't all drop together; or use multi-level caching (local + Redis). - Penetration (querying keys that don't exist at all, bypassing the cache and hitting the DB): cache null values (short TTL, e.g. 60s), or add a Bloom filter as a layer.
// Mutex lock example for breakdown
async function getWithLock(key: string, loader: () => Promise<any>) {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const lock = await redis.set(`lock:${key}`, '1', 'NX', 'EX', 5);
if (!lock) {
await sleep(50); // Didn't get the lock, wait a bit and read again
return getWithLock(key, loader);
}
try {
const val = await loader();
await redis.set(key, JSON.stringify(val), 'EX', 300);
return val;
} finally {
await redis.del(`lock:${key}`);
}
}
9. Don't Over-Cache
Finally, a counterintuitive point: very often you shouldn't cache.
- Strongly time-sensitive (balance, inventory, order status): if you can go to the DB, go to the DB; the cost of cache consistency far exceeds the benefit.
- Write-heavy, read-light: the cache is invalidated before it's even warm — pure waste of memory and CPU.
- Cheap one-off computations: don't introduce a whole invalidation stack just for "speed"; complexity is interest, and you'll pay it back sooner or later.
The criterion is simple: first get DB queries under 50ms, add reasonable indexes, and fix N+1 — then talk about caching. If you reach for Redis before the indexes are right, you're just hiding a bad problem, and the day the cache breaks down, the problem pays you back tenfold.
Summary
- Layered thinking: browser > CDN > server > database; the closer to the user, the bigger the payoff and the harder the invalidation.
- Think clearly about what each layer caches: static resources via HTTP / CDN, expensive computations via Redis, complex aggregates via materialized views.
- Invalidation is core: the
Cache-Aside + TTL fallback + active invalidationtrio is indispensable. - Consistency relies on delayed double deletion or binlog subscription; don't expect a single approach to handle concurrency.
- Breakdown / avalanche / penetration each has its fix, but more importantly: if you can avoid caching, avoid caching.
The punchline: caching isn't free — it trades consistency for performance, and the bill for that consistency comes due sooner or later.
Related
Related Content
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
How Should a Technical Community Design Its Tag System and Recommendation Feed?
Centered on independent development, Next.js technology trends, engineering practices, and the indie developer perspective.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.