LazyLayersv0.5.3
Reference

API

Managed production setup, every LazyLayersCache method, and the complete export surface.

setupCache is the production entry point. It returns a managed LazyLayersCache, which is the only object application code needs after startup.

Find a method

TaskMethod
Create and connect a managed cachesetupCache
Read through the cachegetOrSet
Warm a keyprewarm
Invalidate after a writeinvalidate
Invalidate a key familyinvalidateByPattern
Release resourcesclose

See configuration for option defaults and the quickstart for an application example.

Transaction coordination is a separate API

lazy-layers-cache/transactions coordinates attempts for a durable operation through a primary Redis record. It is not part of LazyLayersCache, setupCache, L1, L2, or the event bus. Do not use a cache loader or a cache lock to authorize a payment, order transition, or other business side effect.

The coordinator exposes begin, renew, complete, and readStatus. Its result unions distinguish acquired, in-progress, completed, and conflict. Its classified unavailable and unknown errors tell your application when to retry before dispatch or reconcile durable state instead of guessing. It does not execute your business action or return its business result.

Transaction coordination documents the exact import, client requirements, result unions, recovery flow, and the database, provider, and outbox responsibilities that remain outside the library.

Production setup

function setupCache<K extends CacheKey = string, V = unknown>(
  options?: SetupCacheOptions<K, V>,
): Promise<ManagedLazyLayersCache<K, V>>

setupCache resolves infrastructure, checks health, waits for the event subscription, and returns a cache with idempotent cleanup. It reads REDIS_URL when no client or URL is passed.

src/cache/index.ts
import { setupCache } from 'lazy-layers-cache'
import type { User } from '../types/user.js'

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

Redis key and channel namespace. Falls back to LAZY_LAYERS_NAMESPACE, then npm_package_name, then app.

redis(SetupRedisOptions | false)

Creates Redis L2 and Redis Pub/Sub from an existing client, an explicit url, or REDIS_URL. Pass false for L1 only. required: true rejects startup when Redis configuration is missing.

l2(CacheStore<K, V> | false)

Explicit store override. It wins over the generated Redis store.

eventBus(EventBus | false)

Explicit bus override. It wins over generated Redis Pub/Sub. Pass false to disable peer fan-out.

startup.requireHealthy(boolean)default: true

Requires configured shared infrastructure and the inbound subscription to be ready before setup returns.

startup.timeoutMs(number)default: 10000

Bounds each startup health, connection, and subscription readiness check.

All LazyLayersCacheOptions are accepted alongside these setup fields. See production setup for the resolved defaults.

Low-level construction

class LazyLayersCache<K extends CacheKey = string, V = unknown> extends HybridCache<K, V> {
  constructor(options?: LazyLayersCacheOptions<K, V>)
}

K is the key type and must extend CacheKey, which is string | number. V is the value type. Both default to string and unknown, so an unparameterised cache hands you unknown back and you cast at every call site. Name the types once at construction and every method is typed from there.

src/cache/index.js
import { LazyLayersCache, createCache } from 'lazy-layers-cache'

export const cache = new LazyLayersCache({ ttlMs: 5 * 60 * 1000 })

// Identical object, built by the factory helper.
export const same = createCache({ ttlMs: 5 * 60 * 1000 })

createCache

function createCache<K extends CacheKey = string, V = unknown>(
  options?: LazyLayersCacheOptions<K, V>,
): LazyLayersCache<K, V>

createCache(options) is a one-line helper that returns new LazyLayersCache(options). Same class, same options, same defaults, no extra behaviour. Use it when you prefer a function call to a new expression. Everything documented below applies to both.

HybridCache is the base class and holds all the logic. LazyLayersCache extends it and only forwards the constructor, so the two are interchangeable. LazyLayersCacheOptions<K, V> is an alias of HybridCacheOptions<K, V>. Every option lives in the configuration reference.

Methods

getOrSet

getOrSet(key: K, loader: CacheLoader<V>, options?: CacheOptions): Promise<V | undefined>

The read-through path, and the method to reach for in almost all code. It checks L1, then L2, then runs your loader, then writes the result everywhere it belongs and broadcasts it to your other servers.

key(K)required

The cache key. A string or number.

loader((context?: { signal: AbortSignal }) => Promise<V | undefined>)required

Runs only on a miss. Receives an AbortSignal that fires on loader timeout or detected distributed lock loss, so pass it into your I/O. Returning undefined records a negative entry instead of caching a value.

options(CacheOptions)

Per-call overrides for ttlMs, levels, inflight, negativeCache, failSafe, timeouts and distributedLock. Falls back to the constructor options for anything you leave out.

Returns the cached or freshly loaded value. It returns undefined when the loader resolves undefined and no stale copy is available, and short-circuits to undefined without calling the loader at all while a negative entry for that key is live.

src/users/repository.js
import { cache } from '../cache/index.js'
import { db } from '../db.js'

