V0.5.3 · OPEN SOURCE. READY TO BUILD WITH.

Node.js cache.
Built in layers.

LazyLayers is a TypeScript tiered cache system for Node.js. Keep hot reads in memory with L1, add Redis L2 when instances share data, and coordinate invalidation as your app scales.

npm i lazy-layers-cache

MIT licensed No Redis required to start

ONE API. EVERY LAYER.getOrSet()
Fast local reads, coordinated across instancesThree Node.js instances each have a local L1 cache. Thin data paths connect directly to shared Redis L2. A separate teal pipe is the event bus, supporting Redis Pub/Sub, RabbitMQ, or NATS. LazyLayers coordinates peers; the origin loader runs when a requested value is missing. INSTANCE 01 INSTANCE 02 INSTANCE 03 L1 HITS EVENT BUS REDIS L2 Load only on a miss ORIGIN
await cache.getOrSet(key, load);
Works with
  • Node.js 20+ runtime
  • TypeScript types built in
  • Redis L2 store · pub/sub
  • RabbitMQ durable bus
  • NATS.io core · JetStream
  • MessagePack lz4 · zstd · gzip
CACHE AT SCALE WORK SAVED
LazyLayersLazyLayers connects local memory, shared storage, and coordinated application instances. LazyLayers LESS WORK. AT EVERY LAYER.
01Cold starts & expiry
Share the refreshConcurrent requests for the same key share an in-flight load. Redis L2 automatically coordinates cold and expired keys across instances and renews active locks. N requestsdedupe + lock1 load
Share the refresh
02Network round trips
Keep hot reads localAn L1 memory hit returns locally, without a Redis round trip. appL1 hitL2 skipped
Keep hot reads local
03Drifting L1 caches
Coordinate every instanceA healthy event bus carries invalidations and optional value priming between peers. Propagation is asynchronous. v2 v2 v2invalidate · prime · fan out
Coordinate every instance
04Repeated missing keys
Remember the missesShort-lived negative entries avoid repeated origin lookups for a missing key. not foundshort TTLDB spared
Remember the misses
05Oversized payloads
Store less. Send less.MessagePack and size-aware compression reduce payload bytes, with a serialization CPU trade-off. JSONMessagePack + compressionfewer bytes
Store less. Send less.
06Invisible cache behavior
See every layerOptional live metrics expose hits, misses, invalidations, and dependency health. cache eventslive metrics
See every layer
Automatic stampede protection

Cold starts.
Expired keys.
Same simple call.

cache.getOrSet(key, loader)

With Redis L2, coordination is built in. No lock configuration required.

How refresh protection works
  1. Share the refresh

    Requests for the same missing or expired key wait for the shared load across instances.

  2. Keep slow loads protected

    Redis renews the active lock automatically. Waiters retry released locks if the original loader fails.

  3. Keep waiting bounded

    At the wait deadline, use eligible stale data or return a clear error. A slow refresh does not trigger an unlocked load by default.

Quickstart

Your first cache, in a few lines.

Start in memory. Add Redis and an event bus when your service needs them.

cache.ts
import { LazyLayersCache } from 'lazy-layers-cache';

// Local memory. No Redis or event bus needed.
const cache = new LazyLayersCache({
  ttlMs: 60_000,
  levels: { L1: { maxEntries: 10_000 } },
});

// Same key, concurrent calls: one loader per process.
const user = await cache.getOrSet(`user:${id}`, () =>
  db.users.findById(id)
);
Why LazyLayers

Every server keeps its own memory.
Without a bus, caches drift.

Without invalidation, another instance can continue serving its stale in-process L1 copy until that entry's TTL expires. With a healthy invalidation bus, the stale window collapses from minutes down to network propagation latency.

