redis-tiered-cache
v0.1.1
Published
Distributed caching framework for Node.js — L1 memory + L2 Redis, cache stampede protection, tag invalidation, multi-cluster routing, and a plugin system.
Maintainers
Readme
redis-tiered-cache
A distributed caching framework for Node.js services that run more than one process. An in-process LRU sits in front of Redis, the two stay coherent across the fleet over Pub/Sub, and a cache miss on a hot key runs its loader once — not once per request, and not once per instance.
- ⚡ Two tiers — L1 memory + L2 Redis, with automatic L1 population and cross-instance invalidation
- 🛡️ Stampede protection — in-process coalescing plus a distributed lock, so one loader runs per key fleet-wide
- 🏷️ Tag invalidation —
invalidateTag('users')instead of enumerating every affected key - 🌍 Multiple Redis clusters — primary/replica, multi-region, Redis Cluster, or your own routing strategy
- 🧯 Degrades, never crashes — circuit breaker per cluster, fail-open reads, retries with full jitter
- 🔌 Extensible — plugin hooks, swappable serializers (JSON/MessagePack) and compressors (gzip/brotli/LZ4)
- 🟦 TypeScript, zero runtime dependencies, ESM + CJS
Install
npm install redis-tiered-cache ioredisioredis@^5 is an optional peer dependency — you only need it for the Redis tier. A memory-only cache installs nothing extra. @msgpack/msgpack and lz4js are optional peers too, loaded through dynamic import() only if you configure them.
Node 20+.
Quickstart
import { createCache } from 'redis-tiered-cache';
const cache = createCache({
namespace: 'api',
ttl: '5m',
memory: { max: 10_000, ttl: '30s' },
clusters: {
primary: { host: 'redis-primary', role: 'primary' },
replica: { host: 'redis-replica', role: 'replica' },
},
routing: 'write-primary-read-replica',
});wrap() is the API you will use most — read-through with stampede protection:
const user = await cache.wrap(`user:${id}`, () => db.users.findById(id), {
ttl: '10m',
tags: ['users', `user:${id}`],
});500 concurrent requests for the same cold key across 10 instances produce one database query. Everything else waits for its result.
Invalidate on write without listing keys:
async function updateUser(id: number, patch: Partial<User>) {
const user = await db.users.update(id, patch);
await cache.invalidateTags([`user:${id}`, `org:${user.organisationId}`]);
return user;
}Direct operations behave as you would expect, and are fully typed:
await cache.set('config', { theme: 'dark' }, { ttl: '1h', tags: ['config'] });
const config = await cache.get<Config>('config'); // Config | undefined
await cache.getMany<Product>(['p:1', 'p:2']); // one MGET, misses simply absent
await cache.increment('views:home', 1, { ttl: '1d' }); // atomic
await cache.clear(); // this namespace only — never FLUSHDBHow it works
A read walks the tiers and populates L1 on the way back. A write goes to Redis first, then L1, then broadcasts.
| Layer | Responsibility |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| L1 memory | LRU with TTL, holding decoded values — no deserialization on a hit. Absorbs hot keys that would otherwise saturate one Redis slot. |
| L2 Redis | The shared source of truth. Written first, so a failed remote write never leaves one pod serving a value no one else can see. |
| Pub/Sub | Every mutation broadcasts the affected keys. Peers drop their L1 copies; messages carry an origin id so a write never invalidates itself. |
| Distributed lock | SET NX PX with compare-and-swap release and auto-extension. Arbitrates between processes after in-process coalescing has collapsed local callers to one. |
| Router | Picks which cluster serves each operation. Reads get an ordered failover list; writes get a fan-out set with an authoritative first target. |
| Circuit breaker | Per cluster, rolling-window failure counting. Turns a slow failure into an instant one so traffic degrades to L1 + source instead of stalling. |
Values are stored with a self-describing header naming the serializer and compressor that produced them, so changing either is safe mid-deploy — two versions of your app can read the same Redis without a coordinated cutover.
Stampede protection
wrap() applies four layers, in order:
- Cache lookup. A fresh hit returns immediately.
- Stale-while-revalidate. A value past its fresh window is returned now and refreshed behind the request, so expiry never becomes a latency spike.
- In-process coalescing. Concurrent callers in one process attach to a single execution — no lock, no extra round trip.
- Distributed lock. The one winner across the fleet re-checks Redis, then runs the loader. Losers re-read once it frees.
Layers 3 and 4 are ordered deliberately: coalescing locally first means one lock attempt per process rather than per request.
createCache({
ttl: '5m',
stale: {
enabled: true,
staleTtl: '1m', // serve stale for a minute past expiry
earlyExpirationBeta: 1, // probabilistic early refresh (XFetch)
},
});earlyExpirationBeta spreads refreshes over time instead of concentrating them at the instant every instance flips from fresh to stale.
TTLs, namespaces and tags
Durations accept milliseconds or a human string — '500ms', '30s', '5m', '2h', '7d', compound '1h30m', or 'never'. Malformed input throws at the call site rather than silently falling back to a default. Every TTL is jittered ±5% so keys written together do not expire together.
Keys are laid out as <namespace>:<version>:<key>:
createCache({ namespace: 'tenant-a', version: 'v2' }); // → 'tenant-a:v2:user:1'Bumping version orphans every previously written key at once — how you ship a breaking change to a cached shape without a flush and the self-inflicted stampede that follows one. cache.namespace('tenant-b') returns a view sharing the same connections, L1 and subscription, so multi-tenant services do not need a connection per tenant.
Tag membership lives in a Redis Set per tag, walked with SSCAN and deleted with UNLINK in batches, so a tag covering a million keys never blocks the server.
Multiple clusters
createCache({
region: process.env.AWS_REGION,
clusters: {
'primary-eu': { host: '…', role: 'primary', region: 'eu-west-1' },
'replica-us': { host: '…', role: 'replica', region: 'us-east-1' },
},
routing: 'read-nearest',
});| Strategy | Reads | Writes |
| ---------------------------- | ----------------------------- | ---------------------- |
| read-primary | primary, replicas as failover | primary |
| read-replica | replicas first | primary |
| read-nearest | same region, then lowest RTT | primary |
| write-primary | primary | primary |
| write-all | primary | every writable cluster |
| write-primary-read-replica | replicas first | primary |
Reads fail over on error, not on a miss — re-querying every cluster on every miss would multiply read cost exactly when the cache is cold. Routing is fully replaceable; pass a factory to reach the live cluster registry:
routing: (registry) => ({
name: 'by-workload',
selectRead: (ctx) => [registry.require(pick(ctx.key))],
selectWrite: (ctx) => [registry.require(pick(ctx.key))],
}),Supplying nodes instead of host switches to Redis Cluster and enables hash tagging automatically.
API
createCache(config)
| Option | Type | Description |
| -------------------------- | ---------------------------------- | ---------------------------------------------------------------- |
| namespace / version | string | Key prefix segments. Bump version to orphan old keys |
| ttl | Duration | Default lifetime. Defaults to '5m' |
| memory | object | { enabled, max, maxBytes, ttl, clone }. L1 tier |
| clusters | Record<string, ClusterConfig> | Named Redis deployments. Omit for memory-only |
| routing | string \| CacheRouter \| factory | Strategy name, router, or factory |
| serialization | object | { serializer, additional } — 'json' (default) or 'msgpack' |
| compression | object | { enabled, algorithm, threshold } — gzip, brotli or lz4 |
| lock | object | { enabled, ttl, timeout, retryDelay, autoExtend } |
| stale | object | { enabled, staleTtl, earlyExpirationBeta } |
| invalidation | object | { enabled, channel, batchWindow } |
| retry / circuitBreaker | object | Resilience policy |
| timeout | Duration | Per-operation deadline. Defaults to '1s' |
| failOpen | boolean | Resolve Redis failures as misses. Defaults to true |
| plugins | CachePlugin[] | Registered at construction |
Methods
get(key, opts?)/getWithMeta(key, opts?)— read, optionally with tier and staleness metadata.set(key, value, opts?)— write to every tier and broadcast.wrap(key, loader, opts?)— read-through with stampede protection. AcceptsshouldCache,forceRefresh,lockTimeout,onLockTimeout.delete/deleteMany/exists/clear/touch.getMany/setMany— batched read and write.increment/decrement— atomic counters. Bypass L1 by design.invalidateTag(tag)/invalidateTags(tags)— remove every key carrying a tag, everywhere.namespace(ns, version?)— a namespaced view over the same connections.use(plugin),on/once/off— extension and events.metrics()/health()— snapshot and per-cluster status.connect()/close()— both idempotent. Callclose()onSIGTERM.
Per-call options include ttl, tags, skipMemory, skipRedis, cluster, timeout, signal and silent.
Exports
createCache,CacheManager— a named registry for services running several caches with different policies.- Errors:
CacheErrorandSerializationError,RedisConnectionError,LockTimeoutError,PluginError,CircuitOpenError,ConfigurationError,TimeoutError,CacheClosedError. Each carries a stablecode— branch on that, notinstanceof. - Plugins:
LoggerPlugin,MetricsPlugin,PluginHost. - Building blocks:
MemoryStore,RedisStore,IoRedisDriver,ClusterRegistry,StrategyRouter,RedisLockProvider,JsonSerializer,MsgpackSerializer,GzipCompressor,CircuitBreaker,SingleFlight,KeyBuilder,parseDuration,ManualClock.
Every export is named and the package is sideEffects: false, so a bundler drops what you do not import.
Events and metrics
cache.on('hit', ({ key, tier, durationMs }) => statsd.timing('cache.hit', durationMs, { tier }));
cache.on('error', ({ operation, error }) => logger.error({ operation, err: error }));
const { hitRate, memoryHits, dbHits, lockTimeouts, latency } = cache.metrics();Events: hit, miss, memory-hit, redis-hit, set, delete, clear, touch, error, lock, unlock, lock-timeout, refresh, invalidate, circuit-open, circuit-close, connect, disconnect — all fully typed. An error event with no listener never throws; a cache is expected to degrade, not crash the process.
Worth alerting on: falling hitRate (TTLs too short, or invalidation too aggressive), rising lockTimeouts (loaders outrunning lock.ttl), sustained circuitOpens (Redis unhealthy), and errors climbing while hits stays flat (failing open, silently).
Performance
Apple M-series, Node 24, against a real Redis 8.10 on loopback. Run npm run bench yourself — without REDIS_URL it uses an in-memory driver and measures framework overhead in isolation.
| Scenario | ops/sec | mean | vs raw ioredis |
| ----------------------------------------- | ---------: | ------: | -------------: |
| L1 hit | ~2,180,000 | 0.46 µs | 188× |
| wrap() on a warm key | ~2,230,000 | 0.45 µs | 191× |
| L1 hit with 2 plugins registered | ~809,000 | 1.24 µs | 70× |
| Raw ioredis GET + JSON.parse (baseline) | ~11,600 | 86 µs | 1.0× |
| L2 hit (L1 bypassed) | ~10,700 | 94 µs | 0.92× |
| Write, memory + Redis | ~10,400 | 96 µs | 0.95× |
| Write, memory + Redis, 2 tags | ~10,100 | 99 µs | 0.93× |
Two numbers matter here. An L1 hit is ~190× faster than going to Redis, because it skips the round trip and the parse entirely — that is the whole argument for a memory tier, and it grows as your Redis moves further away. And an L2 hit costs ~9 µs more than calling ioredis yourself (94 vs 86 µs, ~10%), which is what routing, envelope decoding, metrics and events add on top of the driver.
Writes land within 5–7% of raw ioredis, tagged or not — the tag index is written concurrently with the value rather than after it.
Stampede protection: 3,000 concurrent requests across 30 cold keys → 30 loader executions, exactly one per key.
Payload sizes for a representative 200-item object: JSON 16,804 B · MessagePack 6,485 B · JSON + gzip 1,445 B.
Limitations
- This is a cache, not a database. Every value must be reconstructible from a source of truth. L1 is eventually consistent across instances, bounded by
memory.ttl. - Pub/Sub invalidation is best-effort. An instance disconnected at the moment of publish misses that message; a short
memory.ttl(10–60 s) is what bounds the damage. If a workload cannot tolerate that window at all, keep those values out of L1 withmemory.enabled: falseor per-callskipMemory. - Replica reads are subject to replication lag. A read immediately after a write may miss.
write-allhas no conflict resolution. Concurrent writes to the same key in two regions resolve last-writer-wins per cluster, and the clusters can disagree.- The distributed lock is not a general-purpose lock. It protects a cache population, where the worst case of losing it is a duplicated loader run. Do not reuse it for operations with side effects — those need real fencing.
wrap()is exactly-once in the common case, at-least-once under lock timeout or Redis failure. The default favours availability;onLockTimeout: 'throw'inverts that.- Encrypted or transformed payloads belong in a serializer, not a plugin.
afterGetis observational by design — see docs/plugins.md.
Operational notes
- Keep
memory.ttlshort. It is the safety net for a dropped invalidation. - Leave
failOpen: trueunless a stale-free guarantee genuinely outranks availability. A cache that throws when Redis blips turns a degradation into an outage. - Call
cache.close()onSIGTERM— it flushes buffered invalidations so peers are not left holding stale entries. - Set a
namespacewhenever anything else shares the Redis instance.clear()is namespace-scoped and never issuesFLUSHDB, but namespacing also keeps keys legible during an incident. - Tag at every write site. Retrofitting tags leaves existing entries un-invalidatable until they expire.
- Set
memory.clone: trueif callers mutate cached objects; otherwise one caller's mutation corrupts the entry for the whole process.
Documentation
- Architecture — layering, read/write protocols, locking, invalidation guarantees, and the reasoning behind each trade-off
- Configuration — every option, with defaults and guidance
- API — full signatures and semantics
- Plugins — the hook model, and when to use a serializer instead
- Migration — from raw ioredis,
cache-manager, or a homegrown two-tier cache
Runnable examples in examples/: basics (no Redis needed), Express, multi-cluster, plugins.
Contributing
npm install
npm test # hermetic — no Redis server required
npm run buildThe suite runs against an in-memory driver implementing the same CacheDriver contract as ioredis, so stampede behaviour, pub/sub invalidation, cluster failover and circuit breaking are all deterministic and need no server. REDIS_URL=redis://localhost:6379 npm test additionally runs the integration suite. See CONTRIBUTING.md.
License
MIT
