LazyLayersv0.5.3
Concepts

Resilience

A cache must never be the reason a request fails. What that stance costs, and the five mechanisms that enforce it.

A cache is an optimisation. It sits in front of things that are slower than it, and when it is working nobody thinks about it. The dangerous property is the one it acquires the moment you rely on it: everything now goes through it, including the requests it was never meant to be load bearing for.

So there is one rule underneath every mechanism on this page. A cache failure degrades the request. It does not fail it.

An L2 error is a miss

Redis being unreachable means your loader runs. It does not mean an exception reaches your handler.

A publish failure is not a write failure

The bus being down does not undo the value you just cached locally.

A corrupt value is a miss

A damaged buffer decodes to nothing, and the loader answers instead.

A dead dependency stops being called

Breakers keep your request path from queueing work against something already struggling.

Fail open

An L2 read failure becomes a miss, so getOrSet can use the origin loader. A bus publish failure is reported without rejecting an already successful cache load. Errors and skipped calls remain visible through the cache event stream.

This does not suppress loader errors, origin overload, lock contention deadlines, or detected lease loss. Those can reach the caller when no eligible stale value exists.

With managed Redis Pub/Sub, a subscription gap makes local invalidation state untrusted. L1, negative entries, and stale fallbacks are flushed or bypassed until reconnect recovery restores trust. For other buses, account for their delivery and recovery behavior when choosing TTLs.

Corrupt encoded values also become misses. See read compatibility for unavailable decoders and damaged payloads.

Circuit breakers

Failing open still means trying. Trying costs a connection from the pool, a socket timeout's worth of latency, and a slot in your request path, per request, for as long as the dependency stays sick. Under load that queue is its own outage.

A circuit breaker stops the attempt. Two independent ones exist, one for L2 and one for the event bus, and both take the same shape:

interface CircuitBreakerOptions {
  enabled?: boolean
  failureThreshold?: number
  cooldownMs?: number
}

Both breakers are constructed for every cache you build, so their defaults are already live: three consecutive failures to open, then a thirty second cooldown. Configuring them replaces those numbers with numbers that fit your dependency. You are not switching anything on.

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

export const cache = new LazyLayersCache({
  l2: new RedisStore(redis, { prefix: 'app:cache:' }),
  eventBus: bus,
  source: process.env.INSTANCE_ID ?? `srv-${process.pid}`,

  resilience: {
    l2CircuitBreaker: {
      // Consecutive failures before calls stop being attempted. Default 3.
      failureThreshold: 5,
      // How long calls are skipped before one probe is allowed through.
      // Default 30_000.
      cooldownMs: 10_000,
    },
    eventBusCircuitBreaker: {
      // Keep this generous and the cooldown short. See the warning below.
      failureThreshold: 10,
      cooldownMs: 2_000,
    },
  },
})

Three states, in the standard shape:

StateWhat it meansWhat moves it on
closedNormal. Calls go through.failureThreshold consecutive failures open it
openCalls are skipped, not attempted.After cooldownMs, the next call is allowed through and the state becomes half-open
half-openOne call is being trusted.Success closes it and resets the counter. A single failure reopens it and restarts the cooldown

A skipped call emits l2:skipped or event-bus:publish-skipped carrying the state, so the breaker opening is something you alert on rather than infer from a latency graph. A success anywhere resets the failure count to zero, which is why the threshold counts consecutive failures and a store that fails one call in ten never trips.

Tuning is numbers. A threshold of three trips on a single blip during a Redis failover. A thirty second cooldown keeps you degraded long after the store came back. Five failures and ten seconds is a reasonable starting pair for Redis on the same network, and you should move both based on what your l2:skipped rate looks like during a real failover rather than on this paragraph.

The event bus breaker and the retry queue do not compose. publishInvalidation checks the breaker and returns before eventBus.publish is ever called, and the retry queue lives inside the bus. So while that breaker is open, two things happen: events are dropped outright rather than buffered, and events already sitting in the queue are not flushed either, because the flush also runs inside publish.

Invalidations lost this way are never replayed. Keep eventBusCircuitBreaker.failureThreshold well above a transient blip and cooldownMs short, so the window in which the queue is bypassed stays small, and alert on event-bus:publish-skipped, because that event is your only signal that invalidations went missing.

Stale fallback

When a loader fails you often have a perfectly serviceable slightly older answer sitting in memory. Fail-safe returns it instead of propagating the error.

Values are remembered as stale at write time, kept for staleTtlMs past their normal lifetime, and returned when the loader throws. Serving one emits stale:hit with a reason of loader-error, soft-timeout, hard-timeout, or lock-timeout.

