LazyLayersv0.5.3
Reference

Configuration

Every option, its exact type, its real default, and what it costs you when it is wrong.

setupCache is the production entry point. It accepts the full LazyLayersCache option surface, resolves L1, Redis L2, and Redis Pub/Sub, then applies the bounded setup defaults called out below. The subset that also exists on CacheOptions can be overridden per call on getOrSet or prewarm.

src/cache/index.js
import { setupCache } from 'lazy-layers-cache'

export const cache = await setupCache({
  namespace: 'billing-api',
  redis: { required: true },
})

new LazyLayersCache(options) remains the low-level constructor. It never creates external infrastructure. Where its defaults differ from setupCache, this page names both.

Tuning for production means changing numbers, not disabling safety. In-flight dedupe, fail-open L2 behaviour and broadcastSet are load-bearing. inflight: { enabled: false }, failSafe: { enabled: false } and broadcastSet: false are not production advice, they are ways to reintroduce the failures this library exists to prevent. What you tune is entry counts, TTLs, byte ceilings, thresholds and cooldowns, sized to the instance the process runs on.

Lifetime and layers

ttlMs(number)default: 3600000

Fallback lifetime for every entry, in milliseconds. One hour. Set too high, you serve stale data between invalidations. Set too low, your hit rate collapses and the origin absorbs the difference.

levels.L1({ maxEntries?: number; ttlMs?: number; maxMemory?: MemoryLimit; minMemory?: MemoryLimit; autoEvict?: { enabled?: boolean }; admission?: { enabled?: boolean; maxEntryBytes?: number } })

The in-process layer. maxEntries defaults to 1000. setupCache sets ttlMs to 10 seconds. The low-level constructor falls back to the cache-wide ttlMs, then to one hour.

Eviction order is LRU and is not configurable. Values are retained in the serializer wire format, so L1 can enforce a shared byte budget as well as an entry count.

levels.L2({ maxEntries?: number; ttlMs?: number; codec?: CacheCodecOptions })

The shared layer. ttlMs falls back to the cache-wide ttlMs, then to one hour. Keep it well above L1's, because L2 is what survives a deploy.

maxEntries has no default. When set, RedisStore enables its optional namespace index and trims the oldest expiry entries on write. This is a bounded, best-effort cache policy under concurrent writers, not a transaction quota.

l1(CacheStore<K, V> | false)

A custom in-process store, or false to run without one. Defaults to a MemoryStore built from your options.

Disabling L1 costs you a network round trip on every single read, which is most of what you came here for. The one honest reason is a process where a stale local copy is unacceptable for the length of an L1 TTL.

l2(CacheStore<K, V> | false)

The shared store, usually a RedisStore. setupCache creates one when a Redis client or URL is available. The low-level constructor has no L2 default.

versioning.enabled(boolean)default: false

Appends ::v{generation} to the storage key, so a delete moves the whole key to a new namespace instead of racing a redelivered write.

It costs you: after every delete the old versioned entry is orphaned in L2 until its TTL expires, so your L2 holds more keys than your logical keyspace. This is read from the constructor options only, so a per-call versioning is ignored.

TTL resolution runs most specific to least: per-call level, per-call ttlMs, constructor level, constructor ttlMs, then the one hour default.

Sizing L1

maxEntries is still a count. maxMemory is the shared process-local byte ceiling for built-in L1 stores, expressed as bytes or a percentage of the effective host or cgroup memory. It defaults to 20%. minMemory sets the lower floor used during pressure recovery. admission.maxEntryBytes rejects one oversized value before it evicts a useful working set.

autoEvict samples available memory and cgroup pressure to lower the active target during sustained pressure. It does not make L1 a process RSS limit. Keep maxMemory below the memory available to your application, other libraries, and the Node.js runtime.

Typical valueRough size10,000 entriesReasonable ceiling
Session or flag~200 B~2 MB50,000+
User record~2 KB~20 MB10,000
Rendered list page~18 KB~180 MB1,000

Under Node clustering, every worker keeps its own L1 and process-local byte budget. Divide the host allowance across workers or give each container an explicit memory limit. Lazy Layers does not coordinate one host-wide budget.

memoryBudget(MemoryBudget)

Share an explicit MemoryBudget between cache instances in the same process. Without one, built-in MemoryStore instances use the process default budget. Set this at construction time, not per call.

RedisStore options

Passed to the RedisStore constructor, not to the cache.

