V0.6.2 · CLOUDFLARE KV SUPPORT

Cache in layers.
Run it your way.

LazyLayers gives Node.js and Cloudflare Workers a shared cache read path. Keep hot reads in memory, back them with Redis or Cloudflare KV, and load missing values through one call.

npm i lazy-layers-cache

MIT licensed Start local, add Redis or Cloudflare KV

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
  • Cloudflare Workers · KV · Queues
  • 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.

Cloudflare KV + LazyLayers

Global KV storage.
A complete cache read path.

Run the same cache API in Hono on Workers, Hono on Node.js, or another Node.js framework. Cloudflare stores the shared values. LazyLayers adds lazy reads, local dedupe, adaptive MessagePack and gzip, TTLs, pattern deletion, and application invalidation messages.

worker.tsHono · Workers
const cache = new CloudflareWorkerCache(env.CACHE, {
  prefix: 'users-api:',
  l1, // optional: this isolate's short-lived memory
  compression: 'auto',
  invalidationPublisher: queuePublisher,
})

const user = await cache.getOrSet(key, loadUser)

// After the database write commits:
await cache.invalidateByPattern('tenant:42:user:*')
optional isolate L1Cloudflare KVorigin loader

L1 lasts only while that Worker isolate is reused. Another isolate starts empty, and Cloudflare may evict it at any time.

What each layer does

Cloudflare provides the platform primitives. LazyLayers supplies the cache behavior your app calls.

ConcernCloudflare providesLazyLayers adds
Shared cacheWorkers KV stores and reads keys across locations.One L2 adapter for Worker bindings and Node.js REST, using the existing HC1 MessagePack format.
Lazy loadingKV exposes reads and writes. Your application owns the origin loader.getOrSet runs that loader on a miss and fills KV and the optional isolate L1 for the next read.
Payload and costKV charges for stored bytes and per-key operations; data transfer has no KV egress charge.Adaptive gzip compresses MessagePack only when it saves at least 15% of packed bytes. Local L1 hits can avoid KV reads; compression does not reduce operation charges.
Thundering herdKV has no atomic per-key loader lease.In-flight calls share one loader within a Worker cache or Node.js process. Cross-instance misses can still duplicate.
Expiry and purgeKV has a 60-second minimum physical expiry and prefix listing.Logical TTLs, scoped wildcard deletion, and a bounded Worker scan.
Invalidation deliveryQueues deliver application messages to a consumer with retries.invalidate and invalidateByPattern publish key or pattern messages; the consumer repeats KV deletion.

Gzip is tried for packed values from 1 KiB and kept only when it saves at least 15% of packed bytes; savings depend on your data. KV is eventually consistent. Queue retries do not push into every isolate's L1. Cloudflare Event Subscriptions report builds and namespace lifecycle, not cache key changes. KV pricing ↗

Quickstart

Your first cache, in a few lines.

Start in memory. Add Redis for Node.js or Cloudflare KV for Workers and Node.js when your service needs shared storage.

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 on in-memory LRU, Redis, and Cloudflare KV. 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. Start with an in-process LRU cache. Node.js can add Redis or Cloudflare KV as shared L2; Workers can use a KV binding. Redis L2 supports distributed lock renewal and peer priming when paired with a bus. KV has no cross-instance loader lease; its optional application Queue retries invalidation in KV.

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+ and Cloudflare Workers. Start in memory, then add Redis or Cloudflare KV when you need a shared L2.