Back to Journal
System Design

Complete Guide to Distributed Caching with Typescript

A comprehensive guide to implementing Distributed Caching using Typescript, covering architecture, code examples, and production-ready patterns.

Muneer Puthiya Purayil 15 min read

TypeScript's type system and async/await model make it a natural fit for building distributed caching layers. Whether you're working with a NestJS backend, an Express API, or a Next.js server, adding Redis-backed caching can cut your response times from 200ms to under 5ms for cached data. The challenge is doing it correctly—handling serialization, preventing stampedes, and maintaining type safety across your caching layer.

This guide walks through building a production-grade distributed caching system in TypeScript, from basic Redis operations to multi-level caching with automatic invalidation. Every pattern here has been tested in applications serving 50K+ requests per minute.

Setting Up Redis with TypeScript

Install the required packages:

bash
npm install ioredis zod

Use ioredis over redis (node-redis)—it has better TypeScript support, built-in cluster handling, and Lua scripting support.

Connection Configuration

typescript
1import Redis from 'ioredis';
2 
3interface RedisConfig {
4 host: string;
5 port: number;
6 password: string;
7 db: number;
8 maxRetriesPerRequest: number;
9 retryDelayMs: number;
10 keyPrefix: string;
11}
12 
13function createRedisClient(config: RedisConfig): Redis {
14 const client = new Redis({
15 host: config.host,
16 port: config.port,
17 password: config.password,
18 db: config.db,
19 maxRetriesPerRequest: config.maxRetriesPerRequest,
20 keyPrefix: config.keyPrefix,
21 retryStrategy(times: number): number {
22 return Math.min(times * config.retryDelayMs, 5000);
23 },
24 enableReadyCheck: true,
25 lazyConnect: true,
26 });
27 
28 client.on('error', (err) => {
29 console.error('[Redis] Connection error:', err.message);
30 });
31 
32 client.on('ready', () => {
33 console.log('[Redis] Connected and ready');
34 });
35 
36 return client;
37}
38 
39const redis = createRedisClient({
40 host: process.env.REDIS_HOST!,
41 port: parseInt(process.env.REDIS_PORT!, 10),
42 password: process.env.REDIS_PASSWORD!,
43 db: 0,
44 maxRetriesPerRequest: 3,
45 retryDelayMs: 200,
46 keyPrefix: 'app:',
47});
48 

Type-Safe Cache Layer

The core abstraction. This cache service enforces type safety using Zod schemas for deserialization:

typescript
1import { z, ZodType } from 'zod';
2 
3interface CacheOptions {
4 ttlSeconds: number;
5 staleWhileRevalidateSeconds?: number;
6}
7 
8class CacheService {
9 constructor(private readonly redis: Redis) {}
10 
11 async get<T>(key: string, schema: ZodType<T>): Promise<T | null> {
12 const raw = await this.redis.get(key);
13 if (!raw) return null;
14 
15 const parsed = JSON.parse(raw);
16 const result = schema.safeParse(parsed);
17 
18 if (!result.success) {
19 console.warn(`[Cache] Invalid data for key ${key}:`, result.error.message);
20 await this.redis.del(key);
21 return null;
22 }
23 
24 return result.data;
25 }
26 
27 async set<T>(key: string, value: T, options: CacheOptions): Promise<void> {
28 const serialized = JSON.stringify(value);
29 await this.redis.setex(key, options.ttlSeconds, serialized);
30 }
31 
32 async delete(key: string): Promise<void> {
33 await this.redis.del(key);
34 }
35 
36 async deletePattern(pattern: string): Promise<number> {
37 let cursor = '0';
38 let deleted = 0;
39 
40 do {
41 const [nextCursor, keys] = await this.redis.scan(
42 cursor, 'MATCH', pattern, 'COUNT', 100
43 );
44 cursor = nextCursor;
45 
46 if (keys.length > 0) {
47 // Strip prefix since ioredis adds it back
48 const unprefixed = keys.map(k => k.replace(/^app:/, ''));
49 deleted += await this.redis.del(...unprefixed);
50 }
51 } while (cursor !== '0');
52 
53 return deleted;
54 }
55}
56 

