Cache Layers
layercache provides several built-in cache layer implementations, each optimized for different use cases. You can also create custom layers by implementing the CacheLayer interface.
Table of Contents
MemoryLayer
In-process cache with configurable eviction policies. Perfect for L1 caching with sub-microsecond latency.
Features
- Eviction policies: LRU (default), LFU, or FIFO
- Max size enforcement: Automatically evicts when limit reached
- TTL support: Per-entry expiration with optional cleanup interval
- Snapshot support: Export/import state for persistence
- Zero dependencies: Pure in-memory storage
Configuration
import { MemoryLayer } from 'layercache'
const memoryLayer = new MemoryLayer({
ttl: 60_000, // Default TTL in milliseconds
maxSize: 5_000, // Maximum number of entries
name: 'memory', // Layer name for metrics
evictionPolicy: 'lru', // 'lru' | 'lfu' | 'fifo'
cleanupIntervalMs: 60_000, // Expired entry cleanup interval
onEvict: (key, value) => { // Optional eviction callback
console.log(`Evicted: ${key}`)
}
})
Eviction Policies
LRU (Least Recently Used) - Default
Evicts entries that haven't been accessed for the longest time. Best for general-purpose caching.
new MemoryLayer({
evictionPolicy: 'lru',
maxSize: 1_000
})
LFU (Least Frequently Used)
Evicts entries with the lowest access count. Best for workloads with hot spots.
new MemoryLayer({
evictionPolicy: 'lfu',
maxSize: 1_000
})
FIFO (First In, First Out)
Evicts the oldest entries regardless of access pattern. Best for time-series data.
new MemoryLayer({
evictionPolicy: 'fifo',
maxSize: 1_000
})
State Persistence
// Export current state
const snapshot = memoryLayer.exportState()
// [{ key: 'user:123', value: {...}, expiresAt: 1712847652000 }, ...]
// Import state (e.g., after process restart)
memoryLayer.importState(snapshot)
Cleanup Interval
MemoryLayer periodically scans for expired entries to free memory.
new MemoryLayer({
ttl: 300_000,
cleanupIntervalMs: 30_000 // Cleanup every 30 seconds
})
Set to 0 or omit to disable periodic cleanup (entries still expire on access).
RedisLayer
Distributed caching layer backed by Redis with compression, serialization, and pipeline optimizations.
Features
- Shared state: Multiple processes/servers share the same cache
- Compression: gzip or brotli compression with configurable threshold
- Serializer chain: Try multiple deserializers for smooth migrations
- Pipeline batch ops: Fast multi-key reads/writes
- Scan-based operations: Efficient keys/clear without blocking
- Prefix support: Isolate different applications in the same Redis instance
Configuration
import { RedisLayer } from 'layercache'
import Redis from 'ioredis'
const redisLayer = new RedisLayer({
client: new Redis(), // ioredis client instance
ttl: 300_000, // Default TTL in milliseconds
prefix: 'myapp:cache:', // Key prefix for namespacing
compression: 'gzip', // 'gzip' | 'brotli' | undefined
compressionThreshold: 1_024, // Min bytes to compress (default: 1024)
decompressionMaxBytes: 64 * 1_024 * 1_024, // Max decompressed size
serializer: new MsgpackSerializer(), // Custom serializer
name: 'redis', // Layer name for metrics
allowUnprefixedClear: false, // Require prefix for clear()
scanCount: 100, // SCAN COUNT batch size
disconnectOnDispose: false // Disconnect client on dispose
})
Compression
Reduce Redis memory usage for large values:
// Gzip compression (faster, moderate compression)
new RedisLayer({
client: redis,
compression: 'gzip',
compressionThreshold: 1_024 // Only compress >1KB values
})
// Brotli compression (slower, better compression)
new RedisLayer({
client: redis,
compression: 'brotli',
compressionThreshold: 512
})
Serialization Chain
Support smooth data format migrations by trying multiple deserializers:
import { JsonSerializer, MsgpackSerializer } from 'layercache'
new RedisLayer({
client: redis,
serializer: [
new MsgpackSerializer(), // Try MessagePack first
new JsonSerializer() // Fall back to JSON
]
})
// On write: always use first serializer (MessagePack)
// On read: try each serializer until one succeeds
// Successful reads auto-migrate to primary format
Key Prefixing
Isolate different applications or environments:
const productionCache = new RedisLayer({
client: redis,
prefix: 'prod:cache:'
})
const stagingCache = new RedisLayer({
client: redis,
prefix: 'staging:cache:'
})
Clear Behavior
clear() requires a prefix by default to prevent accidental data loss:
const layer = new RedisLayer({
client: redis,
prefix: 'myapp:cache:',
allowUnprefixedClear: false // Default
})
await layer.clear() // OK - deletes "myapp:cache:*" only
const unsafeLayer = new RedisLayer({
client: redis,
prefix: '',
allowUnprefixedClear: true // Explicitly allow dangerous clear
})
await unsafeLayer.clear() // DELETES EVERYTHING IN REDIS
Pipeline Operations
RedisLayer uses ioredis pipelines for efficient bulk operations:
// getMany() uses pipeline()
const values = await redisLayer.getMany(['key1', 'key2', 'key3'])
// setMany() uses pipeline()
await redisLayer.setMany([
{ key: 'key1', value: data1, ttl: 60_000 },
{ key: 'key2', value: data2, ttl: 60_000 }
])
// deleteMany() uses pipeline()
await redisLayer.deleteMany(['key1', 'key2', 'key3'])
DiskLayer
Persistent file-based cache with atomic writes and LRU eviction. Perfect for caching across restarts without Redis.
Features
- Persistent storage: Survives process restarts
- Atomic writes: Uses temp file + rename for crash safety
- SHA-256 hashed filenames: Safe for any cache key
- Max files enforcement: LRU eviction when limit exceeded
- Size limits: Configurable max entry size
- Concurrent operations: Safe for multi-process access
Configuration
import { DiskLayer } from 'layercache'
import { resolve } from 'node:path'
const diskLayer = new DiskLayer({
directory: resolve('./var/cache/layercache'), // Cache directory
ttl: 3_600_000, // Default TTL in milliseconds
name: 'disk', // Layer name for metrics
maxFiles: 50_000, // Maximum cache files (default: 50,000)
maxEntryBytes: 16 * 1_024 * 1_024, // Max 16MiB per entry
maxWriteQueueDepth: 10_000, // Max pending serialized writes
serializer: new JsonSerializer(), // Optional custom serializer
encryptionKey: process.env.CACHE_ENCRYPTION_KEY, // Optional AES-256-GCM encryption
signingKey: process.env.CACHE_SIGNING_KEY // Optional HMAC-SHA256 signing
})
At-Rest Protection
DiskLayer supports optional at-rest protection for cached data using AES-256-GCM encryption or HMAC-SHA256 signing:
// Full encryption (recommended)
new DiskLayer({
directory: './cache',
encryptionKey: process.env.CACHE_ENCRYPTION_KEY // AES-256-GCM
})
// Signing only (integrity verification without encryption)
new DiskLayer({
directory: './cache',
signingKey: process.env.CACHE_SIGNING_KEY // HMAC-SHA256
})
// Both — signingKey is ignored when encryptionKey is provided
// (encryption already provides authenticated integrity)
new DiskLayer({
directory: './cache',
encryptionKey: process.env.CACHE_ENCRYPTION_KEY,
signingKey: process.env.CACHE_SIGNING_KEY
})
File Storage
Each cache entry is stored as a separate file:
// Cache key -> SHA-256 hash
'user:123:profile' -> 'a1b2c3d4...lf'
// File contents (JSON)
{
"key": "user:123:profile",
"value": { "name": "Alice", ... },
"expiresAt": 1712847652000
}
Atomic Writes
DiskLayer uses temp file + rename for crash-safe writes:
// Write to temp file first
await fs.writeFile('cache/a1b2...lf.tmp.12345.67890.tmp', data)
// Atomic rename (fails if target exists)
await fs.rename('cache/a1b2...lf.tmp.12345.67890.tmp', 'cache/a1b2...lf')
If the process crashes mid-write, the temp file is cleaned up on next access.
Max Files Enforcement
When maxFiles is exceeded, oldest files (by mtime) are evicted:
new DiskLayer({
directory: './cache',
maxFiles: 10_000 // Keep only 10k most recent entries
})
Size Limits
Prevent disk space exhaustion with entry size limits:
new DiskLayer({
directory: './cache',
maxEntryBytes: 16 * 1_024 * 1_024 // Reject entries >16MiB
})
// Disable size limit
new DiskLayer({
directory: './cache',
maxEntryBytes: false
})
Oversized entries are treated as corrupted and deleted.
Write Queue Guard
DiskLayer serializes writes to avoid corrupting files. maxWriteQueueDepth
prevents a slow disk from accumulating unbounded pending writes:
new DiskLayer({
directory: './cache',
maxWriteQueueDepth: 1_000
})
// Disable only when the environment already bounds write pressure.
new DiskLayer({
directory: './cache',
maxWriteQueueDepth: false
})
MemcachedLayer
Memcached-backed cache layer with key validation and pluggable serializers.
Features
- Binary protocol: Compatible with
memjs and memcache-client
- Key validation: Enforces 250-byte key limit
- Pluggable serializers: JSON default, MessagePack supported
- Bulk operations: getMany, deleteMany support
Configuration
import { MemcachedLayer } from 'layercache'
import Memjs from 'memjs'
const memcached = Memjs.Client.create('localhost:11211')
const memcachedLayer = new MemcachedLayer({
client: memcached, // Memcached client (memjs or memcache-client)
ttl: 300_000, // Default TTL in milliseconds
name: 'memcached', // Layer name for metrics
keyPrefix: 'myapp:', // Optional key prefix
serializer: new JsonSerializer() // Optional custom serializer
})
Key Limit Validation
Memcached enforces a 250-byte key limit. MemcachedLayer validates keys before sending:
// This throws: key exceeds 250 bytes
await cache.set('a'.repeat(251), value)
// This throws: key contains invalid characters
await cache.set('key with spaces', value)
Use a short keyPrefix to reserve space for dynamic keys:
const layer = new MemcachedLayer({
client: memcached,
keyPrefix: 'app1:' // Uses 5 bytes, leaves 245 for dynamic keys
})
Clear Not Supported
Memcached doesn't support pattern-based deletion. Use key prefix rotation instead:
// DON'T do this - throws
await memcachedLayer.clear()
// DO this instead - rotate prefix
const cache = new CacheStack([
new MemcachedLayer({ client: memcached, keyPrefix: 'v1:' })
])
// To invalidate all keys, deploy with new prefix
const cache2 = new CacheStack([
new MemcachedLayer({ client: memcached, keyPrefix: 'v2:' })
])
Custom Layers
Implement the CacheLayer interface to create custom cache backends.
Interface
interface CacheLayer {
readonly name: string
readonly defaultTtl?: number
readonly isLocal?: boolean
// Required methods
get<T>(key: string): Promise<T | null>
set(key: string, value: unknown, ttl?: number): Promise<void>
delete(key: string): Promise<void>
clear(): Promise<void>
// Optional optimizations
getEntry?<T>(key: string): Promise<T | null>
getMany?<T>(keys: string[]): Promise<Array<T | null>>
setMany?(entries: Array<{ key: string; value: unknown; ttl?: number }>): Promise<void>
deleteMany?(keys: string[]): 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>
}
Example: Cloudflare KV Layer
interface KVLayerOptions {
namespace: KVNamespace
ttl?: number
name?: string
}
class KVLayer implements CacheLayer {
readonly name: string
readonly defaultTtl?: number
readonly isLocal = false
constructor(private options: KVLayerOptions) {
this.name = options.name ?? 'kv'
this.defaultTtl = options.ttl
}
async get<T>(key: string): Promise<T | null> {
const value = await this.options.namespace.get(key, 'json')
return value as T | null
}
async set(key: string, value: unknown, ttl?: number): Promise<void> {
await this.options.namespace.put(key, JSON.stringify(value), {
expirationTtl: ttl ?? this.defaultTtl
})
}
async delete(key: string): Promise<void> {
await this.options.namespace.delete(key)
}
async clear(): Promise<void> {
// KV doesn't support clear - implement key listing if needed
throw new Error('KVLayer.clear() is not supported')
}
// Optional optimizations
async getMany<T>(keys: string[]): Promise<Array<T | null>> {
return Promise.all(keys.map(key => this.get<T>(key)))
}
async deleteMany(keys: string[]): Promise<void> {
await Promise.all(keys.map(key => this.delete(key)))
}
}
Example: S3 Layer
import { S3Client, GetObjectCommand, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'
class S3Layer implements CacheLayer {
readonly name = 's3'
readonly defaultTtl?: number
readonly isLocal = false
constructor(
private s3: S3Client,
private bucket: string,
private prefix: string
) {}
async get<T>(key: string): Promise<T | null> {
try {
const response = await this.s3.send(new GetObjectCommand({
Bucket: this.bucket,
Key: `${this.prefix}${key}`
}))
const body = await response.Body.transformToString()
return JSON.parse(body) as T
} catch {
return null
}
}
async set(key: string, value: unknown, ttl?: number): Promise<void> {
await this.s3.send(new PutObjectCommand({
Bucket: this.bucket,
Key: `${this.prefix}${key}`,
Body: JSON.stringify(value),
Expires: ttl ? new Date(Date.now() + ttl * 1000) : undefined
}))
}
async delete(key: string): Promise<void> {
await this.s3.send(new DeleteObjectCommand({
Bucket: this.bucket,
Key: `${this.prefix}${key}`
}))
}
async clear(): Promise<void> {
// S3 doesn't support clear - use lifecycle rules or prefix rotation
throw new Error('S3Layer.clear() is not supported')
}
}
Best Practices
-
Implement optional methods: getMany, setMany, deleteMany provide significant performance improvements
-
Return correct TTL: If your backend supports TTL, implement the ttl() method
-
Handle errors gracefully: Return null on network errors instead of throwing
-
Use getEntry for metadata: If your backend stores metadata (e.g., creation time), implement getEntry to support stale-while-revalidate
-
Set isLocal correctly: This affects distributed invalidation behavior
-
Implement dispose: Clean up resources (connections, timers) when the layer is no longer needed
class MyLayer implements CacheLayer {
readonly name = 'custom'
readonly defaultTtl = 300
readonly isLocal = true // Set based on your backend
// Implement all required methods...
async dispose(): Promise<void> {
// Clean up connections, timers, etc.
await this.connection.close()
}
}