prefix(string)default: cache:

Namespaces logical keys. The v2 layout adds its version prefix and hash tag before this namespace. Two applications sharing a Redis instance with the same prefix will invalidate each other's keys.

indexKey(string)default: `${prefix}__index`

The optional sorted set that tracks live keys by expiry deadline. Change it only if it collides with something you already store.

useIndex(boolean)default: false

Maintain the index so pattern deletes and size use ZSCAN instead of scanning the whole keyspace. levels.L2.maxEntries selects it automatically. Leave it off when Redis-native TTL and operator-managed eviction are sufficient.

scanCount(number)default: 1000

Keys per scan step. Higher finishes sooner and blocks the Redis event loop for longer per step.

batchSize(number)default: 500

Keys per delete pipeline. Raising it makes a large invalidation one long stall instead of several short ones.

deleteStrategy('unlink' | 'del')default: unlink

unlink frees memory on a background thread, so a large delete does not block the Redis event loop. Choose del only for a Redis older than 4.0.

keyLayout('auto' | 'legacy' | 'v2')default: 'auto'

auto uses v2 per-entry, same-slot data and lease keys when Redis scripts are available. The v2 and legacy layouts do not dual-read. Drain or backfill the legacy namespace before moving all writers to v2.

Stampede protection

inflight.enabled(boolean)default: true

Collapses concurrent callers for one key into a single loader call. Ten thousand simultaneous requests for a cold key produce one query.

Already on. Setting it to false hands every concurrent caller its own loader call, which is the exact thundering herd this library was built to stop.

inflight.ttlMs(number)default: derived

How long an in-flight promise stays joinable. By default this covers the loader hard timeout, the distributed wait budget when locking is active, and a 50 ms margin, with a 5,000 ms minimum. Defaults are 10,050 ms without locking and 20,100 ms with Redis locking. Settled entries are removed immediately.

An explicit value overrides the derived lifetime. Setting it too low allows a later caller to start another load while the original is still active.

inflight.maxEntries(number)

setupCache defaults to 10000. The low-level constructor leaves it unbounded. Cap it if a burst across many distinct cold keys could grow the map faster than loaders drain it.

Past the cap, loads still run, just without dedupe, and each one emits inflight:bypass. Setting it to 0 bypasses tracking for every load, which disables dedupe entirely.

originLoad({ enabled?: boolean; maxConcurrent?: number; maxQueued?: number; queueTimeoutMs?: number })

Bounds concurrent executions that reach the origin loader. Defaults are 32 active loaders, 1024 queued distinct keys, and a 1000 ms queue deadline. In-flight dedupe runs before this gate, and waiting for a distributed lock does not consume an origin slot.

negativeCache.enabled(boolean)default: true

Remembers that a loader returned undefined, so a missing row is not a free pass to hammer the origin on every retry.

On by default with a 10 second TTL.

negativeCache.ttlMs(number)default: 10000

How long a recorded miss suppresses the loader.

Too long and a row created a second after the miss stays invisible for the rest of that window. Seconds, not minutes.

negativeCache.maxEntries(number)default: 10000

Caps remembered misses. Eviction here is oldest-inserted, not LRU.

distributedLock.enabled(boolean)default: true

Serializes cold loads for one key across servers, not just within a process. In-flight dedupe already covers one process. This covers the case where fifty servers all miss the same key at the same instant.

Requires an L2 that implements acquireLock and releaseLock, which RedisStore does. Without one, it is a no-op. With Redis L2, every cold load uses the per-key lock unless you explicitly opt out.

distributedLock.ttlMs(number)default: 10000

Lease lifetime. RedisStore renews it automatically while the load and write are active. Custom stores without renewLock must finish within this lifetime.

distributedLock.waitTimeoutMs(number)default: derived

Maximum contention wait. Defaults to max(distributedLock.ttlMs, timeouts.hardMs) + distributedLock.pollMs, initially 10,050 ms. Waiters recheck the cache and retry released locks. At the deadline, eligible stale data is returned or DistributedLockTimeoutError is thrown by default. An explicit value overrides the derived budget.

distributedLock.pollMs(number)default: 50

Interval between polls while waiting. Each poll is a real read through L1 and L2, so a small value multiplies L2 traffic across every waiting server.

distributedLock.onTimeout(string)default: throw

