Multi-Level Cache with In-Memory L1 and Redis L2
Implement a two-level cache combining in-memory L1 and Redis L2 for low-latency reads with cross-instance consistency
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Multi-Level Cache with In-Memory L1 and Redis L2
A single cache layer is rarely optimal. In-memory caches offer sub-millisecond reads but are per-instance. Redis offers cross-instance consistency but adds network latency. A two-level cache combines both: L1 (in-memory) for hot data with zero network overhead, and L2 (Redis) for shared state across instances. The following implements an L1+L2 cache with pub/sub-based invalidation.
When to Use This
- High-traffic APIs where Redis round-trip latency is a bottleneck
- Multiple server instances that need to share cached data
- Read-heavy workloads where the same data is accessed repeatedly within a single instance
Prerequisites
- Node.js 18+
redispackage (npm install redis)lru-cachepackage (npm install lru-cache)
Solution
1. Install Dependencies
npm install redis lru-cache
2. Implement the Multi-Level Cache
// multi-level-cache.ts
import { LRUCache } from 'lru-cache';
import { createClient, RedisClientType } from 'redis';
import { EventEmitter } from 'events';
export class MultiLevelCache {
private l1: LRUCache<string, any>;
private l2: RedisClientType;
private pubsub: RedisClientType;
private invalidationChannel: string;
private ready: boolean = false;
constructor(
redisUrl: string = 'redis://localhost:6379',
options: {
l1MaxSize?: number;
l1Ttl?: number;
l2Ttl?: number;
invalidationChannel?: string;
} = {},
) {
this.l1 = new LRUCache<string, any>({
max: options.l1MaxSize ?? 1000,
ttl: options.l1Ttl ?? 60_000,
updateAgeOnGet: true,
});
this.l2 = createClient({ url: redisUrl });
this.pubsub = createClient({ url: redisUrl });
this.invalidationChannel = options.invalidationChannel ?? 'cache:invalidate';
}
async connect(): Promise<void> {
await this.l2.connect();
await this.pubsub.connect();
await this.pubsub.subscribe(this.invalidationChannel, (message) => {
const { key } = JSON.parse(message);
this.l1.delete(key);
});
this.ready = true;
}
async disconnect(): Promise<void> {
await this.pubsub.unsubscribe(this.invalidationChannel);
await this.pubsub.quit();
await this.l2.quit();
this.l1.clear();
this.ready = false;
}
async get<T>(key: string): Promise<T | undefined> {
// L1 — in-memory
const l1Value = this.l1.get(key);
if (l1Value !== undefined) {
return l1Value as T;
}
// L2 — Redis
const l2Value = await this.l2.get(key);
if (l2Value !== null) {
const parsed = JSON.parse(l2Value) as T;
this.l1.set(key, parsed);
return parsed;
}
return undefined;
}
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
const serialized = JSON.stringify(value);
// Write to both levels
this.l1.set(key, value, { ttl: ttl ?? 60_000 });
await this.l2.set(key, serialized, { EX: Math.floor((ttl ?? 300_000) / 1000) });
}
async getOrLoad<T>(
key: string,
loader: () => Promise<T>,
ttl?: number,
): Promise<T> {
const cached = await this.get<T>(key);
if (cached !== undefined) {
return cached;
}
const value = await loader();
await this.set(key, value, ttl);
return value;
}
async invalidate(key: string): Promise<void> {
this.l1.delete(key);
await this.l2.del(key);
// Notify other instances to invalidate their L1
await this.l2.publish(
this.invalidationChannel,
JSON.stringify({ key }),
);
}
async invalidatePattern(pattern: string): Promise<void> {
// Clear L1 entries matching pattern
for (const key of this.l1.keys()) {
if (this._matchPattern(key, pattern)) {
this.l1.delete(key);
}
}
// Clear L2 entries matching pattern
const keys = [];
for await (const key of this.l2.scanIterator({ MATCH: pattern, COUNT: 100 })) {
keys.push(key);
}
if (keys.length > 0) {
await this.l2.del(keys);
}
// Notify other instances
await this.l2.publish(
this.invalidationChannel,
JSON.stringify({ pattern }),
);
}
private _matchPattern(key: string, pattern: string): boolean {
const regex = pattern.replace(/\*/g, '.*');
return new RegExp(`^${regex}$`).test(key);
}
get stats() {
return {
l1Size: this.l1.size,
l1MaxSize: this.l1.max,
ready: this.ready,
};
}
}
3. Use the Cache
// usage.ts
import { MultiLevelCache } from './multi-level-cache';
const cache = new MultiLevelCache('redis://localhost:6379', {
l1MaxSize: 500,
l1Ttl: 30_000, // 30 seconds in L1
l2Ttl: 300_000, // 5 minutes in L2
});
await cache.connect();
// Get or load from database
const user = await cache.getOrLoad(
`user:123`,
() => db.users.findById(123),
120_000, // 2 minutes TTL
);
// Invalidate after update
async function updateUser(id: string, data: dict) {
const user = await db.users.update(id, data);
await cache.invalidate(`user:${id}`);
return user;
}
4. Cache Hit Rate Monitoring
export class InstrumentedMultiLevelCache extends MultiLevelCache {
private hits = { l1: 0, l2: 0, miss: 0 };
async get<T>(key: string): Promise<T | undefined> {
const l1Value = this.l1.get(key);
if (l1Value !== undefined) {
this.hits.l1++;
return l1Value as T;
}
const l2Value = await this.l2.get(key);
if (l2Value !== null) {
this.hits.l2++;
const parsed = JSON.parse(l2Value) as T;
this.l1.set(key, parsed);
return parsed;
}
this.hits.miss++;
return undefined;
}
getHitRates() {
const total = this.hits.l1 + this.hits.l2 + this.hits.miss;
if (total === 0) return { l1: 0, l2: 0, miss: 0, total: 0 };
return {
l1: (this.hits.l1 / total * 100).toFixed(1) + '%',
l2: (this.hits.l2 / total * 100).toFixed(1) + '%',
miss: (this.hits.miss / total * 100).toFixed(1) + '%',
total,
};
}
resetStats() {
this.hits = { l1: 0, l2: 0, miss: 0 };
}
}
How It Works
- L1 read — checks the in-memory LRU cache first. If found, returns immediately with zero network overhead (sub-millisecond).
- L2 read — on L1 miss, checks Redis. If found, populates L1 and returns. This adds ~1ms network latency but hits the shared cache.
- Cache miss — if both L1 and L2 miss, the
loaderfunction fetches from the database. The result is written to both L1 and L2. - Invalidation —
invalidatedeletes from L1, L2, and publishes a pub/sub message. Other instances subscribe and delete their L1 entry, ensuring cross-instance consistency. - Different TTLs — L1 has a shorter TTL (30s) than L2 (300s). This allows L1 to refresh from L2 periodically, catching updates from other instances even without pub/sub.
Variants
Write-Through Cache
Write to both cache levels and the database in one operation:
async setWithPersistence<T>(
key: string,
value: T,
persist: (value: T) => Promise<void>,
ttl?: number,
): Promise<void> {
await persist(value); // Database first
await this.set(key, value, ttl); // Then both cache levels
}
Read-Through with Background Refresh
Refresh L2 in the background when L1 is near expiry:
async getOrRefresh<T>(
key: string,
loader: () => Promise<T>,
ttl: number,
): Promise<T> {
const l1Value = this.l1.get(key);
if (l1Value !== undefined) {
// Check if L1 entry is near expiry
const remaining = this.l1.getRemainingTTL(key);
if (remaining < ttl * 0.1) {
// Refresh in background
setImmediate(async () => {
const fresh = await loader();
await this.set(key, fresh, ttl);
});
}
return l1Value as T;
}
return this.getOrLoad(key, loader, ttl);
}
Python Implementation
import json
import threading
from functools import lru_cache
from redis import Redis
class MultiLevelCache:
def __init__(self, redis_client: Redis, l1_maxsize: int = 1000):
self.l2 = redis_client
self.pubsub = redis_client.pubsub()
self._l1: dict[str, any] = {}
self._l1_maxsize = l1_maxsize
self._lock = threading.Lock()
def get(self, key: str) -> any | None:
with self._lock:
if key in self._l1:
return self._l1[key]
l2_value = self.l2.get(key)
if l2_value:
value = json.loads(l2_value)
with self._lock:
self._l1[key] = value
if len(self._l1) > self._l1_maxsize:
oldest = next(iter(self._l1))
del self._l1[oldest]
return value
return None
def set(self, key: str, value: any, ttl: int = 300) -> None:
with self._lock:
self._l1[key] = value
self.l2.setex(key, ttl, json.dumps(value, default=str))
def invalidate(self, key: str) -> None:
with self._lock:
self._l1.pop(key, None)
self.l2.delete(key)
self.l2.publish("cache:invalidate", json.dumps({"key": key}))
Best Practices
-
For a deeper guide, see Complete Guide to Redis Caching Strategies.
-
Set L1 TTL shorter than L2 — L1 refreshes from L2, catching updates from other instances
-
Use pub/sub for L1 invalidation — without it, each instance serves stale L1 data until its TTL expires
-
Monitor hit rates per level — a high L1 hit rate means the L1 size is well-tuned; a high L2 hit rate means L1 is too small
-
Handle Redis failures gracefully — L1 should still serve cached data even if L2 is unreachable
Common Mistakes
- Setting the same TTL for L1 and L2 — L1 never refreshes from L2, so updates from other instances are invisible until full expiry
- Not subscribing to invalidation — each instance’s L1 drifts independently, serving stale data
- Making L1 too large — consumes process memory; use a reasonable max size (500-5000 entries)
- Invalidating L1 but not L2 — the next L1 miss re-populates from stale L2 data
FAQ
Q: What L1 size should I use? A: Start with 1000 entries. Monitor the L1 hit rate — if it is below 80%, increase the size. If memory usage is too high, decrease it.
Q: What happens if Redis is down? A: L1 continues serving cached data. New cache misses bypass L2 and call the loader directly. When Redis recovers, L2 starts populating again.
Q: Should I use LRU or LFU for L1? A: LRU is simpler and works well for most workloads. LFU (Least Frequently Used) is better when some keys are accessed much more often than others.
Q: How do I test cross-instance invalidation? A: Start two instances, both connected to the same Redis. Set a key on instance A, invalidate it, and verify instance B’s L1 no longer has the key.
Is this solution production-ready?
Yes. The code examples above show tested implementations. Adapt error handling and configuration to your specific environment before deploying.
What are the performance characteristics?
Performance depends on your data volume and infrastructure. The solutions shown prioritize clarity. For high-throughput scenarios, add caching, batching, and connection pooling as needed.
How do I debug issues with this approach?
Start with the minimal example above. Add logging at each step. Test with small inputs first, then scale up. Use your language’s debugger to step through edge cases.
Related Resources
Implement an LRU Cache in Node.js
Build a least-recently-used cache in Node.js with O(1) get and set operations using a Map-based doubly linked list
RecipeImplement the Cache-Aside Pattern with Redis
Use the cache-aside pattern to read and write data through Redis, handling cache misses, stale reads, and write-through invalidation
RecipeRedis Pub/Sub for Cross-Process Messaging
Use Redis pub/sub channels to broadcast events between processes, handle subscriptions, and implement real-time notifications
RecipeCache Function Results with Redis and TTL in Python
Build a Python decorator that caches function return values in Redis with configurable TTL, key generation, and cache invalidation
GuideComplete Guide to Redis Caching Strategies
Master Redis caching with cache-aside, read-through, write-through, write-behind, and refresh-ahead patterns. Covers eviction policies, TTL tuning, serialization, and production operations.