TypeScript · Node.js · L1/L2 Cache

Hybrid
Cache
Done Right

Lazy loading, stampede protection, fail-open safety, stale fallback, and distributed invalidation—shipped as one focused package.

$ npm install lazy-layers-cache
L1 only (Redis off)
import { LazyLayersCache } from "lazy-layers-cache"; const cache = new LazyLayersCache<string, User>({ l2: false, // explicit: no Redis/L2 ttlMs: 60_000, levels: { L1: { maxEntries: 1_000, ttlMs: 10_000 }, }, inflight: { enabled: true, ttlMs: 5_000 }, }); // L1 is created by default and stays process-local. const user = await cache.getOrSet(`user:${id}`, async () => { return db.users.findById(id); });
L1 + Redis L2
import Redis from "ioredis"; import { LazyLayersCache, RedisStore } from "lazy-layers-cache"; const useL1 = process.env.CACHE_L1 !== "false"; const redis = process.env.REDIS_URL ? new Redis(process.env.REDIS_URL) : undefined; const cache = new LazyLayersCache<string, User>({ l1: useL1 ? undefined : false, l2: redis ? new RedisStore<User>(redis, { prefix: "users:" }) : false, ttlMs: 60_000, failSafe: { enabled: true, staleTtlMs: 300_000 }, }); // Fetch order: L1 -> L2 -> loader. L2 failures fail open. const user = await cache.getOrSet(`user:${id}`, async () => { return db.users.findById(id); });
L1 Memory Cache Redis L2 Lazy Loading Stampede Protection Fail Open Circuit Breakers Stale Fallback Negative Caching RabbitMQ · NATS · Redis PubSub MessagePack TypeScript Native ESM + CommonJS L1 Memory Cache Redis L2 Lazy Loading Stampede Protection Fail Open Circuit Breakers Stale Fallback Negative Caching RabbitMQ · NATS · Redis PubSub MessagePack TypeScript Native ESM + CommonJS
What's included
In-Process L1 Cache
LRU memory cache via lru-cache. Configurable max entries and TTL per level. Requests warm only the instance they hit.
🗄
Shared Redis L2
Optional ioredis backend with MessagePack serialization, binary getBuffer reads, millisecond TTL, and sorted-set key indexes.
🔁
Lazy Loading
getOrSet(key, loader)—load once, cache everywhere. Deduplicates concurrent calls to the same key in the same process.
🛡
Stampede Protection
In-flight promise deduplication prevents same-process dogpile. Optional Redis-backed distributed lock for cross-instance coordination.
📡
Distributed Invalidation + L1 Priming
Invalidation over Redis Pub/Sub, RabbitMQ durable queues, NATS core, or NATS JetStream. Every connected instance clears on delete and warms its L1 from peer getOrSet results — one loader call, all peers cached.
🔒
Versioned Keys
Optional generational key writes after invalidation. Eliminates stale write/delete races inside an instance during high-frequency updates.
🚦
Circuit Breakers
Separate L2 and event-bus circuit breakers. After repeated failures the circuit opens and external calls are skipped until cooldown.
Soft & Hard Timeouts
Soft timeout returns stale immediately if available. Hard timeout aborts the loader. Both are independently configurable per cache instance.
📊
Observability Hooks
20+ typed cache events from hit to stale:hit to event-bus:publish-error. Wire to metrics, logs, or traces in one call.
How it works
request
Instance A
miss
loader
getOrSet()
store
L1
Memory
write
L2
Redis
pub set
prime L1
All Peers
Lazy Layers Architecture click to expand
Event buses
Redis
Pub/Sub · Best-effort
Fastest option. At-most-once Pub/Sub over your existing Redis connection. Missed invalidations during disconnects recover naturally via TTL expiry.
low latency zero infra add not durable
RabbitMQ
Fanout · Durable queues
Reliable fanout with named per-instance durable queues. Every instance gets every invalidation even after a reconnect.
durable fanout requires RabbitMQ
NATS
Core · JetStream
Two modes: core for at-most-once speed, JetStream for durable delivery with replay, ack, and per-instance durable consumers.
durable (JS) replay lightweight
Delivery semantics
Transport Delivery meaning
Redis Pub/Sub At-most-once and ephemeral. Subscribers only receive messages while connected.
NATS Core At-most-once. Fast fanout, but no replay for disconnected subscribers.
RabbitMQ durable mode Retryable and durable when durableInvalidationMode, a stable queueName, and persistent messages are configured.
NATS JetStream Durable and replayable with explicit ack, durable consumers, and redelivery.
Getting started — setup directions
Redis — setup
# Docker docker run -d --name redis \ -p 6379:6379 redis:7-alpine # macOS brew install redis brew services start redis # Connection URL REDIS_URL="redis://localhost:6379" # Managed: Redis Cloud, Upstash, AWS ElastiCache
Redis — connect
import Redis from "ioredis"; import { RedisEventBus } from "lazy-layers-cache"; const redis = new Redis(process.env.REDIS_URL); const eventBus = new RedisEventBus(redis, "cache:invalidations", { retryQueue: { enabled: true, maxSize: 1_000, }, handlerConcurrency: 1, logging: { env: "production" }, });
RabbitMQ — setup
# Docker (with management UI) docker run -d --name rabbitmq \ -p 5672:5672 \ -p 15672:15672 \ rabbitmq:3-management # Management UI: http://localhost:15672 # Default login: guest / guest # Connection URL RABBITMQ_URL="amqp://guest:guest@localhost:5672" # Managed: CloudAMQP (free), AWS Amazon MQ
RabbitMQ — connect
import { RabbitMQEventBus } from "lazy-layers-cache"; const eventBus = new RabbitMQEventBus( "cache.invalidations", { url: process.env.RABBITMQ_URL, exchangeType: "fanout", durableInvalidationMode: true, queueName: `${process.env.INSTANCE_ID}-cache`, exclusiveQueue: false, autoDeleteQueue: false, prefetch: 100, retryQueue: { enabled: true, maxSize: 10_000 }, logging: { env: "production" }, });
NATS — setup
# Docker (core mode) docker run -d --name nats \ -p 4222:4222 nats:latest # Docker (JetStream enabled) docker run -d --name nats \ -p 4222:4222 nats:latest -js # Local binary # Download: github.com/nats-io/nats-server/releases nats-server # core nats-server -js # JetStream # Connection URL NATS_URL="nats://localhost:4222" # Demo server (testing only, public!): # nats://demo.nats.io # Managed: Synadia Cloud (by NATS creators)
NATS JetStream — connect
import { NatsEventBus } from "lazy-layers-cache"; const eventBus = new NatsEventBus({ mode: "jetstream", connectionOptions: { servers: process.env.NATS_URL, name: process.env.INSTANCE_ID, }, subject: "cache.invalidations", jetstream: { stream: "CACHE_INVALIDATIONS", durableName: `${process.env.INSTANCE_ID }-cache`, storage: "file", maxAgeMs: 86_400_000, // 24h ensureStream: true, ensureConsumer: true, ackWaitMs: 30_000, maxDeliver: 10, }, logging: { env: "production" }, });
Event bus options — every knob, why it exists

Each transport ships with sane defaults but exposes everything you need to tune durability, fanout, retry, and per-instance identity. The source field on LazyLayersCache is what filters self-loopback regardless of transport — every transport delivers your own publishes back to you.

LazyLayersCache event-bus options (shared across all transports)
Option Default Why it exists
eventBus unset The bus instance used for distributed invalidation and L1 priming. Without it, the cache is local-only.
source random per process Required in multi-instance setups. Stamped on every published event; the subscribe handler discards events whose source matches this value so you don't invalidate yourself. Use $HOSTNAME / INSTANCE_ID.
subscribeToEvents true Set false to publish-only (one-way). Useful for read-only replicas that should not apply remote invalidations.
broadcastSet true When a getOrSet loader returns a value, broadcast it so peer L1s populate without a second loader call. Set false for delete-only fanout (legacy behavior).
broadcastSetMaxBytes unset Optional encoded-size cap for set events. Oversized values are stored locally/L2 but not fanned out for peer L1 priming.
eventDedupeMaxEntries 10_000 How many recent event IDs the cache remembers to drop duplicates. Durable buses can redeliver; this stops a redelivered event from re-applying.
eventDedupeTtlMs 300_000 How long each event ID stays in the dedupe map. Should comfortably exceed your worst-case redelivery window.
resilience.eventBusCircuitBreaker unset { failureThreshold, cooldownMs }. After repeated publish failures, the circuit opens and publishes are skipped until cooldown. Keeps a broken bus from blocking your request path.
Shared retry queue (every transport accepts this under retryQueue)
Option Default Why it exists
retryQueue.enabled true If a publish throws, the event is buffered in memory and re-attempted on the next successful publish. Smooths out brief bus blips.
retryQueue.maxSize 10_000 Cap to prevent memory bloat when the bus stays down for a long time. Oldest event drops first with a warn log. Set a smaller value for memory-sensitive services.
RedisEventBus options — new RedisEventBus(redis, channel, options)
Option Default Why it exists
redis (ctor arg) An existing ioredis client. The bus calls .duplicate() internally for the subscriber (Pub/Sub clients can't issue other commands).
channel (ctor arg) The Pub/Sub channel name. All instances that share invalidation must use the same channel.
retryQueue see above Buffers failed publishes for the next attempt. Pub/Sub is fire-and-forget — without retry, a single network blip drops the event.
handlerConcurrency 1 Limits concurrent subscriber handler execution. The default preserves approximate message order for invalidations.
logging.env inherits NODE_ENV Force "production" / "development" / "test" independent of NODE_ENV. Production suppresses debug logs.
logging.enabled auto from env Hard override of the env-based switch when you want logs on or off regardless of NODE_ENV.
RabbitMQEventBus options — new RabbitMQEventBus(exchange, options)
Option Default Why it exists
exchange (ctor arg) Exchange name. All instances must bind to the same exchange for fanout to work.
url AMQP URL (amqp://user:pass@host:5672). Required unless you call init(url) manually.
exchangeType "fanout" Use "topic" if you want one bus for multiple caches with routing keys; "direct" for exact-match routing. Default fanout broadcasts to every bound queue.
durableInvalidationMode false One switch that flips durable, persistent, names the queue, and disables auto-delete/exclusive. Pick this when you cannot afford to miss an invalidation across reconnects.
durable follows durableInvalidationMode Survives broker restarts. Required if you want messages preserved across RabbitMQ outages.
persistent follows durable Per-message delivery mode 2 — flushed to disk before ack. Pair with durable: true for end-to-end durability.
queueName server-generated Set this to a stable per-instance value (e.g. ${INSTANCE_ID}-cache) when using durable mode. Anonymous queues vanish on reconnect, losing messages buffered for that instance.
exclusiveQueue true unless durable mode Exclusive queues are tied to the connection and auto-deleted on disconnect. Good for ephemeral subscribers, bad for durable per-instance invalidation queues.
autoDeleteQueue true unless durable mode Queue is removed once no consumers remain. Disable when you want messages to buffer while an instance restarts.
routingKey "" Ignored for fanout; required for topic/direct exchanges to pick which messages this consumer wants.
prefetch broker default (unlimited) Channel-level QoS. Caps unacked messages per consumer so a single slow instance can't hoard the queue.
retryQueue see above Buffers failed publishes when the AMQP confirm fails.
logging inherits env Same shape as Redis.
NatsEventBus options — new NatsEventBus(options)
Option Default Why it exists
mode "core" "core" is at-most-once, lowest latency. "jetstream" adds durable streams, per-instance durable consumers, redelivery, and replay.
connection created internally Inject a pre-built NatsConnection when you want to share one connection across multiple services. The bus will not own (or drain) an injected connection on disconnect().
connectionOptions Standard nats.connect() options when the bus creates the connection itself (servers, name, token, etc.). Set name to your instance ID for cleaner observability on the NATS server.
subject "cache.invalidations" NATS subject used to publish and subscribe. Same value on every instance.
retryQueue see above Buffers failed publishes (core mode in particular can drop on broker hiccup).
jetstream.stream "CACHE_INVALIDATIONS" JetStream stream name. The bus creates it if ensureStream is true.
jetstream.durableName Required in JetStream mode. Per-instance durable consumer identity. JetStream replays from where this consumer last acked, so each instance needs its own unique value.
jetstream.storage "file" "file" persists across NATS restarts; "memory" is faster but lost on restart.
jetstream.maxAgeMs unset (forever) Drops messages older than this from the stream. Caps disk usage when invalidations stack up.
jetstream.maxMsgs -1 (unlimited) Hard cap on stored messages. Pair with maxAgeMs for predictable storage.
jetstream.ackWaitMs 30_000 Time the server waits for an ack before redelivering. Increase if your handler is slow; decrease for tighter retries.
jetstream.maxDeliver 10 Maximum redelivery attempts before the message is given up on. Stops poison messages from looping forever.
jetstream.ensureStream true Auto-create the stream on connect() if missing. Set false if streams are provisioned by infra/IaC.
jetstream.ensureConsumer true Auto-create the durable consumer for this instance if missing. Set false when consumers are provisioned externally.
logging inherits env Same shape as Redis.
Resilience features
01
Fail Open
L2 and event-bus failures are logged and swallowed. Local L1 continues serving requests. Your app never breaks because Redis went down.
02
Stale Fallback
Successful values are kept in a stale map. If a later loader fails or times out, the last-known-good value is returned instead of an error.
03
Negative Caching
Loader misses are briefly cached. Repeated queries for known-missing keys are absorbed without hammering your database every time.
04
Circuit Breakers
Separate breakers for L2 and event-bus publish. After configurable failures, the circuit opens and external calls are skipped until cooldown.
05
Distributed Lock
With RedisStore and distributedLock enabled, cross-instance getOrSet calls coordinate so only one instance loads a missing key at a time.
06
Timeout Tiers
Soft timeout returns stale early when available. Hard timeout aborts loading entirely. Two independent dials for latency vs freshness tradeoffs.
Observability events
hit
miss
set
delete
delete-pattern
loader:start
loader:success
loader:error
loader:timeout
inflight:reuse
inflight:bypass
stale:hit
negative:set
l2:error
l2:skipped
invalidation:received
invalidation:duplicate
invalidation:stale
set:broadcast
set:broadcast-skipped
set:received
event-bus:publish-error
event-bus:publish-skipped
+ custom hooks
Intelligent serializer · v2

L2 values are written through an intelligent binary serializer. Each Redis payload carries a fixed 4-byte magic prefix so decode is O(1). Small payloads stay in msgpack; large compressible payloads automatically switch to gzip when it saves at least 15%.

HC1M msgpack — default under 64 KB
HC1G gzip(msgpack) — ≥ 64 KB & ≥ 15% savings
HC1J JSON — opt-in debug mode
Encoding Bytes (1 MB mock listing) vs JSON ms / op (n=25) When picked
JSON 1032.31 KB baseline 3.19 Legacy / debug only
msgpack (HC1M) 805.50 KB 22.0% smaller 3.62 Any payload < 64 KB, or when gzip doesn't pay off
msgpack + gzip (HC1G) 67.57 KB 93.5% smaller 9.98 ≥ 64 KB and gzip saves ≥ 15%
Network transfer
~15×
Less bytes per L2 fetch for the 1 MB listing. Redis stores 67.57 KB instead of 1032.31 KB.
Redis memory
−93.5%
Same data, far less memory pressure on the shared cache tier. More keys per GB.
CPU cost
~7 ms
One-time write-side cost on the 1 MB payload. Reads stay O(1) on L1 hits.
automatic — no code changes
// Same API. The serializer is wired through RedisStore. await cache.set("players:page:1", listing); await cache.get("players:page:1"); // Old cache entries (raw msgpack / JSON) still decode. // Cached null is a real hit, not a miss.
opt-in — stats & debug
import { serializeWithStats } from "lazy-layers-cache"; const stats = serializeWithStats(listing); // { // encoding: "msgpack-gzip", // originalBytes: 824_837, // storedBytes: 69_198, // compressionRatio: 0.916, // compressed: true, // } // Debug: store as readable JSON in Redis process.env.CACHE_FORMAT = "json";
Initialization options
lazy-layers-cache.ts
import { LazyLayersCache, RedisStore, RedisEventBus, } from "lazy-layers-cache"; const cache = new LazyLayersCache<string, User>({ // Layers l1: undefined, // default MemoryStore; false disables LRU memory l2: redis ? new RedisStore<User>(redis, { prefix: "users:", useIndex: true, scanCount: 1_000, batchSize: 500, deleteStrategy: "unlink", }) : false, // Cache policy ttlMs: 60_000, levels: { L1: { maxEntries: 1_000, ttlMs: 10_000 }, L2: { maxEntries: 100_000, ttlMs: 60_000 }, }, inflight: { enabled: true, ttlMs: 5_000, maxEntries: 1_000 }, distributedLock: { enabled: true, ttlMs: 10_000, waitTimeoutMs: 2_000, pollMs: 50 }, negativeCache: { enabled: true, ttlMs: 5_000, maxEntries: 10_000 }, failSafe: { enabled: true, staleTtlMs: 300_000 }, timeouts: { softMs: 100, hardMs: 2_000 }, versioning: { enabled: true }, // Distributed invalidation eventBus: redis ? new RedisEventBus(redis, "cache:invalidations") : undefined, source: process.env.INSTANCE_ID, subscribeToEvents: true, broadcastSet: true, // peers populate L1 from getOrSet results broadcastSetMaxBytes: 256 * 1024, eventDedupeMaxEntries: 10_000, eventDedupeTtlMs: 300_000, // Resilience and hooks resilience: { l2CircuitBreaker: { failureThreshold: 3, cooldownMs: 30_000 }, eventBusCircuitBreaker: { failureThreshold: 3, cooldownMs: 30_000 }, }, logging: { env: "production" }, events: [(event) => metrics.increment(event.type)], });
l1CacheStore | false | undefined
Undefined creates the default in-process LRU. Pass false to avoid L1 allocation; on server restart, no LRU is recreated.
l2CacheStore | false | undefined
Pass RedisStore for shared Redis L2. Pass false when Redis is intentionally off. Runtime L2 errors fail open.
ttlMs / levelsnumber / L1 + L2 policy
Global TTL plus per-level TTL and maxEntries. L1 is usually shorter; L2 is usually longer.
inflightenabled, ttlMs, maxEntries
Same-process request coalescing for concurrent getOrSet calls on the same key.
distributedLockenabled, ttlMs, waitTimeoutMs, pollMs
Cross-instance stampede control when the L2 store supports locks, such as RedisStore.
failSafe / timeoutsstale fallback
Keep last-known-good values and return stale data on loader errors or timeout paths.
eventBus / sourcedistributed invalidation
Redis Pub/Sub, RabbitMQ, or NATS invalidation with a unique source per cache instance, event dedupe, and generation checks for late events.
types<K, V> generics
Use <string, User> for one domain cache. For mixed values, use a union or JsonValue. JavaScript users omit generics.
vs. other libraries
Library Best fit Tradeoff
lazy-layers-cache Focused Node.js L1/L2 with explicit invalidation, Redis-optimized path, stampede & fail-open Early project · fewer drivers
BentoCache Full-featured caching framework with broad driver support Larger API surface · more decisions to make
cache-manager General cache abstraction layer Less opinionated invalidation and resilience
Keyv Simple key-value abstraction Not a full cache orchestration layer
Direct Redis Maximum control over every operation You build stampede protection, invalidation, retries, and metrics yourself
API reference
instance methods
await cache.set(key, value); await cache.get(key); await cache.getOrSet(key, loader); await cache.has(key); await cache.delete(key); await cache.deleteByPattern("user:*"); await cache.clear(); await cache.size(); // Hookable event stream const unsub = cache.on((event) => { metrics.increment(`cache.${event.type}`); }); unsub(); // remove handler
named exports
import { createCache, HybridCache, LazyLayersCache, MemoryStore, RedisStore, RedisEventBus, RabbitMQEventBus, NatsEventBus, } from "lazy-layers-cache"; import type { CacheOptions, CacheStore, LazyLayersCacheOptions, InvalidationEvent, EventBusHealth, } from "lazy-layers-cache"; // Subpath imports import { RedisEventBus } from "lazy-layers-cache/event-bus";
Production notes
checklist.md
# ✓ Short L1 TTLs, longer L2 TTLs # ✓ Run eventBus.healthCheck() before server start # ✓ Unique INSTANCE_ID per deployment (via env var) # ✓ Set INSTANCE_ID as source for event deduplication # ✓ Durable queues: own queue per instance # ✓ NATS JetStream: own durableName per instance # ✓ Prefer negative caching over Bloom filters # ✓ Distributed locks only for expensive loaders # ✓ logging.env: "production" in prod # ✓ Idempotent, timeout-aware loader functions # ✓ deleteStrategy: "unlink" for large values # ✓ NATS server: start with -js for JetStream mode # NOTE Set broadcastSetMaxBytes for large payloads # NOTE Avoid frequent broad pattern deletes on large L1 maps