throw uses eligible stale data when fail-safe is enabled, otherwise it raises DistributedLockTimeoutError. load explicitly permits an unlocked loader after contention times out, which can duplicate another instance's work. No setting is needed for the default protection.

Resilience

failSafe.enabled(boolean)default: true

Serves the last known value when the loader fails or times out, instead of propagating the error.

On unless you explicitly disable it. A stale value is returned instead of an origin error while the stale window remains open.

failSafe.staleTtlMs(number)

Defaults to 30_000. How long a value stays usable as a stale fallback past its normal expiry. Raise it when your origin takes a long time to recover, lower it when serving an old value is worse than serving an error.

The stale copy lives in this process's memory, so it does not survive a restart and is not shared between servers.

failSafe.maxEntries(number)

Caps retained stale fallbacks. Defaults to 1000.

failSafe.maxBytes(number)

Caps retained stale fallback bytes. Defaults to 16777216 bytes.

timeouts.softMs(number)

No default. Stop waiting on the loader and serve the stale copy.

It applies only when fail-safe is enabled and a stale value exists for that key. In that case the soft timeout is the one that runs and hardMs is not consulted at all. Set it near your p99 loader latency, since anything lower trades freshness for latency on every slow load.

timeouts.hardMs(number)default: 10000

Gives up on the loader entirely and rethrows, unless a stale copy is available to fall back on.

Applies whenever the soft path above does not, so it is your only protection on a cold key that has never been loaded.

Both timeouts abort the AbortSignal handed to your loader. If you do not pass that signal into your I/O, the timeout only stops the waiting, not the work.

resilience.l2CircuitBreaker({ enabled?: boolean; failureThreshold?: number; cooldownMs?: number })

Stops calling L2 after repeated failures. enabled defaults to true, failureThreshold to 3, cooldownMs to 30000.

While open, every L2 operation is skipped and emits l2:skipped, and reads fall through to L1 alone. A threshold of 1 opens on a single blip. A long cooldown keeps you off a recovered Redis. After the cooldown one probe is allowed through, and a single failure reopens the breaker.

resilience.l2OperationGate({ maxConcurrent?: number; maxQueued?: number; maxQueuedBytes?: number; queueTimeoutMs?: number; operationTimeoutMs?: number })

Bounds L2 work before it reaches the Redis client. Defaults are 64 active operations, 1024 queued operations, 32 MiB queued payload bytes, a 250 ms queue deadline, and a 2000 ms caller deadline. Timed-out Redis work remains accounted until its underlying command settles.

resilience.eventBusCircuitBreaker({ enabled?: boolean; failureThreshold?: number; cooldownMs?: number })

The same, for bus publishes. Skips emit event-bus:publish-skipped.

Sharp edge. The event-bus circuit breaker and the bus retry queue do not compose. When the breaker is open, publishing returns before it reaches the bus, so the retry queue never buffers that event and it is gone. Peers keep their stale copies until TTL. Keep cooldownMs short here, and treat event-bus:publish-skipped as a real signal rather than noise.

Event bus

eventBus(EventBus)

The transport that carries invalidations and primed values between servers. setupCache creates Redis Pub/Sub when Redis is available. The low-level constructor has no default.

With setupCache, health, connection, and subscription readiness are awaited for you. With the low-level constructor, connect the bus yourself and call await cache.ready() before accepting traffic.

source(string)

Identity stamped on published events, and how a server recognises and ignores its own broadcasts. Defaults to a random per-process id.

Give it a stable, unique value per server. Two servers sharing one source will each ignore the other's events, so invalidations silently stop working between them.

subscribeToEvents(boolean)default: true

Whether this server applies inbound events. Setting it to false gives you a publisher that never invalidates itself, which is what you want for a one-off writer process and never what you want for a server that also serves reads.

broadcastSet(boolean)default: true

Publishes a getOrSet loader result to every peer, carrying the value itself, so their L1 warms without any of them running the query. On whenever an event bus is configured.

Setting it to false leaves you with delete-only fan-out, which means every server pays for its own copy of every key. Tune broadcastSetMaxBytes instead.

broadcastSetMaxBytes(number)

setupCache defaults to 32 KB. The low-level constructor has no default. This is the encoded event size above which a value is not shipped to peers.

With no ceiling set, a five megabyte loader result is broadcast in full to every server, on every cold load, over whatever transport you chose. That is the option most likely to hurt you here. Over the limit, the publish is skipped and set:broadcast-skipped is emitted with reason: 'max-bytes'. The value is still written to L1 and L2 locally.

