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'constcache=createCache({redis: {host: '127.0.0.1',port: 6379},defaultTtl: '5m',})constuser=awaitcache.fetch(`user:${id}`,async()=>{returnawaitdb.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:
constcached=awaitredis.get(`user:${id}`)if(cached)returnJSON.parse(cached)constuser=awaitdb.users.findById(id)awaitredis.setex(`user:${id}`,300,JSON.stringify(user))returnuserWhen 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.
consttrending=awaitcache.fetch('trending',loadTrending,{ttl: '1m',// fresh windowstaleWhile: '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.
awaitcache.set(`order:${id}`,order,{ttl: '1h',tags: [`user:${order.userId}`,`org:${order.orgId}`],})awaitcache.invalidateTag(`user:42`)// removes every key tagged user:42constprofile=awaitcache.fetch(`profile:${id}`,loadProfile,{ttl: '10m',negativeTtl: '30s',// cache nulls for 30s so missing-user lookups don't hit the DB forevererrorTtl: '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.
constcache=createCache({redis: { host, port, password, db, url },// or a node-redis clientprefix: '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.