LazyLayersv0.5.3
Setups

Production setup

How setupCache resolves infrastructure, overload protection, health, tuning, and shutdown.

The production setup is the same small API as the quickstart. Production declares Redis required, uses a stable namespace, and tunes numbers from observed traffic.

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

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

setupCache reads REDIS_URL, creates the client, L2, and Redis event bus, checks health, attaches the subscription, and only then returns the cache. A missing or unhealthy Redis stops startup because required: true and healthy startup are the defaults for that configured dependency.

Redis 6 through 8 migration and ACLs

The cache core supports Redis 6, 7, and 8 with ordinary Redis commands. During setupCache, INFO server validates the server version. Redis below 6, malformed version replies, and ACL denials fail startup with a classified CacheSetupError. Raw Redis responses are not copied into that error.

Grant INFO and PING for readiness checks, plus the commands required by the configured store and event bus. The v2 path uses cached scripts (EVAL/EVALSHA) and their underlying value, TTL, and lease commands. Namespace indexing adds sorted-set commands only when enabled. Redis Pub/Sub needs publish and subscription permissions.

Scope ACL key patterns to the actual key layout. With namespace billing-api, v2 value and lease keys match ~lazy-layers:v2:*:billing-api:cache:*. Legacy and optional index keys use ~billing-api:cache:*. The Pub/Sub channel is &billing-api:cache:events. A legacy-only ACL will reject the default v2 layout. See Redis store layout before changing permissions or rolling out an upgrade.

Managed ioredis clients use readiness checks, finite command timeouts and retries, auto-pipelining, no offline command queue, and no automatic replay of unfulfilled commands. These settings bound application-side work but do not cancel a command that Redis may already have applied. An injected client keeps its own settings and ownership.

Lazy Layers owns its local invalidation trust lifecycle, so v0.5.3 does not enable Redis CLIENT TRACKING. Redis memory limits and eviction remain deployment settings; L1 pressure never rewrites Redis server policy.

Redis Search, vector indexes, embeddings, semantic caching, and AI features are not probed or initialized by the v0.5.3 cache core.

Upgrade an existing Redis namespace

0.5.3 uses the v2 key layout for normal ioredis clients. During a mixed-version rollout, configure upgraded writers with redis.store.keyLayout: 'legacy' while older writers remain. Drain or explicitly backfill the namespace, then move all writers to v2. The layouts are not dual-read.

If the deployment relies on the namespace catalogue, retain redis.store.useIndex: true during migration. New deployments use Redis-native TTL and operator-managed eviction by default. Read the store reference for key shapes and compatibility limits.

Resolution rules

The setup path is conditional without hiding the result.

InputResolved cache
redis: { required: true } and REDIS_URL existsL1 plus Redis L2 plus Redis Pub/Sub
redis: { required: true } and Redis is missing or unhealthyStartup throws CacheSetupError
redis omitted and REDIS_URL existsL1 plus Redis L2 plus Redis Pub/Sub
redis omitted and REDIS_URL is absentProtected L1 only
redis: falseL1 only, even when REDIS_URL exists
l2: falseNo L2. A generated or explicit event bus can still synchronize L1 caches
eventBus: falseNo peer fan-out. L1 and L2 still work
Explicit l2 or eventBusYour implementation wins. Setup still checks and waits for the bus

Do not use conditional Redis discovery for a multi-server deployment that requires coherence. Set redis.required: true so a missing environment variable cannot silently produce isolated L1 caches.

The protection model

There is no global cache mutex. A global mutex would make unrelated keys wait behind one slow loader and turn the cache into a throughput bottleneck.

LazyLayers protects the origin at two useful scopes:

  1. In one process, callers for the same key share one in-flight promise.
  2. Across processes, RedisStore takes one distributed lock per cold key. A server that loses the lock polls for the winner's value. At the bounded deadline it serves eligible stale data or throws by default. It runs unlocked only when you explicitly set distributedLock.onTimeout: 'load'.

This keeps user:1 from stampeding without making user:2 wait for it. The in-flight map is capped at 10,000 tracked keys by setupCache, so a burst over many distinct keys cannot grow that bookkeeping without a bound.

A cache lock is not a rate limiter for the origin as a whole. Keep database pool limits, HTTP client limits, and upstream admission control at the origin boundary. The cache owns duplicate work for one key.

Defaults applied by setupCache

ProtectionDefaultWhat it bounds
L11,000 entries, 10 second TTLFresh entry count and lifetime. Eligible fail-safe values have a separate stale window
L2One hour TTLShared retention across restarts
In-flight dedupeLifetime derived from lock-wait and loader budgets, at least 5 seconds, 10,000 keysDuplicate loaders and bookkeeping growth
Distributed lock10 second lease, derived 10.05 second wait, 50 ms pollCross-process duplicate loaders and waiter latency
Loader timeout10 secondsA loader that never settles
Stale fallback30 secondsHow long an expired value can cover an origin failure
Negative cache10 seconds, 10,000 missesRepeated absent-key loads and miss-map growth
L2 and bus breakers3 failures, 30 second cooldownRepeated calls into failing shared infrastructure
Set broadcast32 KB maximumFan-out payload size
Redis event handlers16 concurrentWork applied from Pub/Sub at once
Event retry queue10,000 eventsMemory used by failed publishes awaiting replay
Startup readiness10 seconds per checkHealth, connection, and subscription waits