eventDedupeMaxEntries(number)default: 10000

How many recent event ids are remembered for duplicate suppression. Too small on a busy bus and an id ages out of the set before a redelivery arrives, so the duplicate is applied.

eventDedupeTtlMs(number)default: 300000

How long each event id is remembered. Five minutes. Keep it above your broker's worst-case redelivery window, which matters on RabbitMQ and JetStream where redelivery is the point.

Only a getOrSet loader result is broadcast. A direct cache.set() writes L1 and L2 and emits a local event, but never publishes to the bus, so it will not prime peers. See the API reference.

Bus-side options that change correctness

Full constructor options for each transport are in the event bus reference. Two of them decide whether the bus holds up under load.

retryQueue({ enabled?: boolean; maxSize?: number })

Buffers events that failed to publish so they go out on reconnect. maxSize defaults to 10000. Available on every transport. Read the sharp edge above: it cannot help with events the circuit breaker skipped.

prefetch(number)

RabbitMQ only, and it has no default. It is the only bound on how many invalidations are in flight to your handler at once, because the RabbitMQ handler dispatches without awaiting the result.

Leave it unset and a slow handler lets unacknowledged deliveries pile up without limit. Set it to a real number, in the tens. The Redis bus bounds the same thing with handlerConcurrency.

Serialization

levels.L1.codec and levels.L2.codec select independent write policies for built-in stores. Reads dispatch on the stored HC1 tag, independently of the write policy.

levels.L1.codec / levels.L2.codec(CacheCodecOptions)

{ format?: 'msgpack' | 'json', compression?: 'none' | 'gzip' | 'zstd' | 'auto' | CompressionTier[] }. Omitted fields use the process defaults. Set these at construction or pass level-specific overrides to getOrSet or prewarm.

format: 'json' stores tagged, uncompressed JSON. It follows JSON value semantics, so use JSON-compatible records. Explicit format: 'msgpack' overrides the JSON environment switches for that layer. Without an explicit format, CACHE_FORMAT=json or CACHE_DEBUG_SERIALIZATION=true selects JSON.

Compression modes

ModePacked-size tiers
autoNone below 256 B, LZ4 below 4 KiB, then Zstd when available or LZ4 otherwise. This is the shipped default
noneNo compression
gzipNone below 1 KiB, then gzip
zstdNone below 1 KiB, then Zstd, falling back to LZ4 when unavailable

All compression modes retain the compressed result only when it saves at least 15% against packed MessagePack.

Process-wide policy

configureCompression(mode: CompressionMode | CompressionTier[]): void
getCompressionTiers(): readonly CompressionTier[]

configureCompression changes the process-wide default for subsequent writes that omit a layer-specific compression policy. CACHE_COMPRESSION initializes that default when the serializer is imported. The last configureCompression call wins. getCompressionTiers reports this process default, not per-layer overrides.

src/cache/compression.js
import { configureCompression } from 'lazy-layers-cache'

configureCompression([
  { maxBytes: 256, codec: 'none' },
  { maxBytes: 4096, codec: 'lz4' },
  { codec: 'zstd' },
])
CompressionTier.maxBytes(number)

Exclusive upper bound in packed bytes. Bounds must ascend. Only the last tier may omit this field, meaning all remaining sizes. If every tier has a bound, larger payloads remain uncompressed.

CompressionTier.codec('none' | 'gzip' | 'zstd' | 'lz4' | 'snappy')

Codec attempted for this band. Unknown names are rejected during tier validation. Unavailable Zstd falls back to LZ4 at write time.

ZSTD_AVAILABLE(boolean)

Whether the current node:zlib provides Zstd compression and decompression.

LZ4_AVAILABLE() / SNAPPY_AVAILABLE()(() => boolean)

Compatibility helpers that return true in this release. LZ4 and Snappy are package dependencies.

serializeWithStats(value, policy) reports encoding, original bytes, stored bytes, and compression savings. deserialize returns null for corrupt data or an unavailable decoder, so a built-in store treats that entry as a miss. See serialization for compatibility and measurement guidance.

Observability

observability(boolean | ObservabilityOptions)default: false

Off by default, with zero hot-path cost when off. true starts the dashboard with the defaults below. Enabling it adds one O(1) handler to the event loop that is already running.

