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.
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.
| Input | Resolved cache |
|---|---|
redis: { required: true } and REDIS_URL exists | L1 plus Redis L2 plus Redis Pub/Sub |
redis: { required: true } and Redis is missing or unhealthy | Startup throws CacheSetupError |
redis omitted and REDIS_URL exists | L1 plus Redis L2 plus Redis Pub/Sub |
redis omitted and REDIS_URL is absent | Protected L1 only |
redis: false | L1 only, even when REDIS_URL exists |
l2: false | No L2. A generated or explicit event bus can still synchronize L1 caches |
eventBus: false | No peer fan-out. L1 and L2 still work |
Explicit l2 or eventBus | Your 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:
- In one process, callers for the same key share one in-flight promise.
- Across processes,
RedisStoretakes 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 setdistributedLock.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
| Protection | Default | What it bounds |
|---|---|---|
| L1 | 1,000 entries, 10 second TTL | Fresh entry count and lifetime. Eligible fail-safe values have a separate stale window |
| L2 | One hour TTL | Shared retention across restarts |
| In-flight dedupe | Lifetime derived from lock-wait and loader budgets, at least 5 seconds, 10,000 keys | Duplicate loaders and bookkeeping growth |
| Distributed lock | 10 second lease, derived 10.05 second wait, 50 ms poll | Cross-process duplicate loaders and waiter latency |
| Loader timeout | 10 seconds | A loader that never settles |
| Stale fallback | 30 seconds | How long an expired value can cover an origin failure |
| Negative cache | 10 seconds, 10,000 misses | Repeated absent-key loads and miss-map growth |
| L2 and bus breakers | 3 failures, 30 second cooldown | Repeated calls into failing shared infrastructure |
| Set broadcast | 32 KB maximum | Fan-out payload size |
| Redis event handlers | 16 concurrent | Work applied from Pub/Sub at once |
| Event retry queue | 10,000 events | Memory used by failed publishes awaiting replay |
| Startup readiness | 10 seconds per check | Health, 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.
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
undefinedcreates 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.
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.
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.
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
- Configuration reference lists constructor and per-call overrides.
- Stampede protection explains the in-process and distributed paths in detail.
- Event buses covers Redis, RabbitMQ, NATS Core, and NATS JetStream.