The booleans stay on. Production tuning changes TTLs, entry counts, byte ceilings, thresholds, and cooldowns.

Tune the numbers

Override only values you can tie to an instance budget, latency objective, or measured payload distribution.

src/cache/index.js
export const cache = await setupCache({
  namespace: 'billing-api',
  redis: {
    required: true,
    store: {
      prefix: 'billing:v1:',
      scanCount: 500,
      batchSize: 500,
    },
    eventBus: {
      handlerConcurrency: 32,
      retryQueue: { maxSize: 20_000 },
    },
  },
  levels: {
    L1: { maxEntries: 20_000, maxMemory: '256MiB', ttlMs: 15_000 },
    L2: { ttlMs: 60 * 60 * 1000 },
  },
  inflight: { ttlMs: 3_000, maxEntries: 20_000 },
  originLoad: { maxConcurrent: 32, maxQueued: 1_024, queueTimeoutMs: 1_000 },
  distributedLock: {
    ttlMs: 8_000,
    waitTimeoutMs: 2_500,
    pollMs: 50,
  },
  timeouts: { softMs: 800, hardMs: 5_000 },
  failSafe: { staleTtlMs: 60_000, maxEntries: 2_000, maxBytes: 32 * 1024 * 1024 },
  resilience: {
    l2CircuitBreaker: { failureThreshold: 5, cooldownMs: 15_000 },
    l2OperationGate: { maxConcurrent: 64, maxQueued: 1_024, maxQueuedBytes: 32 * 1024 * 1024 },
    eventBusCircuitBreaker: { failureThreshold: 5, cooldownMs: 15_000 },
  },
  broadcastSetMaxBytes: 64 * 1024,
})

Size L1 by memory budget

L1 retains encoded values in LRU order. Use maxEntries to bound cardinality and maxMemory to bound the built-in stores together in one process. The shared budget samples available memory and cgroup pressure, then lowers its active target during sustained pressure. It is not a process RSS cap, so leave headroom for the application, Node.js runtime, and bursts. Under Node.js clustering, every worker owns a separate process budget.

Set lock timing from loader latency

Keep the lock TTL above the slowest legitimate loader. Keep waitTimeoutMs around the time a losing server should wait for the winner before it can serve stale data or report contention. The loader receives an AbortSignal, so pass it to database or HTTP calls.

Set the broadcast ceiling from payloads

Values at or below broadcastSetMaxBytes prime every peer L1. Larger values stay in L1 and L2, skip the event bus, and emit set:broadcast-skipped. Peers read them from L2 when needed.

Runtime failure versus startup failure

Startup is strict. A configured bus must connect, pass its health check, and subscribe before setupCache returns.

Runtime is fail open:

  • An L2 failure behaves like an L2 miss while the breaker records the failure.
  • An open L2 breaker skips Redis until its cooldown probe.
  • An event-bus publish failure does not fail the cache mutation.
  • A loader failure or timeout serves a recent stale value when one exists.
  • A loader returning undefined creates a short negative entry.

Set startup.requireHealthy: false only when accepting traffic with degraded shared infrastructure is an explicit operational decision.

Operate pressure and contention

When the shared L1 memory budget enters pressure, the cache evicts LRU entries and can bypass new L1 promotions. An L2 hit still returns to the caller, but it may not become a local hit. Watch promotion:bypassed with its pressure or budget reason before raising maxMemory. Raising the number without leaving heap headroom only moves the incident to Node.js.

For a Redis lock timeout, do not treat the error as permission to repeat the business operation. It means the cache did not observe the winner's value within waitTimeoutMs. Keep loaders read-only and idempotent, pass their abort signal to I/O, and investigate lock:timeout alongside origin latency and Redis health. The lock coordinates cache fills, not side effects or a global origin overload.

Invalidate and pre-warm

Invalidate after the source-of-truth write commits.

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

Pre-warm through the same protected, broadcasting path as a normal read.

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

For a family of stale keys, call invalidateByPattern('tenant:42:*'). Pattern invalidation scans only the configured namespace and uses bounded Redis batches.

Shutdown

close() is idempotent. It closes the observability server, disconnects the bus, and closes the Redis client only if setup created that client.

src/server.js
async function shutdown() {
  await cache.close()
  process.exit(0)
}

process.once('SIGTERM', shutdown)
process.once('SIGINT', shutdown)

If you passed an existing Redis client, that client remains yours to close after every other consumer has stopped.

Where to next

On this page