Without a bus, L1 caches driftThe first Node.js instance has a new value, while two disconnected peers retain their old L1 values until expiry. INSTANCE 01fresh · v2 INSTANCE 02stale · v1 INSTANCE 03stale · v1
UNDER PRESSURE FALLBACK PATHS
01Redis unavailable
Requests keep movingWhen L2 fails, the circuit breaker skips the unhealthy cache and requests can fall through to the origin loader. Origin availability still matters. APPRedis offlineorigincircuit open → bypass L2
Requests keep moving
02Loader fails or stalls
A fallback, when it mattersWhen fail-safe is enabled and a retained stale value is available, serve that value on loader error or timeout. loader timeoutserve retained stale valueAPP
A fallback, when it matters
03The bus disconnects
Buffer brief interruptionsBounded publish queues retry brief event-bus failures. This does not guarantee delivery during prolonged outages or process restarts. bus disconnectedbounded publish retriesPEER APEER B
Buffer brief interruptions
04Events arrive late or twice
Reject stale & duplicate eventsGeneration checks filter stale invalidations and event IDs deduplicate replayed events. This is not a global ordering guarantee. v3 v2 v2 v3generation + event ID checks
Reject stale & duplicate events
Coherence on the wire

Three events.
Only one carries a value.

del and pattern just say what to forget. set brings the newly loaded value with it, allowing peer instances to warm L1 directly without redundant database trips.

del Invalidate one key no value
{
  type: "del",
  keys: ["user:42"],
  source: "node-1",
  generation: 7,
  ts: 1740086400000
}

Every peer drops user:42 from L1, L2, negative, stale and inflight. Only key and generation metadata travel.

pattern Invalidate wildcard pattern no value
{
  type: "pattern",
  pattern: "user:*",
  source: "node-1",
  generation: 7,
  ts: 1740086400000
}

Every peer drops each local entry whose key matches the wildcard pattern. No values travel on the wire.

set Prime peers with the value carries a value
{
  type: "set",
  keys: ["user:42"],
  value: { id: 42, plan: "pro" },
  generation: 8,
  ttlMs: 60000
}

The newly loaded value is broadcast so peers warm L1 directly from the event without running the loader again.

One shared event bus, coordinated peersThe publishing instance sends an event through a shared tubular event bus. Connected peers apply invalidations or optional value priming. Redis Pub/Sub, RabbitMQ, and NATS are supported transports. INSTANCE 01publish INSTANCE 02apply INSTANCE 03apply EVENT BUS · del / pattern / set
Redis Pub/SubRabbitMQNATS / JetStream

Per-key generations reject older del and set events, reducing stale resurrection under reordering. Read the consistency model →


Benchmarks

Spend CPU where bytes are expensive.

Redis payload size using each library's default storage format. BentoCache default measurement includes its CacheEntry metadata envelope ({ value, createdAt, logicalExpiration }) because that is written to Redis; LazyLayers manages metadata separately and stores compact binary values directly.

Payload
Raw JSON
BentoCache Default
LazyLayers
Saved
Encoding chosen
Session token
Small, hot, read on every request
142 B
212 B
123 B
−42.0%
msgpack
User profile
Nested objects, mixed types
354 B
424 B
284 B
−33.0%
msgpack
API list (50)
Paginated list endpoint
17.6 kB
17.7 kB
3.4 kB
−80.6%
msgpack-zstd
Metrics (24h/1m)
1,440 numeric points — binary encoding territory
116.3 kB
116.4 kB
30.4 kB
−73.9%
msgpack-zstd
Product catalog
400 records with repetitive text
122.6 kB
122.6 kB
9.8 kB
−92.0%
msgpack-zstd

Node v24.18.0 · Apple Silicon · BentoCache 1.6.1 vs LazyLayers 0.5.0 · byte counts deterministic, throughput median of 15 reps

Wire encoding

One record. Every byte accounted for.

Most of a JSON document is punctuation and repeated key names. Binary MessagePack stores the entire record in fewer bytes than JSON spends on values alone.

Byte-for-byte comparison of one cached session record The JSON encoding of one session record uses 212 bytes: 21 of structural punctuation, 64 of repeated key names and 127 of actual values. The MessagePack encoding of the same record uses 123 bytes in total — fewer than JSON spends on values alone. JSON 212 B MESSAGEPACK 123 B
Punctuation 21 B
Key names 64 B
Actual values 127 B
MessagePack, all of it 123 B

