Cache
@rudderjs/cache is the framework's cache abstraction. It ships with an in-memory driver out of the box and a Redis driver for shared, persistent caching across processes. The Cache facade exposes the same API regardless of the driver underneath.
Setup
pnpm add @rudderjs/cache// config/cache.ts
import { Env } from '@rudderjs/support'
import type { CacheConfig } from '@rudderjs/cache'
export default {
default: Env.get('CACHE_DRIVER', 'memory'),
stores: {
memory: { driver: 'memory' },
redis: { driver: 'redis', host: Env.get('REDIS_HOST', 'localhost'), port: Env.getNumber('REDIS_PORT', 6379) },
},
} satisfies CacheConfigThe provider is auto-discovered. For Redis, also install ioredis:
pnpm add ioredisThe Cache facade
import { Cache } from '@rudderjs/cache'
await Cache.set('user:1', user, 300) // TTL in seconds
const user = await Cache.get<User>('user:1')
const ok = await Cache.has('user:1')
await Cache.forget('user:1')
// Compute-and-cache pattern
const recent = await Cache.remember('posts:recent', 60, async () => {
return Post.where('publishedAt', '>', new Date(Date.now() - 86400_000)).get()
})
// One-time token
const token = await Cache.pull<string>('one-time-token')
await Cache.flush() // clear the entire default store| Method | Description |
|---|---|
Cache.get<T>(key) |
Retrieve or null |
Cache.set(key, value, ttl?) |
Store; omit ttl to store indefinitely |
Cache.has(key) |
Existence check |
Cache.forget(key) |
Remove one key |
Cache.remember<T>(key, ttl, fn) |
Cache-or-compute |
Cache.rememberForever<T>(key, fn) |
Cache-or-compute, no TTL |
Cache.pull<T>(key) |
Get and remove |
Cache.flush() |
Clear everything in the default store |
TTL values are always in seconds.
Multiple stores
Multiple named stores aren't supported yet — the Cache facade has no per-name store routing. Exactly one store, the configured default, is active for every call. Configure that single default store in config/cache.ts.
Atomic locks
Cache.lock(name, seconds) builds a distributed lock backed by the configured cache driver — SET NX EX + Lua compare-and-delete on Redis, a segregated __lock__: prefix on memory. Use it to serialize cross-process work (job processing, scheduled tasks, third-party sync).
import { Cache } from '@rudderjs/cache'
// Auto-release callback form — preferred.
await Cache.lock('process-podcast:42', 120).get(async () => {
await processPodcast(42)
})
// Block until the lock frees (or throw LockTimeoutError after N seconds).
await Cache.lock('process-podcast:42', 120).block(10, async () => {
await processPodcast(42)
})
// Manual try/finally form when you need explicit control.
const lock = Cache.lock('process-podcast:42', 120)
if (await lock.get()) {
try { await processPodcast(42) }
finally { await lock.release() }
}block() polls every ~250ms; acquisition order is roughly first-come but not strict FIFO. Pick a TTL longer than the worst-case callback — there is no auto-extend, and a crashed holder must wait for the TTL to expire before another process can acquire.
Cross-process release
release() is owner-checked — only the original holder can release the lock. To hand a lock off to a worker, capture the owner token before serialising:
const lock = Cache.lock('process-podcast:42', 120)
await lock.get()
const owner = lock.owner() // UUID owner token (128 bits of entropy)
// In the worker, restore by owner:
await Cache.restoreLock('process-podcast:42', owner).release()
// No-op if the lock TTL'd out and someone else acquired in between.forceRelease() bypasses the owner check — reserve it for operator tooling that clears stuck locks left behind by crashed holders.
What uses locks under the hood
WithoutOverlappingmiddleware on queued jobs — serializes per-key job execution across workers.schedule.withoutOverlapping()/schedule.onOneServer()— see Scheduling.- Rate-limiter "atomic increment" path — see below.
Switching from memory to redis is the single biggest correctness gain when you graduate from one process to many: memory locks are scoped to a single process, so two pm2 workers will both think they own the lock.
Drivers
Memory
In-process Map. Data does not persist across restarts and isn't shared across multiple server instances. Default for development; not for production with more than one process.
{ driver: 'memory' }Redis
Backed by ioredis. Persistent, multi-process, supports clustering.
{
driver: 'redis',
host: 'localhost',
port: 6379,
// Optional:
password: 'secret',
db: 0,
prefix: 'app:',
}The url field is passed straight to ioredis, so for TLS (Redis Cloud, ElastiCache with TLS) use a rediss:// URL. For Cluster mode, use redis:// URLs and the standard ioredis cluster config.
Custom drivers
Implement CacheAdapter to plug in DynamoDB, Memcached, or a tiered store:
import type { CacheAdapter, Lock } from '@rudderjs/cache'
import { CacheRegistry } from '@rudderjs/cache'
class MyAdapter implements CacheAdapter {
async get<T>(key: string): Promise<T | null> { /* ... */ }
async set(key: string, value: unknown, ttl?: number): Promise<void> { /* ... */ }
async has(key: string): Promise<boolean> { /* ... */ }
async forget(key: string): Promise<void> { /* ... */ }
async flush(): Promise<void> { /* ... */ }
// Atomic counter — initialise to `by` on first write, then preserve the original TTL.
async increment(key: string, by = 1, ttl?: number): Promise<number> { /* ... */ }
// Store only if absent; `true` when this writer won the race.
async add(key: string, value: unknown, ttl?: number): Promise<boolean> { /* ... */ }
lock(name: string, seconds: number): Lock { /* ... */ }
restoreLock(name: string, owner: string): Lock { /* ... */ }
// `disconnect?()` is optional — implement it if the store holds an open connection.
}
CacheRegistry.set(new MyAdapter()) // single adapter, no name — registry holds one active adapterUse with rate limiting
RateLimit from @rudderjs/middleware is cache-backed. Multi-process deployments need a shared store — register the Redis cache before any middleware runs:
{
default: 'redis',
stores: { redis: { driver: 'redis', host: '...', port: 6379 } },
}If no cache adapter is registered when RateLimit runs, it fails open (allows everything). See Middleware.
Testing
Cache.fake() returns a FakeCacheAdapter instance — assertions live on the returned fake, not on Cache:
import { Cache } from '@rudderjs/cache'
const fake = Cache.fake()
await someCodeThatCaches()
fake.assertSet('user:1') // value was written
fake.assertSet('user:1', v => (v as User).id === 1) // optional value predicate
fake.assertGet('user:1') // key was read
fake.assertHas('user:1') // currently present in the fake
fake.assertMissing('user:42') // currently absent
fake.assertForgotten('user:99') // forget(key) was called
fake.assertFlushed() // flush() was called
fake.assertLockAcquired('process-podcast:42')
fake.assertLockReleased('process-podcast:42')
fake.restore() // clear and detach the fakeCache.fake() swaps the registered adapter with an in-memory recording fake for the duration of the test. Call fake.restore() in afterEach so subsequent tests start with no adapter registered.
Pitfalls
- Memory driver in production with multiple processes. Each process has its own
Map. Cache hits become inconsistent. Use Redis (or another shared store) for any deployment with more than one Node process. - Forgetting to install
ioredis. The provider throws at boot —ioredisis an optional peer dependency, not bundled with@rudderjs/cache. - TTL in milliseconds. It's not — TTL is in seconds.
Cache.set('k', v, 60)lasts one minute, not 60 ms. - Memory-driver locks across
pm2 cluster. Each Node process has its ownMap, so every worker thinks it owns the lock. Use Redis (or another shared store) for any deployment that runs more than one process. - Reentrant
lock.get()returnsfalse. Callingget()twice on the sameLockinstance returnsfalsethe second time — the lock is held (by you). Locks are not reentrant; pass the samelockreference around or restructure the code. - Crashed holders block the lock for the full TTL. There is no auto-extend or heartbeat. If a process dies mid-callback the lock auto-releases when the TTL expires. Choose a TTL slightly longer than the worst-case execution time, not 10× longer.