export async function getUser(id) {
  return cache.getOrSet(`user:${id}`, async ({ signal } = {}) => {
    const row = await db.users.findById(id, { signal })
    return row ?? undefined
  })
}

What happens on a miss, in order:

Dedupe

Concurrent callers for the same key join the in-flight promise, so the loader body runs once. If inflight.maxEntries is already full the call runs unregistered and emits inflight:bypass.

Coordinate across instances

Redis L2 automatically locks cold and expired keys and renews active leases. Waiters return the shared value or retry a released lock. The cache is checked again after acquisition before another loader can run.

Load

The loader runs under your timeouts with an AbortSignal. A failure serves the stale copy when fail-safe is on and one exists, and rethrows otherwise.

Store

The value is written to L1 and L2, remembered as the stale copy, and any negative entry for the key is cleared.

Broadcast

With an event bus configured, the value is published as a set event. Every peer writes it into its own L1, so their first read of that key is already a local hit. One server pays for the query.

The broadcast is the reason getOrSet earns its place over a manual read-then-write. Only a getOrSet loader result is published. See the difference between getOrSet, get and set.

setupCache sets broadcastSetMaxBytes to 32 KB. The low-level constructor has no ceiling. Values over the ceiling are still stored locally and in L2, and the skip emits set:broadcast-skipped with reason: 'max-bytes'.

With Redis L2, a contention deadline returns eligible stale data or throws the exported DistributedLockTimeoutError (code: 'DISTRIBUTED_LOCK_TIMEOUT', key, waitTimeoutMs). Detected lease loss aborts the loader signal and throws DistributedLockLostError (code: 'DISTRIBUTED_LOCK_LOST', key) unless eligible stale data can be served. No lock settings are required. See stampede protection for the automatic wait budget and failure limits.

prewarm

prewarm(key: K, loader: CacheLoader<V>, options?: CacheOptions): Promise<V | undefined>

The explicit warm-up name for the full getOrSet path. It checks existing layers first, runs one protected loader on a miss, stores the result, and primes peer L1 caches. Use it from deploy hooks, schedulers, or background jobs.

src/jobs/prewarm.js
await cache.prewarm('plans:active', ({ signal }) =>
  db.plans.findActive({ signal }),
)

get

get(key: K): Promise<V | undefined>

Reads what is already cached. Nothing more.

key(K)required

The cache key.

Checks the negative cache, then L1, then L2. A value found in L2 is written into L1 before it is returned, so the next read is local. Returns undefined for a negative-cached key, for a genuine miss, and when L2 fails or its circuit breaker is open, because L2 failures degrade rather than throw.

get never runs a loader, never dedupes, and never publishes anything.

src/users/warm-path.js
import { cache } from '../cache/index.js'

const cached = await cache.get(`user:${id}`)

if (cached === undefined) {
  // Nothing was loaded. You now own the miss.
}

set

set(key: K, value: V, options?: CacheOptions): Promise<void>

Writes a value you already have.

key(K)required

The cache key.

value(V)required

The value to store. It is serialized on the way into L2.

options(CacheOptions)

Per-call overrides, same shape as getOrSet.

Writes to L1 and to L2, remembers the value as the stale copy when fail-safe is on, clears any negative entry for the key, and emits a local set cache event for your on handlers.

set writes to L1 and L2 but does not publish to the event bus. Only a getOrSet loader result is broadcast. Every peer keeps whatever its own L1 already holds until that entry expires or you delete the key, so a plain set will not prime peers and can leave them serving the old value for a full L1 TTL.

src/users/import.js
import { cache } from '../cache/index.js'

// Seeding a value you computed elsewhere. Local layers only.
await cache.set(`user:${user.id}`, user, { ttlMs: 30 * 1000 })

Choosing between getOrSet, get and set

Runs a loaderDedupes concurrent callersWrites L1 and L2Primes peers
getOrSetyesyesyesyes
getnonopromotes an L2 hit into L1no
setnonoyesno

Reach for getOrSet unless you have a specific reason not to. get is for the rare case where a miss is not yours to fill, such as a health probe or a debug endpoint. set is for a value that arrived from somewhere other than a loader, and it costs you the broadcast.

This is the only page in these docs that uses get and set in an example. Everywhere else, a read is getOrSet, a warm-up is prewarm, and an invalidation is invalidate or invalidateByPattern.

has

has(key: K): Promise<boolean>
key(K)required

The cache key.

Returns false immediately for a negative-cached key, without consulting either layer. Otherwise it asks L1, then L2. An L2 failure returns false rather than throwing.

has does not decode the value, which makes it cheap on L2 but leaves you open to the usual race: the key can expire between your has and your read. Prefer getOrSet and check the result for undefined.

src/cache/probe.js
const warm = await cache.has(`user:${id}`)

delete

delete(key: K): Promise<void>
key(K)required

The key to invalidate everywhere.

