API Reference
Complete API documentation for layercache.
Table of Contents
CacheStack
The main class that orchestrates reads, writes, and invalidation across multiple cache layers.
Constructor
import { CacheStack, MemoryLayer, RedisLayer } from 'layercache'
const cache = new CacheStack(layers, options?)
Parameters:
layers - CacheLayer[] - Array of cache layers, ordered from fastest (L1) to slowest (Ln)
options - CacheStackOptions - Optional configuration (see CacheStackOptions)
Read Operations
cache.get<T>(key, fetcher?, options?): Promise<T | null>
Reads through all layers in order. On a partial hit (found in L2 but not L1), backfills the upper layers automatically. On a full miss, runs the fetcher if provided.
// Without fetcher - returns null on miss
const user = await cache.get<User>('user:123')
// With fetcher - runs once on miss, fills all layers
const user = await cache.get<User>('user:123', () => db.findUser(123))
// With full options
const user = await cache.get<User>('user:123', () => db.findUser(123), {
ttl: { memory: 30_000, redis: 600_000 },
tags: ['user', 'user:123'],
negativeCache: true,
negativeTtl: 15_000,
staleWhileRevalidate: 30_000,
staleIfError: 300_000,
ttlJitter: 5_000
})
cache.getOrThrow<T>(key, fetcher?, options?): Promise<T>
Like get(), but throws CacheMissError instead of returning null.
import { CacheMissError } from 'layercache'
try {
const config = await cache.getOrThrow<Config>('app:config')
} catch (err) {
if (err instanceof CacheMissError) {
console.error(`Missing key: ${err.key}`)
}
}
cache.mget<T>(entries): Promise<Array<T | null>>
Concurrent multi-key fetch. Uses layer-level getMany() fast paths when all entries are simple reads.
const [user1, user2] = await cache.mget([
{ key: 'user:1', fetch: () => db.findUser(1) },
{ key: 'user:2', fetch: () => db.findUser(2) },
])
cache.has(key): Promise<boolean>
Check if a key exists in any layer.
cache.ttl(key): Promise<number | null>
Get the remaining TTL in milliseconds for a key in the first layer that has it. Returns null if the key doesn't exist.
cache.inspect(key): Promise<CacheInspectResult | null>
Returns detailed metadata about a cache key for debugging.
const info = await cache.inspect('user:123')
// {
// key: 'user:123',
// foundInLayers: ['memory', 'redis'],
// freshTtlMs: 45,
// staleTtlMs: 75,
// errorTtlMs: 345,
// isStale: false,
// tags: ['user', 'user:123']
// }
cache.getEntry<T>(key): Promise<CacheEntryResult<T> | null>
Reads a key and returns entry metadata instead of only the value. Use this when
null is a valid cached value and you need to distinguish stored nulls,
negative-cache entries, stale entries, and misses.
await cache.set('user:deleted', null)
const entry = await cache.getEntry('user:deleted')
// {
// key: 'user:deleted',
// value: null,
// kind: 'value',
// state: 'fresh',
// layer: 'memory'
// }
const miss = await cache.getEntry('user:missing')
// null
cache.set() stores null values directly. cacheNullValues applies to
read-through fetchers that return null, not to direct writes.
await cache.get('user:deleted', async () => null, { cacheNullValues: true })
Write Operations
cache.set<T>(key, value, options?): Promise<void>
Writes to all layers simultaneously.
await cache.set('user:123', user, {
ttl: { memory: 60_000, redis: 600_000 },
tags: ['user', 'user:123'],
staleWhileRevalidate: { redis: 30_000 },
staleIfError: { redis: 120_000 },
ttlJitter: { redis: 5_000 }
})
// Uniform TTL across all layers
await cache.set('user:123', user, { ttl: 120_000, tags: ['user'] })
cache.mset<T>(entries): Promise<void>
Concurrent multi-key write.
cache.delete(key): Promise<void>
Delete one exact key from all layers.
cache.mdelete(keys): Promise<void>
Bulk delete exact keys.
cache.clear(): Promise<void>
Delete all keys from all layers.
Invalidation
cache.invalidateByKey(key): Promise<void>
Alias for cache.delete(key). Use it when you want the invalidateBy* naming style for one exact key.
await cache.invalidateByKey('user:123') // deletes only user:123
cache.invalidateByKeys(keys): Promise<void>
Alias for cache.mdelete(keys). Deletes only the exact keys provided.
await cache.invalidateByKeys(['user:123', 'user:123:posts'])
cache.invalidateByTag(tag): Promise<void>
Deletes every key stored with this tag across all layers.
await cache.set('user:123', user, { tags: ['user:123'] })
await cache.set('user:123:posts', posts, { tags: ['user:123'] })
await cache.invalidateByTag('user:123') // both keys gone
Delete keys matching any or all of a set of tags.
await cache.invalidateByTags(['tenant:a', 'users'], 'all') // keys tagged with both
await cache.invalidateByTags(['users', 'posts'], 'any') // keys tagged with either
cache.invalidateByPattern(pattern): Promise<void>
Glob-style deletion. Patterns must be non-empty, at most 1024 characters, and free of control characters.
await cache.invalidateByPattern('user:*')
cache.invalidateByPrefix(prefix): Promise<void>
Hierarchical prefix-based invalidation. Prefer this over glob when keys are hierarchical.
await cache.invalidateByPrefix('user:123:') // deletes user:123:profile, user:123:posts, ...
cache.expireByKey(key): Promise<void>
Marks one exact key as no longer fresh while keeping the cached value available for stale-while-revalidate / stale-if-error windows.
await cache.expireByKey('user:123') // expires only user:123
cache.expireByKeys(keys): Promise<void>
Expires only the exact keys provided without deleting their stored values.
await cache.expireByKeys(['user:123', 'user:123:posts'])
cache.expireByTag(tag): Promise<void>
Marks every key stored with this tag as no longer fresh while keeping the cached value available for stale-while-revalidate / stale-if-error windows.
await cache.set('user:123', user, {
ttl: 60_000,
staleWhileRevalidate: 30_000,
tags: ['user:123']
})
await cache.expireByTag('user:123') // value remains, next read can serve stale and refresh
Expire keys matching any or all of a set of tags without deleting the stored values.
await cache.expireByTags(['tenant:a', 'users'], 'all')
await cache.expireByTags(['users', 'posts'], 'any')
cache.expireByPattern(pattern): Promise<void>
Glob-style expiration. Matching envelope-backed entries keep their stale windows; plain layer values that do not carry layercache freshness metadata are left unchanged.
await cache.expireByPattern('user:*')
cache.expireByPrefix(prefix): Promise<void>
Hierarchical prefix-based expiration. Prefer this over glob when keys are hierarchical.
await cache.expireByPrefix('user:123:')
Wrapping & Namespaces
cache.wrap(prefix, fetcher, options?)
Wraps an async function so every call is transparently cached. The key is derived from function arguments unless you supply a keyResolver.
const getUser = cache.wrap('user', (id: number) => db.findUser(id))
const user = await getUser(123) // key -> "user:123"
// Custom key resolver
const getUser = cache.wrap(
'user',
(id: number) => db.findUser(id),
{ keyResolver: (id) => String(id), ttl: 300_000 }
)
cache.namespace(prefix): CacheNamespace
Returns a scoped view with the same full API. clear() only touches prefix:* keys.
const users = cache.namespace('users')
const posts = cache.namespace('posts')
await users.set('123', userData) // stored as "users:123"
await users.clear() // only deletes "users:*"
// Nested namespaces
const tenant = cache.namespace('tenant:abc')
const tenantPosts = tenant.namespace('posts')
await tenantPosts.set('1', data) // stored as "tenant:abc:posts:1"
Namespace prefixes must be non-empty, at most 256 characters, and free of control characters.
Warming & Persistence
cache.warm(entries, options?)
Pre-populate layers at startup. Higher priority values run first.
await cache.warm(
[
{ key: 'config', fetcher: () => db.getConfig(), priority: 10 },
{ key: 'user:1', fetcher: () => db.findUser(1), priority: 5 },
{ key: 'user:2', fetcher: () => db.findUser(2), priority: 5 },
],
{ concurrency: 4, continueOnError: true }
)
cache.exportState() / cache.importState(snapshot)
In-memory snapshot transfer.
const snapshot = await cache.exportState()
await anotherCache.importState(snapshot)
cache.persistToFile(path) / cache.restoreFromFile(path)
Disk-based snapshot persistence. Restricted to process.cwd() by default (configurable via snapshotBaseDir).
await cache.persistToFile('./cache-snapshot.json')
await cache.restoreFromFile('./cache-snapshot.json')
Observability
cache.getMetrics(): CacheMetricsSnapshot
const { hits, misses, fetches, staleHits, refreshes, writeFailures } = cache.getMetrics()
cache.getStats(): CacheStatsSnapshot
Returns metrics, per-layer degradation state, and background refresh count.
const { metrics, layers, backgroundRefreshes } = cache.getStats()
// layers: [{ name, isLocal, degradedUntil }]
cache.captureMetrics(operation)
Runs an async operation and returns only the metrics emitted while that
operation was active. Namespaces use this internally so overlapping namespace
operations do not serialize on a global metrics lock.
const { result, metrics } = await cache.captureMetrics(async () => {
return cache.get('user:123', fetchUser)
})
If the operation rejects, the thrown error is annotated with a metrics
property containing the captured CacheMetricsSnapshot.
try {
await cache.captureMetrics(async () => cache.get('user:123', fetchUser))
} catch (error) {
const metrics = (error as { metrics?: CacheMetricsSnapshot }).metrics
throw error
}
cache.getHitRate()
Computed hit rate overall and per-layer.
cache.healthCheck(): Promise<CacheHealthCheckResult[]>
const health = await cache.healthCheck()
// [{ layer: 'memory', healthy: true, latencyMs: 0.03 }, ...]
cache.resetMetrics(): void
Resets all counters to zero.
Generation Management
Add a generation prefix to every key and rotate it for bulk invalidation without scanning.
const cache = new CacheStack([...], { generation: 1 })
await cache.set('user:123', user)
cache.bumpGeneration() // now reads use v2:user:123
// Optional: auto-cleanup old generation keys
const cache = new CacheStack([...], {
generation: 1,
generationCleanup: { batchSize: 500 }
})
Persist the active generation outside the process when you deploy multiple
instances or restart workers:
import { CacheStack, RedisGenerationStore } from 'layercache'
const generations = new RedisGenerationStore({ client: redis })
const generation = await generations.getOrInitialize(1)
const cache = new CacheStack([...], { generation })
// Later, atomically rotate all future keys and apply that generation locally.
const nextGeneration = await generations.bump()
cache.bumpGeneration(nextGeneration)
cache.bumpGeneration()
Rotate cache namespace by incrementing generation.
cache.getGeneration()
Get current generation number.
Lifecycle
cache.disconnect(): Promise<void>
Graceful shutdown (unsubscribes from invalidation bus, etc.).
Cache Layers
All layers implement the CacheLayer interface:
interface CacheLayer {
readonly name: string
readonly defaultTtl?: number
readonly isLocal?: boolean
get<T>(key: string): Promise<T | null>
getEntry?<T>(key: string): Promise<unknown | null>
getMany?<T>(keys: string[]): Promise<Array<unknown | null>>
set(key: string, value: unknown, ttl?: number): Promise<void>
setMany?(entries: Array<{ key: string; value: unknown; ttl?: number }>): Promise<void>
delete(key: string): Promise<void>
deleteMany?(keys: string[]): Promise<void>
clear(): Promise<void>
keys?(): Promise<string[]>
forEachKey?(visitor: (key: string) => void | Promise<void>): Promise<void>
has?(key: string): Promise<boolean>
ttl?(key: string): Promise<number | null>
size?(): Promise<number>
ping?(): Promise<boolean>
dispose?(): Promise<void>
}
MemoryLayer
In-process LRU/LFU/FIFO eviction with configurable max size.
new MemoryLayer({
ttl: 60_000,
maxSize: 5_000,
name: 'memory' // default
})
RedisLayer
Distributed caching via ioredis with compression, serializers, and optional prefix.
new RedisLayer({
client: redis,
ttl: 300_000,
prefix: 'myapp:cache:',
compression: 'gzip',
compressionThreshold: 1_024,
commandTimeoutMs: 200,
serializer: new MsgpackSerializer(),
name: 'redis',
allowUnprefixedClear: false
})
commandTimeoutMs applies a per-command timeout to Redis round-trips. When a Redis command exceeds this threshold, the layer surfaces an error so CacheStack can trigger graceful degradation instead of waiting on a slow dependency indefinitely.
DiskLayer
Persistent file-based caching with atomic writes and optional at-rest protection.
import { resolve } from 'node:path'
new DiskLayer({
directory: resolve('./var/cache/layercache'),
maxFiles: 50_000,
maxWriteQueueDepth: 10_000,
name: 'disk'
})
maxWriteQueueDepth caps pending serialized set() / delete() work so a
slow disk cannot accumulate unbounded writes. Defaults to 10,000. Set it to
false to disable the guard for trusted low-volume environments.
At-Rest Protection
DiskLayer supports AES-256-GCM encryption or HMAC-SHA256 signing to protect cached data on disk:
new DiskLayer({
directory: resolve('./var/cache/layercache'),
encryptionKey: process.env.CACHE_ENCRYPTION_KEY, // AES-256-GCM encryption
signingKey: process.env.CACHE_SIGNING_KEY, // HMAC-SHA256 signing (ignored if encryptionKey is set)
name: 'disk'
})
Encryption also provides authenticated integrity — a separate signingKey is unnecessary when encryptionKey is provided.
MemcachedLayer
Memcached support with pluggable serializers and bulk operations.
new MemcachedLayer({
client: memcachedClient,
ttl: 300_000,
name: 'memcached'
})
Custom Layers
Implement CacheLayer to plug in any backend:
class MyCustomLayer implements CacheLayer {
readonly name = 'custom'
readonly defaultTtl = 300_000
readonly isLocal = false
async get<T>(key: string): Promise<T | null> { /* ... */ }
async set(key: string, value: unknown, ttl?: number): Promise<void> { /* ... */ }
async delete(key: string): Promise<void> { /* ... */ }
async clear(): Promise<void> { /* ... */ }
}
Options Reference
CacheStackOptions
CircuitBreakerOptions
If breakerKey is provided, it selects the explicit bucket id and takes
precedence over scope. Otherwise scope: 'key' creates one bucket per cache
key, while scope: 'shared' uses one shared bucket for all keys using those
options.
await cache.get('user:1', fetchFromApi, {
circuitBreaker: {
failureThreshold: 2,
cooldownMs: 60_000,
scope: 'shared',
breakerKey: 'users-api'
}
})
RateLimitOptions
Per-Operation Options
Invalidation Strategies
Tag Invalidation
await cache.set('user:123', user, { tags: ['user', 'user:123'] })
await cache.invalidateByTag('user:123')
Exact-Key Invalidation
await cache.invalidateByKey('user:123') // alias for delete()
await cache.invalidateByKeys(['user:123', 'user:456']) // alias for mdelete()
Batch Tag Invalidation
await cache.invalidateByTags(['tenant:a', 'users'], 'all')
await cache.invalidateByTags(['users', 'posts'], 'any')
Wildcard Invalidation
await cache.invalidateByPattern('user:*')
Prefix Invalidation
await cache.invalidateByPrefix('user:123:')
Expiration Without Deletion
Use the expireBy* counterparts when stale serving is preferable to removing values immediately.
await cache.expireByTag('user:123')
await cache.expireByKey('user:123')
await cache.expireByTags(['tenant:a', 'users'], 'all')
await cache.expireByKeys(['user:123', 'user:456'])
await cache.expireByPattern('user:*')
await cache.expireByPrefix('user:123:')
Generation-Based Invalidation
cache.bumpGeneration() // instant bulk invalidation without scanning
Freshness Strategies
Stale-While-Revalidate
await cache.set('config', config, {
ttl: 60_000,
staleWhileRevalidate: 30_000, // serve stale for 30s while refreshing
staleIfError: 300_000 // serve stale for 5min if refresh fails
})
Sliding TTL
await cache.get('session:abc', fetchSession, { slidingTtl: true })
Adaptive TTL
await cache.get('popular-post', fetchPost, {
adaptiveTtl: { hotAfter: 5, step: 60_000, maxTtl: 3_600_000 }
})
Adaptive TTL counters are process-local. In multi-instance deployments, each
Node.js process ramps TTLs from its own observed hits, so use explicit TTLs or a
shared Redis counter when every instance must make the same TTL decision.
Refresh-Ahead
await cache.get('leaderboard', fetchLeaderboard, {
ttl: 120_000,
refreshAhead: 30_000 // refresh when <= 30s remain
})
TTL Policies
await cache.set('daily-report', report, { ttlPolicy: 'until-midnight' })
await cache.set('hourly-rollup', rollup, { ttlPolicy: 'next-hour' })
await cache.set('aligned', value, { ttlPolicy: { alignTo: 300_000 } })
await cache.set('custom', value, {
ttlPolicy: ({ key }) => key.startsWith('hot:') ? 30_000 : 300_000
})
Context-Aware Entry Options
await cache.get('oauth:token', fetchToken, {
ttl: 300_000,
contextOptions: ({ value }) => {
const token = value as { refreshExpiresInMs: number; tenantId: string }
return {
ttl: Math.max(1, token.refreshExpiresInMs),
tags: ['oauth', `tenant:${token.tenantId}`]
}
}
})
contextOptions() runs immediately before a cache write and overrides static
entry settings on the same call. Use it for value-dependent ttl,
negativeTtl, staleWhileRevalidate, staleIfError, ttlJitter,
adaptiveTtl, or tags.
Per-Layer TTL Overrides
await cache.set('session:abc', data, {
ttl: { memory: 30_000, redis: 3_600_000 }
})
Conditional Caching
const data = await cache.get('api:response', fetchFromApi, {
shouldCache: (value) => (value as any).status === 200
})
Resilience
Graceful Degradation
new CacheStack([...], {
gracefulDegradation: { retryAfterMs: 10_000 }
})
Circuit Breaker
new CacheStack([...], {
circuitBreaker: { failureThreshold: 5, cooldownMs: 30_000 }
})
// Per-operation
await cache.get('fragile-key', fetch, {
circuitBreaker: { failureThreshold: 3, cooldownMs: 10_000 }
})
Write Policies
// Strict (default): fail if any layer fails
new CacheStack([...], { writePolicy: 'strict' })
// Best-effort: only fail if every layer fails
new CacheStack([...], { writePolicy: 'best-effort' })
Scoped Fetcher Rate Limiting
await cache.get('user:123', fetchUser, {
fetcherRateLimit: { maxConcurrent: 1, scope: 'key' }
})
Compression & Serialization
Compression
new RedisLayer({
client: redis,
ttl: 300_000,
compression: 'gzip', // or 'brotli'
compressionThreshold: 1_024 // skip compression for small values
})
For large payloads such as HTML fragments, denormalized API responses, or MB-scale documents, prefer compression: 'brotli' with a threshold around 1_024 * 1_024 so small values avoid compression overhead while large values pay less network cost.
MessagePack Serializer
import { MsgpackSerializer } from 'layercache'
new RedisLayer({
client: redis,
ttl: 300_000,
serializer: new MsgpackSerializer()
})
Distributed Features
Distributed Single-Flight
import { RedisSingleFlightCoordinator } from 'layercache'
const coordinator = new RedisSingleFlightCoordinator({ client: redis })
new CacheStack([...], {
singleFlightCoordinator: coordinator,
singleFlightLeaseMs: 30_000,
singleFlightRenewIntervalMs: 10_000,
})
Cross-Server L1 Invalidation
import { RedisInvalidationBus } from 'layercache'
const bus = new RedisInvalidationBus({
publisher: redis,
subscriber: new Redis(),
signingSecret: process.env.LAYERCACHE_INVALIDATION_SECRET
})
new CacheStack([...], {
invalidationBus: bus,
broadcastL1Invalidation: true
})
Distributed Tag Index
import { RedisTagIndex } from 'layercache'
const tagIndex = new RedisTagIndex({
client: redis,
prefix: 'myapp:tag-index',
knownKeysShards: 16
})
new CacheStack([...], { tagIndex })
Event Hooks
CacheStack extends EventEmitter:
cache.on('hit', ({ key, layer }) => metrics.inc('cache.hit', { layer }))
cache.on('miss', ({ key }) => metrics.inc('cache.miss'))
cache.on('error', ({ event, context }) => logger.error(event, context))
Framework Integrations
Express
import { createExpressCacheMiddleware } from 'layercache'
app.get('/api/users', createExpressCacheMiddleware(cache, {
ttl: 30_000,
tags: ['users'],
keyResolver: (req) => `user:${req.url}`
}), handler)
Fastify
import { createFastifyLayercachePlugin } from 'layercache'
await fastify.register(createFastifyLayercachePlugin(cache, {
statsPath: '/cache/stats'
}))
Hono
import { createHonoCacheMiddleware } from 'layercache'
app.use('/api/*', createHonoCacheMiddleware(cache, { ttl: 60_000 }))
tRPC
import { createTrpcCacheMiddleware } from 'layercache'
const cacheMiddleware = createTrpcCacheMiddleware(cache, 'trpc', { ttl: 60_000 })
export const cachedProcedure = t.procedure.use(cacheMiddleware)
GraphQL
import { cacheGraphqlResolver } from 'layercache'
const resolvers = {
Query: {
user: cacheGraphqlResolver(cache, 'user', (_root, { id }) => db.findUser(id), {
keyResolver: (_root, { id }) => id,
ttl: 300_000
})
}
}
NestJS
Use CacheStack directly in your NestJS modules:
import { CacheStack, MemoryLayer, RedisLayer } from 'layercache'
import Redis from 'ioredis'
@Module({
providers: [
{
provide: 'CACHE',
useFactory: () => new CacheStack([
new MemoryLayer({ ttl: 60_000 }),
new RedisLayer({ client: new Redis(), ttl: 300_000 })
])
}
],
exports: ['CACHE']
})
export class CacheModule {}
Note: The separate @cachestack/nestjs package was removed in v1.3.2. Import directly from layercache instead.
OpenTelemetry
import { createOpenTelemetryPlugin } from 'layercache'
createOpenTelemetryPlugin(cache, tracer)
Stats HTTP Handler
import { createCacheStatsHandler } from 'layercache'
import http from 'node:http'
const statsHandler = createCacheStatsHandler(cache)
http.createServer(statsHandler).listen(9090)
Admin CLI
Inspect and manage Redis-backed caches from the terminal.
npx layercache stats --redis redis://localhost:6379
npx layercache keys --redis redis://localhost:6379 --pattern "user:*"
npx layercache invalidate --redis redis://localhost:6379 --tag user:123
npx layercache invalidate --redis redis://localhost:6379 --pattern "session:*"
Debug Logging
DEBUG=layercache:debug node server.js
Or pass a logger instance:
new CacheStack([...], {
logger: {
debug(message, context) { myLogger.debug(message, context) }
}
})