The in-memory LRU cache is the L1 layer. You can layer a
persistent L2 store behind it via cache.persistentStore, so cached data
survives reloads (IndexedDB) or is shared across server instances (Redis).
Import stores from the dedicated entry:
import {
createMemoryPersistentStore,
createIndexedDbStore,
createRedisStore,
createLayeredCacheStore,
} from '@developerehsan/api-client/cache-stores'This module is environment-agnostic: the Redis store takes an injected client
(no redis dependency), and the IndexedDB store feature-detects indexedDB, so
it's safe in any bundle.
const api = createClient({
baseURL,
openapi: { mode: 'runtime' },
cache: { persistentStore: createMemoryPersistentStore() },
})cache: {
persistentStore: createIndexedDbStore({
dbName: 'my-app-cache',
version: 1, // bump when changing storeName or migrating
maxEntries: 500, // proactively evicts oldest ~10% once reached
onQuotaExceeded: (error) => console.warn('cache quota exceeded', error),
onStoreError: (error, { op, key }) => reportToMonitoring(error, op, key),
}),
}Cached GET responses survive a page reload. onQuotaExceeded/onStoreError
are diagnostic hooks only — a write still resolves (swallowed) either way;
they exist so "IndexedDB is full/down" isn't indistinguishable from "cache is
just cold."
cache: {
persistentStore: createIndexedDbStore({
encrypt: {
encrypt: (plaintext) => myCipher.encrypt(plaintext),
decrypt: (ciphertext) => myCipher.decrypt(ciphertext),
},
}),
}No default cipher or key management is shipped — where the key comes from
and whether it survives a reload is inherently application-specific, and
getting that wrong is worse than not offering encryption at all. Know what
this does and doesn't protect against: it protects against casual
inspection of browser storage (devtools, another origin somehow reading it
via a bug). It does not protect against the page's own JS — code running
on your page can call decrypt itself, by definition. A key held only in
memory means the cache doesn't survive a reload, which may defeat the point
of IndexedDB persistence in the first place — that tension is real; there's
no way around it, only tradeoffs to pick from.
import { createClient as createRedis } from 'redis'
const redis = createRedis(/* ... */); await redis.connect()
cache: {
persistentStore: createRedisStore(redis, {
keyPrefix: 'myapp:',
onStoreError: (error, { op, key }) => reportToMonitoring(error, op, key),
// serializer: { stringify, parse }, // e.g. compression — parse must stay JSON.parse-safe
}),
}The store takes your already-connected client, so this package never depends on
redis directly.
By default clear() is a no-op (namespace-wide deletion needs SCAN, which
varies by client — this is the existing, unchanged default). Wire scanKeys
on your client object to make it real:
cache: {
persistentStore: createRedisStore(
{
...redis,
scanKeys: async function* (pattern) {
for await (const key of redis.scanIterator({ MATCH: pattern })) yield key;
},
},
{ keyPrefix: 'myapp:' },
),
}Every key scanKeys yields is verified to actually sit inside this store's
own keyPrefix before deletion — a buggy scanKeys implementation can't
delete an unrelated application key sharing the same Redis instance.
cache: {
persistentStore: createFileSystemStore({
dir: '/var/cache/my-app', // must NOT be inside a web-server-served static path
maxSizeBytes: 100 * 1024 * 1024,
onStoreError: (error, { op, key }) => reportToMonitoring(error, op, key),
}),
}Same shape as the Next.js data-cache (.next/cache) and nginx proxy_cache —
trades RAM for disk on a long-running Node server process, and survives a
restart (unlike mode: 'l1-only'). Node-only: resolves to a safe no-op store
(never throws at construction) if node:fs isn't usable, e.g. if this module
were ever reached from a browser bundle — which it isn't, since
cache-stores is its own build entry, never bundled into /browser.
Does not fit serverless/edge (the filesystem is ephemeral or absent
there — Lambda /tmp, Vercel functions, Workers). Does not solve
cross-instance staleness (each instance has its own disk) — don't reach for
a network filesystem (NFS/EFS) as a workaround for that; it reintroduces
cross-instance coordination with worse latency and file-locking semantics
than Redis pub/sub
already gives you.
Path safety: every cache key is SHA-256 hashed into a fixed-length hex
filename before touching the filesystem — a hostile or malformed key
(however derived, including via cacheKeyParts)
can never escape dir or reach an unintended file. Writes are atomic
(write to a .tmp file, then rename) so a crash mid-write never corrupts a
read. Files default to 0o600 (owner-only) — configurable via fileMode.
Accepts the same encrypt (CacheCipher) option as createIndexedDbStore
for at-rest encryption, with the same caveats.
A single Redis store already shares data across instances (L2). It does not, by default, tell sibling instances to evict their own in-memory L1 copy when one instance invalidates a tag — so a mutation handled by instance A can leave a stale L1 entry on instances B and C until TTL expiry. Opt into pub/sub broadcast to fix that:
import { createClient as createRedis } from 'redis'
const redis = createRedis(/* ... */); await redis.connect()
// Most Redis clients need a DEDICATED connection for subscribe mode.
const redisSub = createRedis(/* ... */); await redisSub.connect()
cache: {
persistentStore: createRedisStore(
{ ...redis, subscribe: (channel, onMessage) => redisSub.subscribe(channel, onMessage) },
{ crossInstance: true },
),
}Off by default (no silent behavior change, and it costs a dedicated connection). Only tags/keys/a clear signal ever go over the wire — never cached data, headers, or auth material. The Redis instance/channel should be reachable only by your own servers; anyone who can publish to it can force cache evictions on every instance (an availability nit, never a data leak).
maxSize bounds the L1 cache by entry count. On a small VM, a handful of
large responses can matter more than count. Bound by estimated bytes too, or
skip local memory almost entirely in favor of Redis:
const api = createClient({
baseURL,
cache: {
maxSizeBytes: 10 * 1024 * 1024, // 10MB soft cap on L1, alongside maxSize
mode: 'l2-only', // tiny shadow L1, everything else lives in Redis
persistentStore: createRedisStore(redisClient),
memoryPressure: { thresholdMb: 400 }, // proactively evict before OOM (Node only)
},
});maxSizeBytes— an approximate (JSON-length) byte cap; whichever of it ormaxSizeis hit first triggers LRU eviction.mode: 'l2-only'— keeps only a small, fixed-size shadow L1 (just enough to avoid a network round-trip on an immediately-repeated read) in front ofpersistentStore, which is required in this mode.'l1-only'is the inverse: ignore a configuredpersistentStoreentirely.memoryPressure— Node-only; periodically checksprocess.memoryUsage().rssand proactively evicts oldest entries ahead of the other limits. No-ops (feature-detected) on edge/browser runtimes.
None of these change how entries are keyed — cross-tenant/auth isolation
via computeCacheKey is unaffected; this only changes where/how much is
kept in memory.
Both createIndexedDbStore and createRedisStore silently degrade to "as
if the entry wasn't there" on a backend failure by default — you can't tell
"Redis is down and every request is a cache miss" from "cache is just cold"
without onStoreError:
const onStoreError = (error: unknown, { op, key }: { op: string; key?: string }) => {
metrics.increment('cache.store_error', { op })
logger.warn('cache backend error', { op, key, error })
}
cache: { persistentStore: createRedisStore(redis, { onStoreError }) }It fires in addition to (never instead of) the existing swallow-and-degrade
behavior — the call's own return value is unchanged. The payload is always
(error, { op, key? }) — never the cache entry's data, so this can't
become a second channel for cached response bodies to leak through logging.
Without a circuit breaker, every request still attempts the persistent store and pays its full timeout before falling through — repeatedly, for as long as an outage lasts. Opt in to stop attempting L2 during a cool-down once failures pile up:
cache: {
persistentStore: createRedisStore(redis),
circuitBreaker: { failureThreshold: 5, cooldownMs: 30_000 },
onStoreError: (error, { op }) => {
if (op === 'circuit-open') alertOncall('cache L2 circuit opened')
if (op === 'circuit-close') logger.info('cache L2 circuit closed')
},
}After failureThreshold consecutive L2 failures, calls serve L1-only for
cooldownMs — the same degraded-but-safe behavior L2 failures already fall
back to, just without the repeated timeout cost. After cooldown, one probe
attempt runs: success closes the circuit, failure reopens it for another
cooldown window (so a permanently-down backend never quietly disables
persistence forever with no way to recover without a restart).
When using runtime schema mode, every cached entry captures the active OpenAPI schema hash. A deploy that changes response shapes bumps that hash — any entry written under the old hash is treated as a miss (evicted, refetched) rather than served to a client now expecting the new shape. This is automatic; there's nothing to configure.
createLayeredCacheStore(...) composes multiple stores (e.g. IndexedDB in front
of a remote store) if you need more than one L2 tier.
See the API reference for the full PersistentCacheStore
interface.