Distributed Deployment
Layercache provides distributed coordination features for multi-server deployments. Prevent stampedes, coordinate invalidations, and maintain consistent tag indexes across your infrastructure.
Overview
When running multiple instances of your application, you need to coordinate cache operations to avoid:
- Stampedes - Multiple servers fetching the same uncached key simultaneously
- Stale L1 caches - Memory layers holding outdated data after writes
- Inconsistent tags - Different servers with different tag-to-key mappings
Layercache solves these with three Redis-based components:
- RedisSingleFlightCoordinator - Distributed request deduplication
- RedisInvalidationBus - Cross-server L1 invalidation via pub/sub
- RedisTagIndex - Shared tag index for consistent invalidations
RedisSingleFlightCoordinator
Prevent multiple servers from fetching the same key simultaneously using distributed locking.
How It Works
- First server to request a key acquires a Redis lock (SET NX PX)
- Lock holder runs the fetcher and writes the value
- Other servers wait and poll for the value
- Lock is released automatically via Lua script
- Waiters read the cached value and return
Installation
import { CacheStack, MemoryLayer, RedisLayer, RedisSingleFlightCoordinator } from 'layercache'
import Redis from 'ioredis'
const redis = new Redis()
const coordinator = new RedisSingleFlightCoordinator({ client: redis })
Basic Setup
const cache = new CacheStack([
new MemoryLayer({ ttl: 60_000 }),
new RedisLayer({ client: redis, ttl: 300_000 })
], {
singleFlightCoordinator: coordinator,
singleFlightLeaseMs: 30000, // Lock duration
singleFlightTimeoutMs: 5000, // Max wait time
singleFlightPollMs: 50 // Polling interval
})
Configuration Options
Lease Renewal
The lock holder automatically renews the lease to prevent expiration during long fetches:
const cache = new CacheStack([/* ... */], {
singleFlightCoordinator: coordinator,
singleFlightLeaseMs: 60000,
singleFlightRenewIntervalMs: 10000 // Renew every 10 seconds
})
Custom Prefix
const coordinator = new RedisSingleFlightCoordinator({
client: redis,
prefix: 'myapp:singleflight'
})
RedisInvalidationBus
Broadcast invalidations to all servers via Redis pub/sub.
How It Works
- Server A writes a key
- Invalidation message is published to Redis channel
- All servers receive the message
- Each server invalidates its local memory layer
- Tag index is updated (if using RedisTagIndex)
Installation
import { CacheStack, MemoryLayer, RedisLayer, RedisInvalidationBus } from 'layercache'
import Redis from 'ioredis'
const publisher = new Redis()
const subscriber = new Redis()
const bus = new RedisInvalidationBus({
publisher,
subscriber,
channel: 'myapp:invalidation',
signingSecret: process.env.LAYERCACHE_INVALIDATION_SECRET
})
Basic Setup
const cache = new CacheStack([
new MemoryLayer({ ttl: 60_000 }),
new RedisLayer({ client: publisher, ttl: 300_000 })
], {
invalidationBus: bus,
broadcastL1Invalidation: true
})
Configuration Options
{
sourceId: string, // Unique server ID
scope: 'key' | 'keys' | 'clear',
operation: 'write' | 'delete' | 'invalidate' | 'clear',
keys: string[], // Affected keys
timestamp: number
}
Example Flow
// Server A
await cache.set('user:123', user)
// → Publishes { keys: ['user:123'], operation: 'write' }
// Server B (memory layer)
// → Receives message, deletes 'user:123' from memory
// Server B (next read)
await cache.get('user:123', fetchUser)
// → Miss in memory, hits in Redis, backfills memory
Custom Channel
const bus = new RedisInvalidationBus({
publisher: redis,
subscriber: new Redis(),
channel: 'myapp:cache:invalidation',
signingSecret: process.env.LAYERCACHE_INVALIDATION_SECRET,
logger: console
})
Signed Messages
Use signingSecret when a Redis channel can be reached by more than one trusted application. Layercache signs published invalidation messages and verifies inbound messages before handlers run.
const bus = new RedisInvalidationBus({
publisher: redis,
subscriber: new Redis(process.env.REDIS_URL),
channel: 'myapp:cache:invalidation',
signingSecret: process.env.LAYERCACHE_INVALIDATION_SECRET
})
All instances on the same channel must use the same secret. Unsigned mode remains available for compatibility with older deployments.
Cleanup
// When shutting down, unsubscribe from Redis
await cache.disconnect()
RedisTagIndex
Maintain a consistent tag index across all servers using Redis sets.
How It Works
- When setting a key with tags, add key-to-tags mapping to Redis
- Add key to tag-indexed sets for efficient lookups
- When invalidating by tag, fetch all keys for that tag
- Delete keys and publish invalidations
Sharding for Scale
The tag index shards known-keys sets across multiple Redis keys for horizontal scaling:
- Default: 16 shards
- Set
knownKeysShards: 1 only when you intentionally need the legacy single-set layout
- Sharding reduces single-key contention
Installation
import { CacheStack, MemoryLayer, RedisLayer, RedisTagIndex } from 'layercache'
import Redis from 'ioredis'
const redis = new Redis()
const tagIndex = new RedisTagIndex({
client: redis,
prefix: 'myapp:tag-index',
knownKeysShards: 16 // Default: distribute across 16 sets
})
Basic Setup
const cache = new CacheStack([
new MemoryLayer({ ttl: 60_000 }),
new RedisLayer({ client: redis, ttl: 300_000 })
], {
tagIndex
})
// Use tags as usual
await cache.set('user:123', user, { tags: ['user', 'user:123'] })
await cache.invalidateByTag('user:123')
Configuration Options
Redis Key Structure
myapp:tag-index:tag:<tag> → SET of keys with this tag
myapp:tag-index:key:<key> → SET of tags for this key
myapp:tag-index:keys:<shard> → SET of all known keys (sharded)
Tag Invalidation Flow
// 1. Set key with tags
await cache.set('user:123:posts', posts, {
tags: ['user:123', 'posts']
})
// 2. Invalidate by tag
await cache.invalidateByTag('user:123')
// Behind the scenes:
// - Fetches all keys from 'myapp:tag-index:tag:user:123'
// - Deletes keys from Redis
// - Publishes invalidation to RedisInvalidationBus
// - Cleans up tag index entries
Sharding Example
// The default is already 16; tune explicitly for unusual workloads
const tagIndex = new RedisTagIndex({
client: redis,
knownKeysShards: 16 // Distribute across 16 sets
})
// Each shard handles ~1/16 of known keys
// Reduces contention on SSCAN/SADD operations
Migrating Legacy Known Keys
Older deployments may have a single known-key set at <prefix>:keys. Current RedisTagIndex reads that set for compatibility and logs a warning when it exists, but migration avoids repeated fallback scans:
npx layercache migrate-tag-index \
--redis rediss://prod-redis.example.com:6380 \
--tag-index-prefix "myapp:tag-index" \
--known-key-shards 16 \
--require-tls
Complete Distributed Setup
Combine all three components for a production-ready distributed cache:
import { CacheStack, MemoryLayer, RedisLayer } from 'layercache'
import {
RedisSingleFlightCoordinator,
RedisInvalidationBus,
RedisTagIndex
} from 'layercache'
import Redis from 'ioredis'
const redis = new Redis({
host: process.env.REDIS_HOST,
port: 6379,
retryStrategy: (times) => Math.min(times * 50, 2000)
})
// 1. Distributed single-flight
const coordinator = new RedisSingleFlightCoordinator({
client: redis,
prefix: 'myapp:singleflight'
})
// 2. Cross-server invalidation
const bus = new RedisInvalidationBus({
publisher: redis,
subscriber: new Redis(),
channel: 'myapp:invalidation',
signingSecret: process.env.LAYERCACHE_INVALIDATION_SECRET,
logger: console
})
// 3. Shared tag index
const tagIndex = new RedisTagIndex({
client: redis,
prefix: 'myapp:tag-index',
knownKeysShards: 16
})
// 4. Create cache stack
const cache = new CacheStack([
new MemoryLayer({ ttl: 60_000 }),
new RedisLayer({ client: redis, ttl: 300_000 })
], {
// Distributed single-flight
singleFlightCoordinator: coordinator,
singleFlightLeaseMs: 30000,
singleFlightTimeoutMs: 5000,
singleFlightPollMs: 50,
// L1 invalidation
invalidationBus: bus,
broadcastL1Invalidation: true,
// Tag index
tagIndex
})
// 5. Use the cache
await cache.set('user:123', user, { tags: ['user:123'] })
const user = await cache.get('user:123', fetchUser)
await cache.invalidateByTag('user:123')
// 6. Graceful shutdown
process.on('SIGTERM', async () => {
await cache.disconnect()
await redis.quit()
process.exit(0)
})
Architecture Diagram
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Server A │ │ Server B │ │ Redis │
├─────────────┤ ├─────────────┤ ├─────────────┤
│ Memory L1 │ │ Memory L1 │ │ │
│ Redis L2 │ │ Redis L2 │ │ Data Store │
│ │ │ │ │ │
│ Coordinator │◄────┤ Coordinator │◄────│ Locks │
│ Bus Pub │ │ Bus Sub │◄────│ Pub/Sub │
│ Tag Index │◄────┤ Tag Index │◄────│ Tag Sets │
└─────────────┘ └─────────────┘ └─────────────┘
Single Flight
- Lock acquisition: ~1-2ms (SET NX)
- Polling overhead: ~50ms intervals
- Lease renewal: Background, non-blocking
Invalidation Bus
- Publish latency: <1ms
- Message size: ~1KB per invalidation
- Subscriber lag: Network-dependent
Tag Index
- Set operations: O(1) per key
- Tag invalidation: O(n) where n = keys with tag
- Sharding: Reduces contention by factor of shards
Recommended Settings
For high-traffic deployments:
const cache = new CacheStack([/* ... */], {
// Single-flight: longer leases for slow fetchers
singleFlightLeaseMs: 60000,
singleFlightRenewIntervalMs: 10000,
singleFlightPollMs: 100,
// Tag index: more shards for scale
tagIndex: new RedisTagIndex({
client: redis,
knownKeysShards: 16
})
})
Monitoring
Track Coordinator Operations
cache.on('error', ({ event, context }) => {
if (context.operation === 'singleFlight') {
console.error('Single-flight error:', context.error)
}
})
Monitor Bus Messages
cache.on('invalidation', ({ keys, sourceId }) => {
console.log(`Invalidated ${keys.length} keys from ${sourceId}`)
})
Check Tag Index Size
const redis = new Redis()
const keys = await redis.keys('layercache:tag-index:tag:*')
console.log(`Tracking ${keys.length} tags`)
Troubleshooting
Lock Timeouts
If servers timeout waiting for locks:
// Increase lease duration
singleFlightLeaseMs: 60000
// Increase wait timeout
singleFlightTimeoutMs: 10000
Stale L1 Data
If memory layers aren't invalidating:
// Ensure broadcast is enabled
broadcastL1Invalidation: true
// Check bus connection
bus.on('error', (err) => console.error('Bus error:', err))
Slow Tag Invalidations
If tag invalidations are slow:
// Increase shards for parallelization
tagIndex: new RedisTagIndex({
client: redis,
knownKeysShards: 16 // More shards
})
High Redis Memory
If tag index uses too much memory:
// Reduce scan batch size
tagIndex: new RedisTagIndex({
client: redis,
scanCount: 50 // Fewer keys per SSCAN
})