Using Zod schemas for deserialization catches corrupted cache data before it reaches your application logic. If the schema validation fails, the invalid entry is automatically evicted.

Cache-Aside Pattern with Type Safety

The workhorse pattern. Check cache, miss falls through to the source, result gets cached:

typescript
1class CacheService {
2 // ... previous methods
3 
4 async getOrSet<T>(
5 key: string,
6 schema: ZodType<T>,
7 fetcher: () => Promise<T>,
8 options: CacheOptions,
9 ): Promise<T> {
10 // Try cache
11 const cached = await this.get(key, schema);
12 if (cached !== null) return cached;
13 
14 // Fetch from source
15 const value = await fetcher();
16 
17 // Write to cache (fire-and-forget, don't block response)
18 this.set(key, value, options).catch((err) => {
19 console.error(`[Cache] Failed to set key ${key}:`, err.message);
20 });
21 
22 return value;
23 }
24}
25 

Usage with a database query:

typescript
1const UserSchema = z.object({
2 id: z.number(),
3 email: z.string().email(),
4 name: z.string(),
5 role: z.enum(['admin', 'user', 'viewer']),
6 createdAt: z.string().datetime(),
7});
8 
9type User = z.infer<typeof UserSchema>;
10 
11async function getUser(userId: number): Promise<User> {
12 return cache.getOrSet(
13 `user:${userId}`,
14 UserSchema,
15 async () => {
16 const row = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
17 return row[0];
18 },
19 { ttlSeconds: 300 },
20 );
21}
22 

Stampede Protection

When a popular cache key expires and 500 requests arrive simultaneously, all 500 hit the database. Implement a distributed lock to ensure only one request fetches the data:

typescript
1class CacheService {
2 async getOrSetLocked<T>(
3 key: string,
4 schema: ZodType<T>,
5 fetcher: () => Promise<T>,
6 options: CacheOptions & { lockTimeoutMs?: number },
7 ): Promise<T> {
8 const cached = await this.get(key, schema);
9 if (cached !== null) return cached;
10 
11 const lockKey = `lock:${key}`;
12 const lockValue = crypto.randomUUID();
13 const lockTimeout = options.lockTimeoutMs ?? 5000;
14 
15 // Try to acquire lock
16 const acquired = await this.redis.set(
17 lockKey, lockValue, 'PX', lockTimeout, 'NX'
18 );
19 
20 if (acquired === 'OK') {
21 try {
22 const value = await fetcher();
23 await this.set(key, value, options);
24 return value;
25 } finally {
26 // Release lock only if we still own it
27 await this.releaseLock(lockKey, lockValue);
28 }
29 }
30 
31 // Lock held by another request — poll cache until data appears
32 return this.waitForCache(key, schema, lockTimeout);
33 }
34 
35 private async releaseLock(lockKey: string, lockValue: string): Promise<void> {
36 const script = `
37 if redis.call("get", KEYS[1]) == ARGV[1] then
38 return redis.call("del", KEYS[1])
39 else
40 return 0
41 end
42 `;
43 await this.redis.eval(script, 1, lockKey, lockValue);
44 }
45 
46 private async waitForCache<T>(
47 key: string,
48 schema: ZodType<T>,
49 timeoutMs: number,
50 ): Promise<T> {
51 const start = Date.now();
52 const pollInterval = 50;
53 
54 while (Date.now() - start < timeoutMs) {
55 const cached = await this.get(key, schema);
56 if (cached !== null) return cached;
57 await new Promise((r) => setTimeout(r, pollInterval));
58 }
59 
60 throw new Error(`Cache lock timeout waiting for key: ${key}`);
61 }
62}
63 

The Lua script for lock release is essential—without it, a slow request could release a lock that was already acquired by another process after timeout.