85 of 212 bytes — 40% of that record — is punctuation and key names you already know. The binary encoding fits the whole record into less than JSON spends on values alone.

Cost model

What the bytes are costing you

The ratios are measured. Everything else is yours to set.

1,000,000
$28
2
Estimated annual saving
$0

Adjust the inputs to model your workload.

BentoCache default payload
LazyLayers payload
Bytes saved per replica set
Encoding chosen

Estimate only. Payload bytes exclude Redis key/object overhead, allocator fragmentation, indexes, persistence, and provider-specific pricing.

Observability

Don't guess whether your cache works.

Inspect L1/L2 entries, TTL countdowns, compression savings, hit ratios, inflight reuse, and real-time invalidation traffic directly from the built-in live dashboard at /__lazylayers.

LazyLayers Inspector — http://127.0.0.1:7077/__lazylayers LIVE
L1 Hit Rate 94.8% 12.4k req/s
Inflight Coalesce 99.9% 1 db loader / spike
Redis Bytes Saved −78.4% Zstd + LZ4 tiered
Bus Propagation < 1.2ms Redis / NATS / Rabbit
Live Invalidation & Priming Stream
SET PRIMED user:9841 fanned to 3 peers · 284 B · L1 primed
DEL session:x7f9 invalidated cluster-wide · gen: 14
PATTERN catalog:prod:* wiped 42 local L1 keys · wildcard fanout
Transports

Choose your delivery semantics.

At-most-once for ultra-low latency fanout, or durable brokers for persistent message delivery on reconnect.

Redis Pub/Sub At-most-once

Ephemeral. Zero extra broker infra if you already use Redis.

NATS Core At-most-once

Fastest fanout and lowest latency. No replay for disconnected peers.

RabbitMQ Durable

Persistent messages and dedicated per-instance queue bindings.

NATS JetStream Durable

Replayable streams, explicit acks, redelivery tracking, max-deliver.

Engineering honesty

When you should NOT use LazyLayers.

No infrastructure library is right for every workload. If your architecture has these requirements, LazyLayers is the wrong choice.

You need strongly consistent or linearizable reads

LazyLayers uses asynchronous bus invalidation and generation counters. It is an eventually consistent caching layer, not a distributed consensus engine like Raft.

You need ACID transactions across cached keys

Operations on L1 and L2 are individually atomic per key, but there is no multi-key distributed transaction coordinator.

The cache is your primary source of truth

LazyLayers is built to sit in front of databases and APIs. Cache entries can be evicted, expired, or purged at any time.

You need dozens of database/storage adapters

LazyLayers focuses intentionally on in-memory LRU and Redis. If you need Postgres, SQLite, MongoDB, or DynamoDB as cache stores, libraries like Keyv or BentoCache are better suited.

You need complex hierarchical tagging taxonomies today

LazyLayers currently provides key prefixes and wildcard pattern invalidations (e.g. user:*). Complex multi-tag intersections are not supported in pre-1.0.

Your values are micro-strings and serialization speed dominates

We deliberately spend some CPU cycles packing MessagePack and compressing tiered payloads to save 33%–92% Redis bytes. If storing 20-byte strings at 5,000,000 ops/sec, raw JSON.stringify will be faster.

“A cache should be allowed to be wrong for a bounded amount of time. If a value must never be stale, don't cache it.”

Ecosystem comparison

LazyLayers vs BentoCache vs Keyv / Cacheable.

We let competitors win where they excel. Here is a feature-by-feature comparison across Node.js caching libraries.

