Redis Caching Patterns in Node.js: Cache-Aside, Write-Through, Refresh-Ahead and Stampedes
Key takeaways
Redis caching turns repeated database round trips into sub-millisecond lookups, but it also gives you two copies of your data that can disagree. This guide covers cache-aside, write-through, write-behind and refresh-ahead, stampede prevention, pub/sub invalidation, eviction policy and failure handling, with Node.js code for each.
Why Cache with Redis?
Most backend services don’t have a CPU problem — they have an I/O problem, and the slowest I/O in a typical request path is almost always the database round trip. A single Postgres query with a join and an index scan might cost 20-80ms under normal load, and that number gets worse, not better, as concurrent traffic rises, because every connection competes for the same buffer pool, the same disk I/O queue, and (eventually) the same connection pool slots. Redis sidesteps that entirely: it’s an in-memory key-value store with sub-millisecond read latency, so moving a read-heavy, rarely-changing query result into Redis turns a 50-200ms database round trip into a ~1ms in-memory lookup for every request after the first.
Without caching:
API request → Database query (50-200ms) → Response
1000 req/s → 1000 DB queries/s → DB overloaded
With Redis caching:
API request → Redis hit (< 1ms) → Response
API request → Redis miss → DB query → Store in Redis → Response
1000 req/s → ~950 cache hits (< 1ms) + ~50 DB queries → DB happy
The catch — and the reason this article spends as much time on failure modes as on code — is that caching doesn’t remove complexity, it relocates it. You trade a slow-but-simple system (one source of truth, the database) for a fast-but-dual system (two copies of the data that can disagree). Every pattern below is really a different answer to the same question: how stale is a stale read allowed to be, and what happens when many clients discover staleness at the same instant?
Setup
npm install ioredis
# or
npm install redis
// lib/redis.ts — connection with reconnect
import Redis from 'ioredis'
const redis = new Redis({
host: process.env.REDIS_HOST ?? 'localhost',
port: parseInt(process.env.REDIS_PORT ?? '6379'),
password: process.env.REDIS_PASSWORD,
db: 0,
retryStrategy: (times) => Math.min(times * 100, 3000),
maxRetriesPerRequest: 3,
enableOfflineQueue: false,
})
redis.on('connect', () => console.log('Redis connected'))
redis.on('error', (err) => console.error('Redis error:', err))
export default redis
Cache-Aside (Lazy Loading)
The most common pattern — check cache, then database:
import redis from './lib/redis'
import { db } from './lib/db'
// Generic cache-aside helper
async function withCache<T>(
key: string,
ttlSeconds: number,
fetchFn: () => Promise<T>
): Promise<T> {
// 1. Check cache
const cached = await redis.get(key)
if (cached) {
return JSON.parse(cached) as T
}
// 2. Cache miss — fetch from source
const data = await fetchFn()
// 3. Store in cache
await redis.setex(key, ttlSeconds, JSON.stringify(data))
return data
}
// Usage
async function getUser(userId: string) {
return withCache(
`user:${userId}`,
300, // 5 minutes
() => db.users.findUnique({ where: { id: userId } })
)
}
async function getProductList(category: string, page: number) {
return withCache(
`products:${category}:page:${page}`,
60, // 1 minute (changes more often)
() => db.products.findMany({
where: { category },
skip: (page - 1) * 20,
take: 20,
})
)
}
// In Express
app.get('/api/users/:id', async (req, res) => {
const user = await getUser(req.params.id)
if (!user) return res.status(404).json({ error: 'Not found' })
res.json(user)
})
Cache-aside is popular because it’s the least invasive pattern to retrofit onto an existing codebase — you wrap an existing database call, and every consumer of getUser() gets the benefit without touching the caller. But look closely at withCache, and there’s a race condition baked into the three numbered steps in the comment. The redis.get(key) check and the redis.setex(key, ...) write are two separate round trips, not one atomic operation. Under concurrent load, the window between them is where bugs live: N requests can all observe a miss at step 1, all independently run fetchFn(), and all issue their own setex at step 3. Functionally, Redis ends up in a consistent state — whichever write lands last simply overwrites the others — but you paid for N database queries when one would have been enough. That’s usually a performance annoyance rather than a correctness bug when fetchFn is a pure read. It becomes a correctness bug the moment fetchFn has any dependency on request-scoped state (a paginated cursor, a request-specific filter smuggled into a shared cache key), because now the last writer decides what every other concurrent reader — including ones who asked for something slightly different — receives from cache. Section 3 covers the fix.
When Redis itself is down
withCache as written also treats Redis as a hard dependency: if redis.get rejects (Redis restarting, a network blip, or enableOfflineQueue: false rejecting commands while the client reconnects), the whole request fails, even though the database that holds the real data is perfectly healthy. For most read caches, the right policy is to fail open — log the error, skip the cache, and serve from the database:
async function withCacheFailOpen<T>(
key: string,
ttlSeconds: number,
fetchFn: () => Promise<T>
): Promise<T> {
try {
const cached = await redis.get(key)
if (cached) return JSON.parse(cached) as T
} catch (err) {
console.warn(`cache read failed for ${key}, falling back to source`, err)
}
const data = await fetchFn()
// Fire-and-forget: a failed cache write must not fail the request
redis.setex(key, ttlSeconds, JSON.stringify(data)).catch((err) =>
console.warn(`cache write failed for ${key}`, err)
)
return data
}
Failing open has its own risk, and it’s the reason to write the policy down instead of letting it happen by accident: when Redis goes away, every request that used to be a cache hit lands on the database at once. If the cache was absorbing most of the read traffic, the database may not survive that. Services that depend heavily on the cache usually combine fail-open with a concurrency limit or circuit breaker around fetchFn, or deliberately fail closed (return 503) on the most expensive endpoints. Either choice is defensible. Finding out which one you made during the first Redis outage is not.
Write-Through Caching
Update cache on every write — cache is always fresh, at least on the instance that performed the write:
class UserRepository {
private cacheKey(id: string) { return `user:${id}` }
private TTL = 600 // 10 minutes
async findById(id: string) {
const cached = await redis.get(this.cacheKey(id))
if (cached) return JSON.parse(cached)
const user = await db.users.findUnique({ where: { id } })
if (user) await redis.setex(this.cacheKey(id), this.TTL, JSON.stringify(user))
return user
}
async update(id: string, data: Partial<User>) {
// Update database
const user = await db.users.update({ where: { id }, data })
// Update cache immediately (write-through)
await redis.setex(this.cacheKey(id), this.TTL, JSON.stringify(user))
return user
}
async delete(id: string) {
await db.users.delete({ where: { id } })
// Invalidate cache
await redis.del(this.cacheKey(id))
}
}
Notice that delete() removes the key while update() overwrites it. For writes, deleting is often the safer default even outside write-through: if two requests update the same user concurrently, their database writes and their cache writes can land in different orders, and the cache ends up holding the older value with a full TTL ahead of it. A DEL has no ordering problem — the next read simply repopulates from the database. The cost is one guaranteed cache miss after each write, which is the right trade for anything written more often than a few times per TTL. Overwriting pays off for read-hot keys that are written rarely and by one writer at a time.
Write-through removes the staleness window for the request that performed the write — call update() and the very next findById() call, even on the same instance, returns fresh data because the cache was overwritten synchronously as part of the mutation. What it does not fix is staleness across a fleet of Node.js instances behind a load balancer. If instance A handles the update() call, instances B and C are still holding whatever they cached before A’s write happened. They haven’t received A’s update — they simply haven’t hit their own TTL yet, and from the outside, a stale read from instance B looks identical to a stale read from a plain TTL-only cache-aside setup. Pattern 6 (pub/sub invalidation) exists specifically to close that fleet-wide gap, and it has its own limits worth understanding before you rely on it — see the note in that section.
Cache Invalidation Is the Hard Problem
Phil Karlton’s line — “there are only two hard things in computer science: cache invalidation and naming things” — gets quoted often enough to be a cliché, but it survives because it’s still accurate. A TTL is invalidation you don’t have to think about, which also means you’re deliberately choosing to serve wrong data for up to ttlSeconds after every write, on every instance that hasn’t refreshed yet. Whether that’s acceptable depends entirely on what the field means to the business: a 60-second-stale product price is a customer service ticket; a 60-second-stale “unread message count” badge is invisible.
Write-through, as above, narrows the staleness window for the writer but not the fleet. Write-behind — queueing writes in memory or in a Redis list and flushing them to the database asynchronously — inverts the risk instead of removing it: reads are always fresh from cache, but now the database is the stale copy, and if the process crashes before a queued write flushes, that write is gone permanently, not just delayed. This article doesn’t implement write-behind because for most CRUD backends the durability risk isn’t worth the throughput gain. It’s the right trade for high-frequency, loss-tolerant counters — view counts, like counts, impression counters — where losing the last few increments to a crash is acceptable in exchange for not hitting the database on every single increment. It’s the wrong trade for anything a customer would notice going missing, like an order or a payment record.
The practical takeaway: pick your staleness budget per field, not per system. A single Redis instance and a single Node.js codebase can reasonably use TTL-only caching for a “trending posts” widget, write-through for user profile edits, and no caching at all for account balance — treating “our caching strategy” as one uniform policy across every field is how teams end up either serving stale prices or caching nothing and eating the database load anyway.
Cache Stampede Prevention
When a hot key expires, every request that arrives before the cache is repopulated observes a miss independently, and every one of them will, left unchecked, kick off its own trip to the database for what is logically a single piece of work:
sequenceDiagram
participant C1 as Client 1
participant C2 as Client 2
participant C3 as Client 3
participant R as Redis
participant D as Database
Note over R: "trending" key just expired
C1->>R: GET trending
R-->>C1: miss
C2->>R: GET trending
R-->>C2: miss
C3->>R: GET trending
R-->>C3: miss
C1->>D: expensive aggregation query
C2->>D: expensive aggregation query
C3->>D: expensive aggregation query
Note over D: 3x load for what should be 1 logical read
I’ve chased this exact failure in production. A “trending posts” key with a 60-second TTL sat behind a moderately busy read path, and it was fine until that key expired during a traffic spike from a scheduled newsletter send. Every request that landed within roughly the same 40ms window saw a cache miss, and each one independently triggered the same expensive aggregation query against Postgres. The connection pool — sized for the previous steady-state query volume — saturated within a couple of seconds, and every other, completely unrelated endpoint sharing that pool started timing out too. The dashboards looked exactly like a database outage; the actual root cause was one hot key expiring at an unlucky moment. The fix wasn’t a bigger connection pool — it was making sure only one request repopulates a given key at a time, and staggering that key’s TTL with jitter so it wouldn’t reliably expire right when the newsletter’s traffic spike hit.
// Problem: 100 requests all miss cache at the same time → 100 DB queries
// Solution: Probabilistic early expiration + mutex lock
import Redlock from 'redlock'
const redlock = new Redlock([redis], {
retryCount: 5,
retryDelay: 100,
})
async function getWithLock<T>(
key: string,
ttlSeconds: number,
fetchFn: () => Promise<T>
): Promise<T> {
const cached = await redis.get(key)
if (cached) return JSON.parse(cached)
// Acquire lock — only one process fetches at a time
const lock = await redlock.acquire([`lock:${key}`], 5000)
try {
// Double-check after acquiring lock
const cached2 = await redis.get(key)
if (cached2) return JSON.parse(cached2)
const data = await fetchFn()
await redis.setex(key, ttlSeconds, JSON.stringify(data))
return data
} finally {
await lock.release()
}
}
The “double-check after acquiring lock” line is not defensive boilerplate — it’s the whole point. Without it, every request that queued up waiting for the lock would still run its own fetchFn() in sequence the moment the lock became available, one after another, which just serializes the stampede instead of eliminating it. With the re-check, only the first request to win the lock actually queries the database; everyone else who was waiting finds the key already populated and returns immediately.
One nuance worth flagging: Redlock is designed to provide correctness guarantees across multiple independent Redis masters that can fail independently — that’s the whole reason its algorithm requires acquiring a majority of nodes. Using it against a single Redis instance, as the snippet above does, is not wrong, but it’s also not buying you anything beyond a simple SET lock:key value NX PX 5000 — a single atomic command that acquires a lock only if it doesn’t already exist, with an automatic expiry so a crashed holder doesn’t lock the key forever. Pulling in the redlock package for a single-instance topology adds a dependency and a more complex retry/timing model without the safety property it exists to provide. Reach for full Redlock when you’re actually running a Redis Sentinel or Cluster setup with independent masters; reach for a plain SET NX PX otherwise. Also budget for the jitter mentioned above: if many keys share the same TTL and were all set at the same time (a common outcome of a cache-warming job, see Pattern 8), they expire together too, and a lock only prevents duplicate work on one key at a time — it doesn’t prevent ten different hot keys from expiring simultaneously and each independently stampeding. Adding ±10-20% random jitter to your TTL (ttlSeconds + Math.floor(Math.random() * ttlSeconds * 0.2)) spreads expirations out so they don’t all land in the same 40ms window in the first place.
Refresh-ahead: never letting the hot key expire
A lock limits the damage when a hot key expires. Refresh-ahead avoids the expiry: the value is rebuilt in the background before it goes stale, so readers of a hot key never see a miss at all. The easiest version in Redis stores a “soft” expiry inside the value and keeps a longer “hard” TTL on the key:
interface Envelope<T> { value: T; softExpiresAt: number }
async function getRefreshAhead<T>(
key: string,
softTtlSeconds: number, // when to start refreshing
hardTtlSeconds: number, // when Redis actually drops the key
fetchFn: () => Promise<T>
): Promise<T> {
const raw = await redis.get(key)
if (raw) {
const env = JSON.parse(raw) as Envelope<T>
if (Date.now() >= env.softExpiresAt) {
// Stale-but-usable: serve it now, let exactly one caller refresh in the background
const gotLock = await redis.set(`refresh:${key}`, '1', 'EX', 30, 'NX')
if (gotLock) {
void refresh(key, softTtlSeconds, hardTtlSeconds, fetchFn)
.catch((err) => console.warn(`refresh-ahead failed for ${key}`, err))
.finally(() => redis.del(`refresh:${key}`))
}
}
return env.value
}
// True miss (cold start or hard expiry): load synchronously
return refresh(key, softTtlSeconds, hardTtlSeconds, fetchFn)
}
async function refresh<T>(
key: string, softTtl: number, hardTtl: number, fetchFn: () => Promise<T>
): Promise<T> {
const value = await fetchFn()
const env: Envelope<T> = { value, softExpiresAt: Date.now() + softTtl * 1000 }
await redis.setex(key, hardTtl, JSON.stringify(env))
return value
}
// "trending" is refreshed after 60s but survives up to 10 minutes if the refresh keeps failing
const trending = await getRefreshAhead('trending', 60, 600, computeTrending)
This is the “stale-while-revalidate” idea from HTTP caching applied to Redis. The gap between the soft and hard TTL is your staleness budget when things go wrong: if the database is slow or down, readers keep getting the last good value for up to hardTtlSeconds instead of all piling onto a failing query. The costs are real, though. Every key carries a small envelope. Keys nobody reads after their soft expiry still sit in memory until the hard TTL. And the pattern only pays off for keys that are read continuously — a key read once an hour just gets refreshed for nothing, or hits the hard TTL and behaves like plain cache-aside. Use it for the handful of expensive, hot keys (home page aggregates, trending lists, config blobs), not as the default.
Session Storage
npm install express-session connect-redis
import session from 'express-session'
import RedisStore from 'connect-redis'
app.use(session({
store: new RedisStore({ client: redis }),
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
sameSite: 'lax',
},
name: 'sid',
}))
// Use session
app.post('/auth/login', async (req, res) => {
const user = await authenticateUser(req.body.email, req.body.password)
if (!user) return res.status(401).json({ error: 'Invalid credentials' })
req.session.userId = user.id
req.session.role = user.role
res.json({ success: true })
})
app.get('/api/me', (req, res) => {
if (!req.session.userId) return res.status(401).json({ error: 'Unauthorized' })
res.json({ userId: req.session.userId, role: req.session.role })
})
app.post('/auth/logout', (req, res) => {
req.session.destroy((err) => {
if (err) return res.status(500).json({ error: 'Could not log out' })
res.clearCookie('sid')
res.json({ success: true })
})
})
Rate Limiting with Redis
// Sliding window rate limiter
async function rateLimit(
key: string,
limit: number,
windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
const now = Date.now()
const windowStart = now - windowSeconds * 1000
const redisKey = `ratelimit:${key}`
const pipeline = redis.pipeline()
pipeline.zremrangebyscore(redisKey, 0, windowStart)
pipeline.zadd(redisKey, now, `${now}-${Math.random()}`)
pipeline.zcard(redisKey)
pipeline.expire(redisKey, windowSeconds)
const results = await pipeline.exec()
const count = results![2][1] as number
const resetAt = Math.floor((now + windowSeconds * 1000) / 1000)
return {
allowed: count <= limit,
remaining: Math.max(0, limit - count),
resetAt,
}
}
// Express middleware
function rateLimitMiddleware(limit: number, windowSeconds: number) {
return async (req: Request, res: Response, next: NextFunction) => {
const key = req.user?.id ?? req.ip
const { allowed, remaining, resetAt } = await rateLimit(key, limit, windowSeconds)
res.setHeader('X-RateLimit-Limit', limit)
res.setHeader('X-RateLimit-Remaining', remaining)
res.setHeader('X-RateLimit-Reset', resetAt)
if (!allowed) {
return res.status(429).json({
error: 'Too many requests',
retryAfter: resetAt - Math.floor(Date.now() / 1000),
})
}
next()
}
}
app.use('/api/', rateLimitMiddleware(100, 60)) // 100 req/min
app.post('/auth/login', rateLimitMiddleware(5, 900)) // 5 attempts per 15 min
Pub/Sub for Cache Invalidation
When you have multiple Node.js instances, invalidate cache across all servers:
// publisher.ts — runs on server that modifies data
const pub = new Redis()
async function updateUser(userId: string, data: Partial<User>) {
const user = await db.users.update({ where: { id: userId }, data })
// Publish invalidation event to all servers
await pub.publish('cache:invalidate', JSON.stringify({
type: 'user',
id: userId,
}))
return user
}
// subscriber.ts — runs on every server instance
const sub = new Redis()
await sub.subscribe('cache:invalidate')
sub.on('message', async (channel, message) => {
const { type, id } = JSON.parse(message)
if (type === 'user') {
await redis.del(`user:${id}`)
console.log(`Invalidated cache: user:${id}`)
}
})
Pub/sub invalidation is the fleet-wide fix hinted at in Pattern 2 — it’s how instance B and C find out that instance A wrote new data, instead of waiting out their TTL. But it’s worth being precise about what guarantee it actually provides, because the name “pub/sub” invites an assumption of reliable delivery that Redis does not make. Redis pub/sub is fire-and-forget: messages are not persisted, not queued, and not retried. If a subscriber is disconnected, restarting, or simply busy (a GC pause, an event-loop-blocking synchronous operation) at the exact moment PUBLISH runs, that subscriber never receives the message — there is no redelivery when it comes back. It will keep serving the stale cached value it already had until that key’s own TTL expires independently, with no error, no log line, no signal that a message was missed. This is why pub/sub invalidation should always be layered on top of a TTL, never treated as a replacement for one: the TTL is the actual correctness guarantee (it bounds the worst case), and pub/sub is a latency optimization that makes updates propagate in milliseconds in the common case where every subscriber happens to be listening.
Batch Operations
// Get multiple keys at once (pipeline)
async function getMultipleUsers(userIds: string[]): Promise<User[]> {
const keys = userIds.map(id => `user:${id}`)
const cached = await redis.mget(...keys)
const results: User[] = []
const missingIds: string[] = []
cached.forEach((value, index) => {
if (value) {
results[index] = JSON.parse(value)
} else {
missingIds.push(userIds[index])
}
})
// Fetch missing from DB
if (missingIds.length > 0) {
const dbUsers = await db.users.findMany({
where: { id: { in: missingIds } }
})
// Store fetched users in cache
const pipeline = redis.pipeline()
for (const user of dbUsers) {
const idx = userIds.indexOf(user.id)
results[idx] = user
pipeline.setex(`user:${user.id}`, 300, JSON.stringify(user))
}
await pipeline.exec()
}
return results
}
// Store complex objects with hash
async function cacheUserHash(userId: string, user: User) {
await redis.hset(`user:hash:${userId}`,
'id', user.id,
'name', user.name,
'email', user.email,
'role', user.role,
)
await redis.expire(`user:hash:${userId}`, 300)
}
async function getUserFromHash(userId: string) {
return redis.hgetall(`user:hash:${userId}`)
}
Cache Warming
Pre-populate cache before it’s needed:
// Warm cache on startup for frequently accessed data
async function warmCache() {
console.log('Warming cache...')
// Load top products
const topProducts = await db.products.findMany({
where: { featured: true },
take: 100,
})
const pipeline = redis.pipeline()
for (const product of topProducts) {
pipeline.setex(`product:${product.id}`, 3600, JSON.stringify(product))
}
await pipeline.exec()
// Load site config
const config = await db.settings.findFirst()
await redis.setex('site:config', 86400, JSON.stringify(config))
console.log(`Cache warmed: ${topProducts.length} products`)
}
// Run on startup
warmCache().catch(console.error)
// Re-warm every hour
setInterval(warmCache, 60 * 60 * 1000)
Choosing an Eviction Policy: allkeys-lru vs. noeviction
None of the patterns above matter if Redis runs out of memory, and how it behaves at that point is controlled by a single setting almost nobody sets explicitly: maxmemory-policy. That’s the problem, because the default — on a fresh Redis install, and on plenty of managed Redis offerings unless you change it — is noeviction.
With noeviction, once memory usage hits maxmemory, every command that would use more memory starts failing with an OOM command not allowed error. Concretely, that means the redis.setex() call inside the withCache helper from Pattern 1 starts throwing — and the sample code in this article doesn’t handle that, so an unhandled rejection propagates straight up and turns a cache-capacity problem into a 500 error on every endpoint that uses withCache. In production, wrap the cache write in its own try/catch and treat a failed write as “serve this response uncached, this once” rather than letting it fail the request — a cache that can’t accept writes should degrade the hit rate, not the availability of the endpoint. And if you’re running sessions, a rate limiter, or a queue on the same Redis instance as your cache (a common cost-saving shortcut), noeviction means a full cache takes those down too, since their writes hit the same OOM wall.
allkeys-lru (or allkeys-lfu on Redis 4+) is the policy an actual cache-only deployment usually wants: once memory pressure hits, Redis silently evicts the least-recently-used key to make room for the incoming write, with no error returned to the client. The trade-off is that this failure mode is invisible by design — nothing in your application logs records that a key was evicted ten minutes before its TTL was due to expire, so a gradually shrinking cache hit rate under memory pressure looks identical to organically increasing traffic, and it’s easy to misdiagnose as “we need to scale the database” when the real fix is “we need more Redis memory or shorter TTLs.” If you do mix cache data with data you can’t afford to silently lose — session tokens being the most common case — either run it on a separate Redis instance from your cache, or set maxmemory-policy volatile-lru, which only evicts keys that have an explicit TTL and leaves keys set without one (a common pattern for manually-managed counters or locks) untouched under memory pressure.
Serialization Pitfalls: What JSON.stringify Silently Drops
Every pattern in this article round-trips data through JSON.stringify on write and JSON.parse on read, and that round trip is lossy in ways that don’t surface until a specific field happens to hit the edge case:
- Dates become plain strings, not
Dateobjects.JSON.stringify(new Date())produces an ISO string;JSON.parseon the way back out gives you that string, not a reconstructedDate. Code that calls.getTime()or does date arithmetic on a field read from cache will throw, while the exact same code path reading straight from the database — where the ORM reconstructs a realDate— works fine. The bug only exists on the cached branch, which makes it look intermittent rather than structural. undefinedproperties disappear entirely, they don’t becomenull.JSON.stringify({ a: 1, b: undefined })produces'{"a":1}'— thebkey is gone from the string, not present with a null value. A downstream check like'b' in userbehaves differently depending on whetherusercame fresh from the database (where an ORM might still exposebasundefined) or from cache (where the key never existed in the parsed object at all).- Circular references throw synchronously. A common ORM shape: a
userobject with a populated.postsrelation, where each post has a back-reference.authorpointing at that sameuserinstance.JSON.stringifyon that object throwsTypeError: Converting circular structure to JSONimmediately — and because that throw happens inside a genericwithCachehelper, it happens on every single cache miss for that key, meaning the endpoint that includes the relation fails on every request that doesn’t hit cache, with a stack trace pointing at a shared caching utility three layers away from wherever the circular relation was actually introduced. BigIntvalues don’t serialize at all (TypeError: Do not know how to serialize a BigInt) unless you’ve definedBigInt.prototype.toJSONyourself — worth knowing if your IDs come from a Snowflake-style generator or a database column that maps to a JSbigint.
I ran into the circular-reference case directly. A getUserWithPosts cache-aside call had worked in every test suite run because the test fixtures never populated the back-reference, then broke in staging the day someone added include: { author: true } to an unrelated posts query for a different feature. The stack trace pointed at JSON.stringify inside the shared withCache helper, which made it slow to track down precisely because the bug wasn’t in the code that had just changed — it was in a caching utility that had been sitting untouched for months, and the actual trigger was three files away. None of these are Redis bugs; they’re JSON.stringify bugs that caching makes load-bearing, because caching is often the first place in a codebase that actually forces an object through full serialization instead of just holding a reference to it in memory. The fix is the same shape in every case: normalize before you cache — convert dates to ISO strings and parse them back explicitly on read, strip or null out undefined fields, cache a flattened DTO instead of a raw ORM entity with circular relations, and register a toJSON for any BigInt fields at application startup.
Caching Strategy Reference
| Pattern | Who fills the cache | Read latency | Write latency | Consistency | Main risk | Complexity |
|---|---|---|---|---|---|---|
| Cache-aside | App, on miss | Low on hit, DB cost on miss | Unchanged (+ DEL) | Stale up to TTL unless invalidated | Stampede on hot-key expiry | Low |
| Read-through | A cache layer/library calls the loader on miss | Same as cache-aside | Unchanged | Same as cache-aside | Loader logic hidden from call sites | Medium |
| Write-through | App, on every write | Low | Higher (DB + cache on each write) | Fresh for the writer; other instances still need invalidation | Caching data nobody reads | Medium |
| Write-behind | App writes cache, DB flushed async | Low | Very low | Database lags the cache | Lost writes on crash | High |
| Refresh-ahead | Background refresh before expiry | Very low, almost no misses | Unchanged | Stale by up to the soft TTL | Wasted refreshes of cold keys | Medium–High |
Read-through isn’t implemented separately in this article because in application code it’s the same function as withCache. The difference is ownership: with cache-aside, every call site decides how to load and cache. With read-through, a repository or caching library owns the loader, and call sites only ask for a key. It’s worth adopting once the same entity gets cached from several places with slightly different keys or TTLs.
Most services start with cache-aside plus delete-on-write, and move individual keys to write-through or refresh-ahead only when measurements show a specific staleness or latency problem. Write-behind stays reserved for loss-tolerant counters.
TTL Guidelines
| Data type | Suggested TTL |
|---|---|
| User profile | 5-15 min |
| Product listing | 1-5 min |
| Static content | 1-24 hours |
| Session | 7 days |
| Rate limit window | Match window |
| Computed analytics | 5-60 min |
Related Articles
- Redis Internals and Usage: The Event Loop, Encodings, RDB vs AOF, Replication and Cluster
- API Rate Limiting: Fixed Window, Sliding Log and Token Bucket, and Making Them Atomic in Redis
- Node.js Performance: Clustering, Caching, and Profiling
- Node.js + Nginx Reverse Proxy Setup