Drops the key from L1, L2, the negative cache, the stale copy and any in-flight promise, advances that key's generation counter, then publishes a del event so every peer does the same. Call it after your write commits, not before.

src/users/repository.js
export async function updateUser(id, patch) {
  const updated = await db.users.update(id, patch)
  await cache.delete(`user:${id}`)
  return updated
}

invalidate

invalidate(key: K): Promise<void>

The application-facing name for delete. It has identical behaviour and is the preferred name after a source-of-truth write.

src/users/repository.js
const user = await db.users.update(id, patch)
await cache.invalidate(`user:${id}`)

deleteByPattern

deleteByPattern(pattern: string): Promise<void>
pattern(string)required

A glob pattern such as user:42:sessions:*.

Removes every matching entry from L1, L2, and the in-flight, negative and stale maps, then publishes a pattern event so every peer repeats the scan locally.

This walks the keyspace. L1 iterates every key it holds, and L2 scans Redis. deleteByPattern('*') does that on every server at once. Keep patterns tightly prefixed, and never build one from user input.

Unlike delete, a pattern event carries no generation counter, so the ordering guard that protects single keys does not apply to pattern invalidations.

src/users/repository.js
await cache.deleteByPattern(`user:${id}:sessions:*`)

invalidateByPattern

invalidateByPattern(pattern: string): Promise<void>

The application-facing name for deleteByPattern. It performs the same namespace scan and publishes the same pattern event.

clear

clear(): Promise<void>

Exactly deleteByPattern('*'). It empties this server's layers and publishes a pattern event that empties every peer's too, including the shared L2 you all read from.

clear is a production incident waiting for a caller. It is a test and local-development tool. To drop a bounded set of keys, use deleteByPattern with a real prefix.

size

size(): Promise<number>

Returns the L1 entry count when L1 exists. It only reports L2 when L1 is disabled, so with both layers configured this is the number of entries in this process's memory, not the total across the cache.

L2 sizing is approximate. RedisStore returns the cardinality of its index when useIndex is on, and otherwise counts keys with a full scan, which is expensive on a large keyspace.

src/cache/metrics.js
const l1Entries = await cache.size()

on

on(handler: CacheEventHandler): () => void
handler((event: CacheEvent) => void)required

Called synchronously for every cache event. A handler that throws is caught and logged, so it cannot break a read.

Returns an unsubscribe function. Passing handlers to the events constructor option is equivalent to calling on for each of them at startup.

src/cache/metrics.js
import { cache } from './index.js'

const off = cache.on((event) => {
  if (event.type === 'set:broadcast-skipped') {
    console.warn('value too large to broadcast', event.bytes, event.maxBytes)
  }
})

// Later, during shutdown.
off()

The full event union is in the cache events reference.

Never use a cache key as a metric label. Keys are unbounded, and one high-cardinality label will take down your metrics backend before it takes down your cache.

Observability methods

getObservabilityHandler(): ObservabilityRequestHandler | undefined
getObservabilityServer(): ObservabilityServerHandle | undefined
closeObservability(): Promise<void>

getObservabilityHandler returns a request handler you can mount on your own HTTP server. It answers the request and returns true, or returns false to pass through. Both getters return undefined when observability is off. closeObservability stops the standalone server and is safe to call when none was started.

Lifecycle methods

ready(): Promise<void>
close(): Promise<void>

ready resolves when the inbound event subscription is active and rejects when subscription failed. setupCache calls it before returning.

close stops observability and disconnects the event bus. On a ManagedLazyLayersCache, it also closes the Redis client when setup created that client. Caller-provided clients remain caller-owned. Managed close is idempotent.

Redis capability discovery

discoverRedisCapabilities(
  client: RedisCapabilityClient,
  options?: RedisCapabilityDiscoveryOptions,
): Promise<RedisCapabilityManifest>
hasRedisCapability(manifest: RedisCapabilityManifest, name: RedisCapabilityName): boolean

Discovery sends bounded, read-only INFO server and COMMAND INFO requests for an allow-list. It never runs CONFIG, creates an index, or enables Redis modules. Each command has a supported, unavailable, or unknown state with a sanitized reason. supported means the server advertises the command, not that every application operation has passed an ACL or runtime check.

OptionDefaultMaximum
timeoutMs250 ms per requestValidated by discovery
maxCommandNames18 allow-listed commands64
maxInfoBytes16 KiB64 KiB
maxCommandReplyBytes64 KiB256 KiB

commandNames narrows the allow-list. minimumRedisMajorVersion defaults to 6. Discovery failures can produce unknown, so do not treat a failed probe as proof that Redis lacks a capability.

new RedisCapabilityRegistry(client, options) caches and deduplicates discovery via get(). invalidate() discards its manifest. A client ready event invalidates it after reconnect. Call close() to detach listeners. The registry does not own or close the client.

Exported classes and values

Everything below is a runtime export of lazy-layers-cache.

Exported types

Where to next

On this page