The dashboard exposes cache contents and live activity. It is a development and staging tool. Leaving it enabled in production, on default credentials, publishes your cached data to anyone who can reach the port.

observability.enabled(boolean)default: false

Master switch. Also enabled by a truthy LAZY_OBS_ENABLED.

observability.route(string)default: /__lazylayers

Base route. Normalised to a leading slash with no trailing slash.

observability.maxEvents(number)default: 1000

Size of the in-memory ring buffer for the live feed. Never persisted. Raising it costs heap on a process you are already trying to keep small.

observability.maxValueBytes(number)default: 262144

Decoded values larger than this are truncated in the UI. 256 KB.

observability.server({ host?: string; port?: number; autoStart?: boolean } | false)

Standalone server config. host defaults to 127.0.0.1, port to 7077, autoStart to true. Pass false to skip the server and mount getObservabilityHandler() on your own instead.

Changing host off localhost exposes the dashboard to your network. Do that only behind real authentication.

observability.auth({ username?: string; password?: string; token?: string; disabled?: boolean })

Credentials. username and password both default to lazydev, which is a development default and nothing more. token adds ?token= and bearer support. disabled: true removes authentication entirely.

The environment variable is LAZY_OBS_USER, not LAZY_OBS_USERNAME.

observability.prometheus(boolean | { enabled?: boolean; prefix?: string; public?: boolean })

Exposes an exposition endpoint at {route}/metrics. enabled defaults to false, prefix to lazycache, public to false. public: true allows unauthenticated scrapes of the metrics endpoint while the UI stays behind auth.

observability.quiet(boolean)default: false

Suppresses the one-time startup warning. Silence it only once you have actually secured the dashboard.

Every option above has an environment variable equivalent. They are listed in the environment variables reference.

Logging

logging.env('development' | 'production' | 'test')

Overrides NODE_ENV for log verbosity. Defaults to production when NODE_ENV is production, and development otherwise. Anything other than production prints debug lines on every hit, miss, set and delete.

logging.enabled(boolean)

No default. A hard override of the environment check. true forces debug logging on in production, which will fill your log pipeline at cache throughput. false silences it everywhere, including the error lines that tell you L2 is failing open.

events(CacheEventHandler[])

Handlers registered at construction, equivalent to calling on for each. Registering them here is what catches events emitted during startup, before you would have had a chance to subscribe.

Logger configuration is process-wide. The last cache you construct wins for every cache in the process.

Transaction coordination

lazy-layers-cache/transactions is a separate, primary-only Redis coordination API. It is not configured through setupCache, LazyLayersCacheOptions, CacheOptions, L1, L2, or the event bus. A cache's Redis client is not automatically a transaction authority.

Configure RedisOperationTransport with an isolated namespace, a diagnostic authorityId, and a client that is known to reach a Redis primary. Production clients must explicitly set enableOfflineQueue: false, autoResendUnfulfilledCommands: false, and maxRetriesPerRequest to 0 or 1. Cluster use additionally requires explicit clusterValidated: true. The transport rejects replicas, read-only clients, keyPrefix, and uninspectable routing options by default.

RedisOperationStore separately controls its lease, retention, and bounded dispatcher. Its defaults are a 30 second lease, seven day retention, 64 active commands, 1,024 queued commands, 4 MiB of queued command data, a 250 ms queue wait, and a 2 second command deadline. These limits constrain coordination traffic. They do not configure cache memory, Redis eviction, or a business transaction.

Read transaction coordination before using the API. It defines the required durable-record, provider-idempotency, reconciliation, and transactional-outbox responsibilities.

Per-call overrides

getOrSet takes a third argument of the same CacheOptions shape, so one call can differ from the cache-wide policy.

src/reports/annual.js
import { cache } from '../cache/index.js'
import { buildReport } from './build.js'

export function getAnnualReport() {
  return cache.getOrSet('report:annual', buildReport, {
    ttlMs: 24 * 60 * 60 * 1000,
    timeouts: { hardMs: 30_000 },
  })
}

Overridable per call: ttlMs, levels, inflight, negativeCache, failSafe, timeouts and distributedLock.

Constructor only: memoryBudget, originLoad limits, l1, l2, eventBus, source, subscribeToEvents, resilience, events, eventDedupeMaxEntries, eventDedupeTtlMs, logging, broadcastSet, broadcastSetMaxBytes and observability. versioning is accepted per call by the type but read from the constructor options only.

Where to next

On this page