Cache-aside is the default for a reason
Cache-aside (lazy loading) means the app reads Redis first, and on miss loads from the database, then writes the cache. I prefer it over write-through for most product APIs because the cache stays out of the write path and failed Redis does not block mutations.
I wrap this in a small helper so every endpoint does not reinvent JSON serialization and key naming. Keys look like `user:v1:42`—include a version prefix so schema changes are a deploy, not a silent poison.
Write-through and read-through layers have their place in platforms with a dedicated cache service team. For a typical Node monolith or modular service, cache-aside keeps behavior visible in application code where I can debug it.
I treat Redis as an optimization layer, not the source of truth, for almost every product entity. The exception is ephemeral state that would be wrong to persist—presence, locks, rate-limit counters. Mixing those roles in one mental model causes bad invalidation designs.
async function getUser(userId) {
const key = `user:v1:${userId}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const user = await db.users.findById(userId);
if (!user) {
await redis.set(key, "null", "EX", 30); // negative cache briefly
return null;
}
await redis.set(key, JSON.stringify(user), "EX", 300);
return user;
}
TTL is a product decision, not a constant
Session-ish data: minutes. Public product pages: 5–15 minutes with explicit invalidation on publish. Rarely changing config: hours, but still a TTL so a bug cannot live forever. I avoid “no TTL” keys unless there is a hard invalidation path and monitoring for memory.
A mistake I made: caching search results for 24 hours with no purge. Merchants updated inventory; customers saw ghost stock. We cut TTL to 60 seconds and invalidated on stock writes. Support tickets dropped the same day.
Another mistake: identical TTL for every key type. User profiles and homepage fragments do not share a lifecycle. Group keys by freshness requirements and document the matrix next to the Redis client module.
Clock skew between app servers does not matter for Redis TTL the way people fear, but logical time in soft-TTL payloads does. Store expiry timestamps from Redis TIME or from a single source if you implement soft TTL in application code.
- Short TTL for user-specific or frequently mutated data
- Longer TTL only with a clear invalidation story
- Negative-cache misses briefly to stop DB hammering on 404 bots
- Version key prefixes when response shapes change
Stampede prevention that is good enough
When a hot key expires, many processes miss at once and all hit the DB. That is a cache stampede. My pragmatic fix: single-flight locking with a short Redis lock, plus probabilistic early refresh on soft TTL.
The lock approach: on miss, SET lock key NX EX 5. Winner loads DB and fills cache. Losers wait briefly and retry Redis. It is not perfect under extreme load, but it stopped a thundering herd on a homepage fragment that used to spike Postgres CPU every five minutes.
Soft TTL means storing `{ value, expiresAt }` and refreshing early when a random chance fires near expiry. That spreads recompute load instead of aligning every process on the same second.
If the lock holder crashes before releasing, the EX on the lock saves you. Never use locks without a short expiry. I have seen indefinite locks freeze homepage rendering after a bad deploy.
async function getWithLock(key, ttlSec, loader) {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const lockKey = `lock:${key}`;
const gotLock = await redis.set(lockKey, "1", "EX", 5, "NX");
if (!gotLock) {
await new Promise((r) => setTimeout(r, 50));
const retry = await redis.get(key);
if (retry) return JSON.parse(retry);
return loader(); // last resort
}
try {
const value = await loader();
await redis.set(key, JSON.stringify(value), "EX", ttlSec);
return value;
} finally {
await redis.del(lockKey);
}
}
Invalidation without lying to yourself
Delete keys on write when you know them. For lists and fan-out views (`feed:user:9`), invalidation gets messy—prefer short TTLs over trying to delete every derived key. I once built an elaborate tag-based purge system; it was wrong more often than a 90-second TTL.
Never assume Redis is always up. Time out commands (e.g., 50–100ms) and fall through to the database. A hung Redis client with infinite waits takes down the whole API faster than a cold DB.
When you invalidate, delete the exact key you write. Pattern DELETEs in production (`KEYS user:*`) are how you freeze Redis on a large keyspace. Maintain explicit key lists or use Redis Cluster-friendly designs from day one.
Transactional outbox patterns help when writes must invalidate reliably. Publish “user.updated” and let a consumer delete keys. In-process deletes after write are fine for monoliths; they get racy in multi-service setups.
What I measure
Hit rate alone is vanity. I watch miss latency, DB QPS on hot endpoints, Redis memory, and eviction counts. A 99% hit rate with multi-second miss latency still fails users during expiry storms.
Also log cache key cardinality. Unbounded keys like `search:${rawQuery}` will fill Redis with one-off garbage. Hash or normalize queries, or do not cache them.
Alert on eviction spikes and on sustained miss latency, not only on Redis CPU. Memory pressure shows up as subtle correctness bugs when hot keys disappear under maxmemory policies.
Separate Redis logical DBs or key prefixes per environment. Pointing staging at production Redis “just for a read” is an incident waiting to happen. I namespace keys with env only as a last resort—separate instances are clearer.
Export Redis info metrics to the same dashboard as API latency. Correlating eviction spikes with p95 is how we found a cardinality bug in a week instead of a quarter.
Serialization and payload size
JSON is fine until values get large. I strip unused fields before caching API responses and avoid caching entire ORM graphs. A 200KB cached user object that grows with every relation is a tax on every hit.
If you need compression, measure first. For small objects, CPU cost can exceed network savings on localhost Redis. For multi-kilobyte HTML fragments, compression sometimes pays.
When hit rate falls after a deploy, check for accidental key version bumps and for serialization changes that make every read look like a miss after parse errors. Log parse failures explicitly.
Multi-layer caching without confusion
HTTP cache headers, CDN edge cache, and Redis application cache solve different hops. I label each layer in architecture notes so engineers do not “fix caching” by clearing the wrong place.
Browser caches and Redis can disagree after a publish. For user-specific HTML I avoid long CDN TTLs. For public product JSON, CDN plus short Redis TTL can both exist if invalidation paths are documented.
- Name the layer you are caching at before changing TTLs
- Do not clear Redis to fix a CDN problem
- Document who owns invalidation for each hot key family
Key takeaways
- Cache-aside keeps Redis off the critical write path
- Pick TTLs from data volatility, not habit
- Prevent stampedes with short locks or early refresh
- Negative-cache missing keys briefly
- Fail open to the DB if Redis is slow or down
- Prefer short TTL over complex fan-out invalidation when unsure
About the author
Ram — Founder & Editor, BudhiWorks. I build and ship production web apps — Node, React/Next.js, Postgres, and the boring infrastructure that keeps them online. BudhiWorks is where I publish the guides I wish I had when something broke at 2 a.m.