Need a second opinion on your system design architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Stale-While-Revalidate Pattern

Serve stale data immediately while refreshing in the background. This gives users instant responses even when cache entries are being refreshed:

typescript
1interface CacheEntry<T> {
2 data: T;
3 cachedAt: number;
4}
5 
6class CacheService {
7 async getOrSetSWR<T>(
8 key: string,
9 schema: ZodType<T>,
10 fetcher: () => Promise<T>,
11 options: CacheOptions & { staleWhileRevalidateSeconds: number },
12 ): Promise<T> {
13 const entrySchema = z.object({
14 data: schema,
15 cachedAt: z.number(),
16 });
17 
18 const entry = await this.get(key, entrySchema);
19 
20 if (entry) {
21 const age = (Date.now() - entry.cachedAt) / 1000;
22 const isStale = age > options.ttlSeconds;
23 const isExpired = age > options.ttlSeconds + options.staleWhileRevalidateSeconds;
24 
25 if (!isStale) return entry.data;
26 
27 if (!isExpired) {
28 // Serve stale, revalidate in background
29 this.revalidate(key, fetcher, options).catch(console.error);
30 return entry.data;
31 }
32 }
33 
34 // No cache or fully expired — fetch synchronously
35 const value = await fetcher();
36 await this.setWithTimestamp(key, value, options);
37 return value;
38 }
39 
40 private async revalidate<T>(
41 key: string,
42 fetcher: () => Promise<T>,
43 options: CacheOptions,
44 ): Promise<void> {
45 const value = await fetcher();
46 await this.setWithTimestamp(key, value, options);
47 }
48 
49 private async setWithTimestamp<T>(
50 key: string,
51 value: T,
52 options: CacheOptions,
53 ): Promise<void> {
54 const entry: CacheEntry<T> = {
55 data: value,
56 cachedAt: Date.now(),
57 };
58 const totalTtl = options.ttlSeconds + (options.staleWhileRevalidateSeconds ?? 0);
59 await this.redis.setex(key, totalTtl, JSON.stringify(entry));
60 }
61}
62 

With ttlSeconds: 60 and staleWhileRevalidateSeconds: 300, users always get sub-5ms responses for the first 60 seconds, then slightly stale data for the next 5 minutes while fresh data loads in the background.

Cache Key Strategies

Consistent key naming prevents collisions and makes debugging easier:

typescript
1class CacheKeyBuilder {
2 private parts: string[] = [];
3 
4 constructor(private readonly entity: string) {
5 this.parts.push(entity);
6 }
7 
8 id(value: string | number): this {
9 this.parts.push(String(value));
10 return this;
11 }
12 
13 field(name: string): this {
14 this.parts.push(name);
15 return this;
16 }
17 
18 query(params: Record<string, string | number | boolean>): this {
19 const sorted = Object.entries(params)
20 .sort(([a], [b]) => a.localeCompare(b))
21 .map(([k, v]) => `${k}=${v}`)
22 .join('&');
23 this.parts.push(sorted);
24 return this;
25 }
26 
27 build(): string {
28 return this.parts.join(':');
29 }
30}
31 
32// Usage
33const key = new CacheKeyBuilder('product')
34 .id(42)
35 .field('details')
36 .query({ currency: 'USD', locale: 'en' })
37 .build();
38// Result: "product:42:details:currency=USD&locale=en"
39 

Sorting query parameters ensures the same logical request always produces the same cache key, regardless of parameter order.

Cache Middleware for Express/Fastify

Apply caching declaratively at the route level:

typescript
1import { Request, Response, NextFunction } from 'express';
2 
3interface CacheMiddlewareOptions {
4 ttlSeconds: number;
5 keyGenerator?: (req: Request) => string;
6 condition?: (req: Request) => boolean;
7}
8 
9function cacheMiddleware(
10 cache: CacheService,
11 options: CacheMiddlewareOptions,
12) {
13 return async (req: Request, res: Response, next: NextFunction) => {
14 if (req.method !== 'GET') return next();
15 if (options.condition && !options.condition(req)) return next();
16 
17 const key = options.keyGenerator
18 ? options.keyGenerator(req)
19 : `route:${req.originalUrl}`;
20 
21 const RawSchema = z.object({
22 statusCode: z.number(),
23 body: z.string(),
24 headers: z.record(z.string()),
25 });
26 
27 const cached = await cache.get(key, RawSchema);
28 
29 if (cached) {
30 res.set(cached.headers);
31 res.set('X-Cache', 'HIT');
32 return res.status(cached.statusCode).send(cached.body);
33 }
34 
35 // Capture the response
36 const originalJson = res.json.bind(res);
37 res.json = function (body: unknown) {
38 const serialized = JSON.stringify(body);
39 cache.set(key, {
40 statusCode: res.statusCode,
41 body: serialized,
42 headers: { 'content-type': 'application/json' },
43 }, { ttlSeconds: options.ttlSeconds }).catch(console.error);
44 
45 res.set('X-Cache', 'MISS');
46 return originalJson(body);
47 };
48 
49 next();
50 };
51}
52 
53// Usage
54app.get('/api/products',
55 cacheMiddleware(cache, { ttlSeconds: 60 }),
56 productController.list,
57);
58 

Monitoring and Health Checks

Track cache performance to catch degradation early:

typescript
1class CacheMetrics {
2 private hits = 0;
3 private misses = 0;
4 private errors = 0;
5 private totalLatencyMs = 0;
6 private operationCount = 0;
7 
8 recordHit(latencyMs: number): void {
9 this.hits++;
10 this.totalLatencyMs += latencyMs;
11 this.operationCount++;
12 }
13 
14 recordMiss(latencyMs: number): void {
15 this.misses++;
16 this.totalLatencyMs += latencyMs;
17 this.operationCount++;
18 }
19 
20 recordError(): void {
21 this.errors++;
22 }
23 
24 getStats() {
25 const total = this.hits + this.misses;
26 return {
27 hitRate: total > 0 ? ((this.hits / total) * 100).toFixed(1) + '%' : 'N/A',
28 avgLatencyMs: this.operationCount > 0
29 ? (this.totalLatencyMs / this.operationCount).toFixed(2)
30 : 'N/A',
31 totalHits: this.hits,
32 totalMisses: this.misses,
33 totalErrors: this.errors,
34 };
35 }
36 
37 reset(): void {
38 this.hits = this.misses = this.errors = this.totalLatencyMs = this.operationCount = 0;
39 }
40}
41 

Wire metrics into your instrumented cache:

typescript
1class InstrumentedCache extends CacheService {
2 private metrics = new CacheMetrics();
3 
4 override async get<T>(key: string, schema: ZodType<T>): Promise<T | null> {
5 const start = performance.now();
6 try {
7 const result = await super.get(key, schema);
8 const latency = performance.now() - start;
9 result !== null ? this.metrics.recordHit(latency) : this.metrics.recordMiss(latency);
10 return result;
11 } catch (err) {
12 this.metrics.recordError();
13 throw err;
14 }
15 }
16 
17 getMetrics() {
18 return this.metrics.getStats();
19 }
20}
21 

Conclusion

Building distributed caching in TypeScript is about combining Redis's speed with TypeScript's type safety. The patterns covered here—cache-aside, stampede protection, stale-while-revalidate, and middleware caching—cover 90% of real-world caching needs.

Start with the basic CacheService and getOrSet pattern. Add Zod schema validation from day one to catch serialization bugs early. Introduce stampede protection only for keys with high concurrent access. Use stale-while-revalidate for data where freshness is less critical than response time.

Monitor your hit rate continuously. A healthy cache maintains 85-95% hit rate. If it drops below 80%, investigate your TTL strategy, key design, and invalidation patterns. The goal is making your cache invisible to users—fast responses with data that's fresh enough for your use case.

FAQ

Need expert help?

Building with system design?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026