Distributed read-through cache for Redis. Single-flight loader execution across instances, stale-while-revalidate, tag-based invalidation, negative and error caching.
npm install @prsm/cache
import { createCache } from '@prsm/cache'
const cache = createCache({
redis: { host: '127.0.0.1', port: 6379 },
defaultTtl: '5m',
})
const user = await cache.fetch(`user:${id}`, async () => {
return await db.users.findById(id)
}, { ttl: '5m' })The loader is called only on a miss. When N instances race for the same missing key, only one of them runs the loader. The rest wait for the result and receive it from Redis pub/sub. Your database sees one query, not N.
The pattern most teams write looks correct but causes outages under load:
const cached = await redis.get(`user:${id}`)
if (cached) return JSON.parse(cached)
const user = await db.users.findById(id)
await redis.setex(`user:${id}`, 300, JSON.stringify(user))
return userWhen a hot key expires and 800 concurrent requests arrive in the same millisecond, all 800 see a miss, all 800 call the database. The database melts. This is the cache stampede problem.
This package handles it by acquiring a per-key lock when a miss happens. The lock winner runs the loader, writes the value, and publishes the result. Everyone else waits on the pub/sub channel and receives the value when the leader finishes. One database query for N concurrent requests.
When staleWhile is set, a key past its TTL but still within the stale window is served immediately while a background refresh runs. Requests never block on cache regeneration.
const trending = await cache.fetch('trending', loadTrending, {
ttl: '1m', // fresh window
staleWhile: '10m', // serve stale up to 10 minutes past TTL while refreshing
})Keys can carry tags. Invalidating a tag wipes every key that was set with it.
await cache.set(`order:${id}`, order, {
ttl: '1h',
tags: [`user:${order.userId}`, `org:${order.orgId}`],
})
await cache.invalidateTag(`user:42`) // removes every key tagged user:42const profile = await cache.fetch(`profile:${id}`, loadProfile, {
ttl: '10m',
negativeTtl: '30s', // cache nulls for 30s so missing-user lookups don't hit the DB forever
errorTtl: '5s', // briefly cache thrown errors so an outage doesn't cascade
})undefined returns are never cached. null is cached using negativeTtl if it is set.
const cache = createCache({
redis: { host, port, password, db, url }, // or a node-redis client
prefix: 'cache:',
defaultTtl: '5m',
defaultStaleWhile: 0,
defaultNegativeTtl: null,
defaultErrorTtl: 0,
defaultLockTtl: '30s',
waitTimeout: '10s',
serialize: JSON.stringify,
deserialize: JSON.parse,
})Returns the cached value, or runs loader if missing. Options override defaults: ttl, staleWhile, negativeTtl, errorTtl, lockTtl, waitTimeout, tags.
Returns the value if the key is fresh, otherwise undefined. Does not call any loader.
Writes a value directly. Options: ttl, staleWhile, tags.
Removes a key and its tag membership. Returns true if a key was removed.
Returns true if the key is fresh.
Removes every key associated with the tag. Returns the number of keys removed.
Returns a snapshot of counters: hits, misses, sets, dels, errors, refreshes, stampedeLeads, stampedeWaits, stampedeSavings, invalidations.
Events: hit, miss, set, del, invalidate, stampede:lead, stampede:wait, stampede:result, stampede:timeout, refresh, error.
Closes the underlying Redis connections.
make up # start Redis
make test # run tests
make down # stop Redis
Redis must be running on localhost:6379 for tests.