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
| Task | Method |
|---|---|
| Create and connect a managed cache | setupCache |
| Read through the cache | getOrSet |
| Warm a key | prewarm |
| Invalidate after a write | invalidate |
| Invalidate a key family | invalidateByPattern |
| Release resources | close |
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.
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 },
})Redis key and channel namespace. Falls back to LAZY_LAYERS_NAMESPACE, then npm_package_name, then app.
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.
Explicit store override. It wins over the generated Redis store.
Explicit bus override. It wins over generated Redis Pub/Sub. Pass false to disable peer fan-out.
Requires configured shared infrastructure and the inbound subscription to be ready before setup returns.
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.
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.
The cache key. A string or number.
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.
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.
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.
await cache.prewarm('plans:active', ({ signal }) =>
db.plans.findActive({ signal }),
)get
get(key: K): Promise<V | undefined>Reads what is already cached. Nothing more.
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.
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.
The cache key.
The value to store. It is serialized on the way into L2.
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.
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 loader | Dedupes concurrent callers | Writes L1 and L2 | Primes peers | |
|---|---|---|---|---|
getOrSet | yes | yes | yes | yes |
get | no | no | promotes an L2 hit into L1 | no |
set | no | no | yes | no |
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>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.
const warm = await cache.has(`user:${id}`)delete
delete(key: K): Promise<void>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.
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.
const user = await db.users.update(id, patch)
await cache.invalidate(`user:${id}`)deleteByPattern
deleteByPattern(pattern: string): Promise<void>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.
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.
const l1Entries = await cache.size()on
on(handler: CacheEventHandler): () => voidCalled 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.
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): booleanDiscovery 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.
| Option | Default | Maximum |
|---|---|---|
timeoutMs | 250 ms per request | Validated by discovery |
maxCommandNames | 18 allow-listed commands | 64 |
maxInfoBytes | 16 KiB | 64 KiB |
maxCommandReplyBytes | 64 KiB | 256 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.
ManagedLazyLayersCache, LazyLayersCache, HybridCache, MemoryStore, RedisStore, CircuitBreaker, setupCache, createCache, and CacheSetupError.
MemoryStore is the L1 implementation and is created for you when you do not pass l1. RedisStore is generic over the value type only, as RedisStore<V>, because its key type is fixed to CacheKey.
RedisEventBus, RabbitMQEventBus, NatsEventBus. Each satisfies the EventBus interface and each needs await bus.connect() before use. Constructor options are in the event bus reference.
DEFAULT_CACHE_TTL_MS // 3_600_000
DEFAULT_L1_MAX_ENTRIES // 1_000
DEFAULT_INFLIGHT_TTL_MS // 5_000 minimum for the derived in-flight lifetime
PRODUCTION_L1_TTL_MS // 10_000
PRODUCTION_INFLIGHT_MAX_ENTRIES // 10_000
PRODUCTION_BROADCAST_SET_MAX_BYTES // 32_768
PRODUCTION_STARTUP_TIMEOUT_MS // 10_000
GZIP_MIN_BYTES // 65_536
GZIP_SAVINGS_THRESHOLD // 0.15
NULL_SENTINEL // '__hybridcache_null__'serialize, serializeWithStats, deserialize, inspectBuffer, estimateValueBytes, getCompressionSavings, sizeSavings, shouldGzip, hasPrefix, stripPrefix.
serialize returns a Buffer. serializeWithStats returns that buffer plus encoding, originalBytes, storedBytes, compressionRatio and compressed. deserialize accepts a Buffer or Uint8Array and returns null on corrupt input rather than throwing, so one poisoned key cannot take down a read path.
ObservabilityCollector, ObservabilityInspector, createObservabilityHandler, startObservabilityServer, renderDashboard, renderPrometheus, resolveObservabilityOptions, normalizeRoute, isInspectableStore.
Telemetry rides Node's diagnostics_channel: subscribeTelemetry, publishTelemetry, hasTelemetrySubscribers, and TELEMETRY_CHANNEL_NAME, which is lazycache:cache:event.
Constants: DEFAULT_OBSERVABILITY_PORT (7077), DEFAULT_OBSERVABILITY_ROUTE (/__lazylayers), DEFAULT_OBSERVABILITY_MAX_EVENTS (1000), DEFAULT_OBSERVABILITY_MAX_VALUE_BYTES (262_144), DEFAULT_OBSERVABILITY_USERNAME and DEFAULT_OBSERVABILITY_PASSWORD (both lazydev).
Exported types
SetupCacheOptions<K, V>, SetupRedisOptions, CacheStartupOptions, LazyLayersCacheOptions<K, V>, HybridCacheOptions<K, V>, HybridCacheResilienceOptions, CacheOptions, CacheLevelOptions, CacheLevel, CacheKey, CacheLayer<K, V>, CacheEntry<V>, CacheStore<K, V>, CacheLoader<V>, InflightEntry<K, V>, InflightOptions, InflightStore<K, V>, DistributedLock, DistributedLockOptions, CircuitBreakerOptions, CircuitBreakerState, RedisStoreOptions, CacheLoggerOptions, CacheRuntimeEnv.
CacheStore<K, V> is the interface any custom layer must satisfy: set, get, getOrSet, has, delete, deleteByPattern, clear and size. CacheLayer<K, V> is CacheStore<K, V> | false, which is why l1: false and l2: false typecheck.
CacheEvent and CacheEventHandler are the local event stream that on gives you.
InvalidationEvent is what travels over the bus. It is a union of three variants keyed on InvalidationType, which is 'del' | 'pattern' | 'set'. All three extend BaseInvalidationEvent with id, type, source, ts and generation.
| Variant | Type | Extra fields |
|---|---|---|
DeleteEvent | 'del' | keys: string[] |
PatternEvent | 'pattern' | pattern: string |
SetEvent | 'set' | keys: string[], value: unknown, ttlMs?: number |
SetEvent is the one that carries a payload, which is what primes a peer's L1 and what broadcastSetMaxBytes measures.
InspectableStore, StoreInspectOptions, StoreInspection, KeyInspection, BufferInspection, CacheEncoding, SerializedCacheValue.
A store is inspectable when it exposes inspect(). Both MemoryStore and RedisStore do, and isInspectableStore is the type guard the dashboard uses. Inspection is read-only and cursor-paginated, and L1 inspection uses peek so browsing keys never changes eviction order.
ObservabilityOptions, ResolvedObservabilityOptions, ObservabilityAuthOptions, ObservabilityServerOptions, ObservabilityPrometheusOptions, ObservabilityCounters, ObservabilityHandlerDeps, ObservabilityRequestHandler, ObservabilityServerHandle, StartObservabilityServerOptions, InspectorDeps, ConfigSnapshot, OverviewSnapshot, PrometheusGauges, RecordedEvent.
EventBus, EventBusHealth, EventBusRetryQueueOptions, RedisEventBusOptions, RedisEventBusHealth, RabbitMQEventBusOptions, RabbitMQEventBusHealth, NatsEventBusOptions, NatsEventBusMode, NatsEventBusHealth, NatsJetStreamOptions.