This is opt-out. Fail-safe runs with a 30,000 ms stale window unless you say otherwise. Earlier releases required an explicit enabled: true and silently did nothing without it, so if you are reading an older configuration, the flag in it is now redundant rather than load-bearing.

src/cache/index.js
export const cache = new LazyLayersCache({
  ttlMs: 5 * 60 * 1000,

  failSafe: {
    // How long past normal expiry a value stays usable as a fallback.
    staleTtlMs: 60_000,
  },
})

staleTtlMs must be positive. Fail-safe is already enabled with a 30 second stale window, so the example only changes that window.

Stale data is still stale. This is the right trade for a product page and the wrong trade for a permission check. getOrSet takes per-call options, so scope it to the reads that can tolerate an old answer and keep authoritative permission or payment decisions on your database path.

Negative caching

Asking repeatedly for something that does not exist runs the loader repeatedly. A crawler walking deleted IDs, or a client retrying a 404 in a tight loop, pushes full origin load through a cache that only remembers hits.

A loader that resolves to undefined records the absence. Later reads for that key return undefined without calling the loader, and emit miss with level negative. That check runs before any layer is touched, so a known miss costs nothing.

src/cache/index.js
export const cache = new LazyLayersCache({
  ttlMs: 5 * 60 * 1000,

  negativeCache: {
    // Already defaults to 10 seconds. Tune for your freshness needs.
    ttlMs: 10_000,
    // Bounds the map. The oldest entry is dropped when the cap is reached.
    maxEntries: 5_000,
  },
})

The TTL is the window in which a newly created record still looks absent, so keep it short. Ten seconds absorbs a retry storm while staying well inside human patience. An invalidate call on the key clears the negative entry immediately, and so does a set event arriving from a peer, so writing through your own API stays consistent regardless of the number.

Timeouts, and the signal you have to pass on

A loader with no ceiling holds a request open for as long as the origin keeps the socket alive, which on a struggling database is minutes. Two timeouts bound that, for different reasons.

TimeoutRequiresEffect
softMsFail-safe on and a stale value present for that keyStop waiting, abort the loader, serve the stale value
hardMsNothingAbort the loader and give up on it

The soft timeout is a latency budget. You already hold an answer that is slightly old, so you return it rather than making the user wait for a fresher one. The hard timeout is for a loader that is not coming back at all. When the soft conditions are met the soft path takes precedence, so a request never waits hardMs when a stale answer was available at softMs.

Both fire controller.abort() on the AbortSignal handed to your loader. That signal is the only lever the cache has, because it cannot reach inside your database client and cancel a query it never issued.

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

export async function getUser(id) {
  return cache.getOrSet(`user:${id}`, async ({ signal } = {}) => {
    // Passing the signal is what makes the timeout cancel work rather than
    // just stop waiting for it.
    const res = await fetch(`${process.env.USER_API}/users/${id}`, { signal })
    return res.ok ? res.json() : undefined
  })
}

A loader that ignores its signal keeps running after the timeout fires. The cache stops waiting, your request returns, and the query carries on holding a connection. Under sustained pressure you accumulate abandoned work at exactly the rate the timeout was supposed to prevent, which makes the timeout worse than useless: it hides the symptom while the cause accelerates.

What is already on

Every protection is opt-out. A cache that only defends you once you have read the whole options reference defends nobody on their first deploy, which is exactly when it matters.

BehaviourOn a cache you constructed with no resilience options
L2 failures treated as missesAlways on. Not configurable
Bus publish failures swallowedAlways on. Not configurable
Corrupt buffers decode to a missAlways on. Not configurable
L1On. 1,000 entries, one hour
In-flight dedupeOn. In-flight entries expire after 5,000 ms
L2 circuit breakerOn. 3 consecutive failures, 30,000 ms cooldown
Event bus circuit breakerOn. 3 consecutive failures, 30,000 ms cooldown
Bus retry queueOn when a bus is configured. Holds 10,000 events
Stale fallbackOn. 30,000 ms past normal expiry
Negative cachingOn. 10,000 ms, capped at 10,000 remembered misses
Loader hard timeoutOn. 10,000 ms
Distributed lockOn when L2 supports it. Otherwise a no-op
Loader soft timeoutOff. Needs timeouts.softMs
Observability dashboardOff. Binds a port and serves cache contents

Two rows are deliberately off.

The soft timeout trades freshness for latency: it abandons a slow loader and serves the stale copy instead. Whether that is right depends on your data, so it waits for a number from you.

The observability dashboard opens a listening socket and serves decoded cache values over it. That is a decision to make on purpose, not one to inherit.

Tuning for production means changing these numbers, not setting anything to false.

Where to next

On this page