Stores
The CacheStore interface, MemoryStore, RedisStore, and what it takes to write your own.
A store is anything that implements CacheStore. Both layers take one: l1 defaults to a MemoryStore built from your options, and l2 has no default at all.
The CacheStore interface
Eight methods, all asynchronous. This is the complete contract.
interface CacheStore<K extends CacheKey, V> {
set(key: K, value: V, options?: CacheOptions): Promise<void>
get(key: K): Promise<V | undefined>
getOrSet(key: K, loader: CacheLoader<V>, options?: CacheOptions): Promise<V | undefined>
has(key: K): Promise<boolean>
delete(key: K): Promise<void>
deleteByPattern(pattern: string): Promise<void>
clear(): Promise<void>
size(): Promise<number>
}CacheKey is string | number. CacheLoader<V> is (context?: { signal: AbortSignal }) => Promise<V | undefined>.
undefined is the miss signal throughout. A store returning undefined from get means "not here", which is why a cached undefined is handled by the negative cache rather than by the store.
MemoryStore
The default L1. It wraps lru-cache and owns tagged serializer buffers. Reads decode a fresh value, so caller mutation cannot change the retained entry.
new MemoryStore<K extends CacheKey, V>(options?: CacheOptions)Two type parameters, key first. It takes the same CacheOptions the cache takes and resolves L1 lifetime, count, memory, pressure, and admission settings.
import { MemoryStore } from 'lazy-layers-cache'
export const l1 = new MemoryStore({
levels: { L1: { maxEntries: 10_000, ttlMs: 30_000 } },
})You rarely construct one yourself. The cache builds it for you from the options you already passed, unless you set l1 explicitly or set l1: false.
L1 limits and policy
Becomes max. A count of entries, not bytes.
Becomes ttl. Falls back to the cache-wide ttlMs, then to one hour.
Hard ceiling shared by built-in stores in one process. Percentages resolve against effective host or container memory.
Optional lower bound for the adaptive target during pressure.
Samples runtime and Linux pressure signals and shrinks or gradually recovers the active target. Disabling sampling does not disable byte enforcement.
Rejects oversized entries before they displace the hot set and uses bounded history when the cache must choose whether to admit a new value.
That is the whole surface.
- There is no configurable replacement policy. Retained entries remain ordered by LRU. Bounded frequency history affects admission, not victim ordering.
- Byte accounting is explicit. Encoded payloads, key and metadata estimates, and fail-safe snapshots share the configured budget. It is not an RSS guarantee.
Keep maxEntries as a count defense for tiny keys and use maxMemory for cache-owned retained bytes. Under Node clustering every worker has an independent process-local budget unless the deployment divides a host allowance explicitly.
TTL on write
set resolves the lifetime most specific first: the per-call levels.L1.ttlMs, then the per-call ttlMs, then the store's constructor levels.L1.ttlMs, then its constructor ttlMs, then one hour.
The rest of the behaviour
deleteByPattern walks every key in the LRU and glob-matches it, so its cost is the size of L1, not the size of the match. size() reports the LRU's current entry count.
MemoryStore implements InspectableStore. Its inspect reads values with peek(), so opening the dashboard never changes recency or brings forward an eviction.
RedisStore
The shared L2. It takes an ioredis client you already built, so connection settings, TLS and cluster configuration stay yours.
new RedisStore<V>(redis: Redis, options?: RedisStoreOptions)RedisStore declares one type parameter, not two. The class is RedisStore<V> implements CacheStore<CacheKey, V>, so the key type is fixed to CacheKey and the only thing you name is the value.
Write new RedisStore<User>(redis, options). RedisStore<string, User> does not compile.
import { LazyLayersCache, RedisStore } from 'lazy-layers-cache'
import { redis } from './redis.js'
export const cache = new LazyLayersCache({
ttlMs: 5 * 60 * 1000,
l2: new RedisStore(redis, {
prefix: 'app:cache:',
deleteStrategy: 'unlink',
scanCount: 500,
}),
})RedisStoreOptions
RedisStoreOptions extends CacheOptions, so a store can carry its own ttlMs and levels policy independent of the cache. It also controls the Redis key layout, optional namespace catalogue, scans, and delete strategy.
Namespaces the logical keys. In the v2 layout, it follows the lazy-layers:v2:{encoded-logical-key}: prefix. Two applications sharing a Redis instance with the same prefix will invalidate each other's keys.
The optional sorted set that tracks live keys, scored by their expiry deadline. Derived from prefix unless you set it. Change it only if it collides with something you already store.
Maintain the index, so pattern deletes and size use ZSCAN and ZCARD instead of walking the keyspace. It is also selected when levels.L2.maxEntries is set.
Leave it off for Redis-native TTL and operator-managed eviction. Turning it on adds a ZADD per write and avoids a full SCAN for pattern deletes and size().
The COUNT hint on SCAN, ZSCAN and the streaming variants. Higher finishes a sweep in fewer round trips and blocks the Redis event loop for longer on each one.
Keys per delete pipeline, and the batch size for accumulating matches during an indexed pattern delete. Raising it turns a large invalidation into one long stall instead of several short ones.
unlink frees memory on a background thread, so a large delete does not block the Redis event loop. Any value other than the exact string 'del' resolves to unlink. Choose del only for a Redis older than 4.0.
auto uses the v2 per-entry key layout when the client exposes defineCommand and otherwise keeps the legacy layout. Set v2 only after the namespace migration is complete. Set legacy while old writers still use the legacy namespace.
The key layout
The legacy layout has three shapes under your prefix.
| Shape | Purpose |
|---|---|
{prefix}{key} | One cached value, serialized, with a PX expiry |
{prefix}__index | Optional sorted set of live key names, scored by expiry deadline |
{prefix}__lock:{key} | Distributed lock token, held for the lock TTL |
The v2 layout stores values as lazy-layers:v2:{encoded-logical-key}:{prefix}{key} and leases as lazy-layers:v2:{encoded-logical-key}:{prefix}__lock:{key}. The hash tag keeps one value and its lease in the same Redis Cluster slot while unrelated keys still shard independently. It does not dual-read the legacy layout. During a mixed-version rollout, set keyLayout: 'legacy' on upgraded writers while older writers remain. Drain or explicitly backfill the legacy namespace before moving every writer to v2. Update Redis ACL key patterns to cover the v2 prefix as described in production setup.
When useIndex is off, inspect filters out internal keys so the dashboard does not show bookkeeping keys.
What each operation actually does
set uses a token-fencing script with v2, so a direct write removes an outstanding loader lease in the same Redis operation. The legacy layout pipelines SET key value PX ttl with an optional ZADD. When levels.L2.maxEntries selects the index, the store trims the oldest expiry entries after the write. This is a bounded, best-effort cache policy under concurrent writers, not a transaction quota.
get uses one script execution to read the value and remaining TTL with v2. The legacy layout reads binary data with getBuffer() and its TTL separately. A miss removes that key from the optional index, so an expired key stops being counted by size().
delete and deleteByPattern batch into pipelines of batchSize, issuing UNLINK or DEL alongside a ZREM on the index. With the index on, a pattern delete streams ZSCAN. With it off, it streams a keyspace SCAN.
clear() is deleteByPattern('*') under your prefix, so it does not touch keys another application put in the same Redis.
size() is ZCARD when the index is on. Without it, the store performs a full namespace SCAN.
The two optional capabilities
RedisStore implements both, which is why it is the store the dashboard and the distributed lock are written against.
InspectableStore gives you inspect, used by the observability dashboard. It advances one SCAN or ZSCAN step at a time and pipelines PTTL plus the value fetch, so it never blasts the keyspace and never runs unless a dashboard tab is open.
acquireLock, renewLock and releaseLock back distributedLock. Acquire is SET NX PX on the lease key. Renewal and release compare the token in a script. publishIfOwner checks that same token and writes the encoded value with its TTL in one Redis operation. An expired owner cannot renew, release, or publish over another owner's value. HybridCache manages renewal automatically.
Introspection
One method: inspect(options?: StoreInspectOptions): Promise<StoreInspection>. Both shipped stores implement it.
interface StoreInspectOptions {
cursor?: string
limit?: number
match?: string
includeValues?: boolean
maxValueBytes?: number
}
interface StoreInspection {
size: number
cursor?: string
keys: KeyInspection[]
}
interface KeyInspection {
key: string
ttlRemainingMs?: number
serializedBytes: number
deserializedBytes: number
compressionRatio: number
encoding: 'msgpack' | 'msgpack-gzip' | 'json' | 'legacy'
value?: unknown
truncated?: boolean
}limit defaults to 100 in both stores, and includeValues defaults to true via a !== false check. maxValueBytes defaults to 262144, which is 256 KB, and a value over it is reported with truncated: true instead of its contents. An absent cursor in the result means the scan is finished.
Both built-in stores retain tagged encoded buffers, so serializedBytes is the payload actually stored and inspection does not re-serialize it. deserializedBytes comes from write-time serializer statistics when available; unknown legacy or directly injected encoded values are left truncated instead of being expanded only for inspection.
isInspectableStore
A runtime type guard, exported from the package. Use it before calling inspect on a store you did not construct.
import { isInspectableStore } from 'lazy-layers-cache'
export async function firstPage(store, match) {
if (!isInspectableStore(store)) {
return { size: 0, keys: [] }
}
return store.inspect({ match, limit: 50, includeValues: false })
}The guard is a single typeof store?.inspect === 'function' check. A store without inspect still works everywhere else, and the dashboard simply shows nothing for that layer.
Writing your own store
Implement the eight CacheStore methods and pass the instance as l1 or l2.
// A minimal store with no eviction. Real backends need a bound.
export class MapStore {
constructor() {
this.entries = new Map()
}
async set(key, value) {
this.entries.set(key, value)
}
async get(key) {
return this.entries.get(key)
}
async getOrSet(key, loader, options) {
const cached = await this.get(key)
if (cached !== undefined) return cached
const value = await loader()
if (value === undefined) return undefined
await this.set(key, value, options)
return value
}
async has(key) {
return this.entries.has(key)
}
async delete(key) {
this.entries.delete(key)
}
async deleteByPattern(pattern) {
const regex = new RegExp(`^${pattern.split('*').map(escapeRegExp).join('.*')}$`)
for (const key of this.entries.keys()) {
if (regex.test(String(key))) this.entries.delete(key)
}
}
async clear() {
this.entries.clear()
}
async size() {
return this.entries.size
}
}
function escapeRegExp(part) {
return part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}Then hand it to a layer. getOrSet on the cache goes through your store on the way to the loader, so nothing above changes.
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
})
}Things to get right
Return undefined for a miss, never null. The cache treats undefined as "not present" everywhere. A null return counts as a hit and is served to your callers.
Only L2 is wrapped in a circuit breaker. Every L2 call goes through a guard that catches the failure, records it against resilience.l2CircuitBreaker, emits l2:error and falls back. Calls to your L1 store are awaited directly with no guard at all, so an L1 store that throws propagates that error straight to the caller of getOrSet. If you are writing an L1 store, handle your own failures and degrade to a miss.
Add the optional capabilities you can support. Both are detected at runtime and neither is required.
| Capability | Methods | Unlocks |
|---|---|---|
InspectableStore | inspect(options?) | Key browsing in the observability dashboard |
| Distributed lock | acquireLock(key, token, ttlMs), releaseLock(key, token) | distributedLock, which serializes cold loads across servers |
Custom stores may also provide renewLock(key, token, ttlMs): Promise<boolean>. It must atomically extend only a live lock owned by that token, returning false after ownership is lost. Without this optional method, the load is bounded by the original lease.
The lock check is structural: an object with both methods as functions satisfies it. acquireLock must return true only when it actually took the lock, and releaseLock must compare the token before deleting, or a slow holder will release a lock somebody else now owns.
Respect the options argument on set. A store that ignores options.ttlMs and options.levels silently discards every per-call TTL override the cache passes down.