Capability LazyLayers BentoCache Keyv / Cacheable
In-memory L1 (LRU) ✓ Built-in ✓ Built-in ✓ Built-in
Shared Redis L2 ✓ Built-in ✓ Built-in ✓ Built-in
Read-through getOrSet ✓ Built-in ✓ Built-in ✓ Built-in
Per-process inflight dedupe ✓ Automatic ✓ Automatic ✓ Automatic
Cross-instance stampede lock ✓ Built-in Redis lock ✓ Built-in driver lock Plugin required
Cross-instance invalidation ✓ Redis, RabbitMQ, NATS ✓ Redis, Memory bus Varies by adapter
Value broadcast / L1 priming ✓ First-class (broadcastSet) Delete-focused
NATS Core & JetStream ✓ Built-in native drivers Custom driver needed
RabbitMQ durable bus ✓ Built-in amqplib driver ✓ Community bus
Redis payload compression ✓ MessagePack + LZ4/Zstd tiered JSON default envelope JSON / manual plugin
Live UI cache inspector ✓ Built-in (/__lazylayers) External APM tooling
Storage driver ecosystem Focused (Memory, Redis) Large (Redis, Memcached, Dynamo, Files) Very large (Postgres, Mongo, SQLite, etc.)
Tagging & Namespaces Prefixes & Wildcard patterns Comprehensive tag trees Strong namespaces
Maturity Active (v0.5.x) Mature (1.x) Mature (1.x)

FAQ

The questions engineers
actually ask

Technical answers about guarantees, trade-offs, and failure boundaries.

Yes. Cold starts and expired keys use the same protection. Concurrent callers share an in-flight load, and Redis L2 automatically coordinates refreshes across instances. If only L1 expires, a live L2 value refills it without calling your loader. No lock configuration is required.

Redis locks renew automatically while the load is active. Other instances wait for the shared result, with a budget derived from the lock TTL and loader timeout. If that wait expires, getOrSet serves eligible stale data or throws DistributedLockTimeoutError instead of starting an unlocked load. Detected lock loss aborts the loader signal. This reduces duplicate work but cannot guarantee exactly-once execution during outages or when a loader ignores cancellation.

Every application instance keeps its own fast in-process L1 LRU cache. When one instance updates or deletes a record, other instances would otherwise continue serving their stale local copy until its TTL expires. An invalidation bus notifies peer instances immediately, reducing the stale window to network propagation latency.

Invalidation is delete-only: peers receive a key deletion event, drop the local entry, and reload it from L2 or database on their next read. Priming goes further: when a getOrSet loader resolves, the loaded value is broadcast so peers can warm their local L1 directly without executing the database loader. Set broadcastSet: false if you prefer delete-only fanout.

LazyLayers uses three distinct checks: (1) Event deduplication by ID with an LRU ring-buffer catches redeliveries on durable brokers; (2) Source filtering ignores self-broadcasts; (3) Per-key generation counters reject incoming events that are older than the local state, preventing stale sets from overwriting newer deletes.

Native JSON.stringify is built in C++ inside the V8 engine and heavily optimized. LazyLayers uses MessagePack and size-tiered LZ4/Zstd compression. We deliberately trade modest CPU time on writes/reads to reduce Redis payload size by 33%–92%. This substantially lowers Redis memory costs, network payload transfer, and replica sync bandwidth.

Under 256 bytes: raw MessagePack (no compression, since headers expand small buffers). Between 256 bytes and 4 KB: LZ4 (fastest compression with low CPU overhead). Above 4 KB: Zstd (where compression ratios produce the largest byte savings per microsecond). On Node 20 runtimes without native Zstd, it automatically falls back to LZ4.

No. LazyLayers is designed for progressive adoption. You can use it as a standalone, zero-infrastructure in-process LRU cache. You can add Redis when you need a shared L2 store, add Pub/Sub or RabbitMQ/NATS when scaling out across multiple servers, and get automatic distributed locking and renewal whenever Redis L2 is present.

Set observability: true in cache options. LazyLayers exposes a lightweight, zero-dependency dashboard at /__lazylayers (with /observelazyily supported as an alias). It includes real-time L1/L2 memory inspection, compression savings gauges, and Prometheus metrics at /__lazylayers/metrics.

Yes. All fixtures are seeded and deterministic, so byte counts reproduce identically on any machine. Throughput benchmarks use 15-repetition fixed-iteration timing in benchmarks/run.mjs.

Start local.
Scale distributed.

MIT licensed · TypeScript first · ESM + CommonJS · Node 20+. Start as an in-process LRU with zero external dependencies, then add Redis and event buses as your architecture demands.