Back to Journal
System Design

How to Build Distributed Caching Using Nestjs

Step-by-step tutorial for building Distributed Caching with Nestjs, from project setup through deployment.

Muneer Puthiya Purayil 19 min read

NestJS provides first-class support for caching through its modular architecture. In this tutorial, you'll build a production-ready distributed caching system using NestJS and Redis, leveraging decorators, interceptors, and dependency injection to create a clean, maintainable caching layer. By the end, you'll have a system that handles 25K+ requests per second with automatic cache invalidation and stampede protection.

Prerequisites

  • Node.js 20+
  • Redis 7+ running locally
  • Basic familiarity with NestJS modules, providers, and decorators

Project Setup

bash
1nest new nestjs-caching
2cd nestjs-caching
3npm install @nestjs/cache-manager cache-manager cache-manager-ioredis-yet ioredis
4npm install -D @types/cache-manager
5 

Project Structure

1src/
2├── app.module.ts
3├── cache/
4│ ├── cache.module.ts
5│ ├── cache.service.ts
6│ ├── cache-lock.service.ts
7│ ├── cache.interceptor.ts
8│ ├── cache.decorator.ts
9│ └── cache.constants.ts
10├── products/
11│ ├── products.module.ts
12│ ├── products.controller.ts
13│ ├── products.service.ts
14│ └── dto/
15│ └── product.dto.ts
16└── health/
17 ├── health.module.ts
18 └── health.controller.ts
19 

Step 1: Cache Module Configuration

Create a dedicated cache module that wraps NestJS's built-in caching with Redis:

typescript
1// src/cache/cache.constants.ts
2export const CACHE_TTL = {
3 SHORT: 30, // 30 seconds — volatile data
4 MEDIUM: 300, // 5 minutes — standard cache
5 LONG: 3600, // 1 hour — rarely changing data
6 DAY: 86400, // 24 hours — static data
7} as const;
8 
9export const CACHE_PREFIX = 'nestcache';
10 
typescript
1// src/cache/cache.module.ts
2import { Module, Global } from '@nestjs/common';
3import { CacheModule as NestCacheModule } from '@nestjs/cache-manager';
4import { redisStore } from 'cache-manager-ioredis-yet';
5import { CacheService } from './cache.service';
6import { CacheLockService } from './cache-lock.service';
7import { CACHE_PREFIX } from './cache.constants';
8 
9@Global()
10@Module({
11 imports: [
12 NestCacheModule.registerAsync({
13 useFactory: () => ({
14 store: redisStore,
15 host: process.env.REDIS_HOST,
16 port: parseInt(process.env.REDIS_PORT!, 10),
17 password: process.env.REDIS_PASSWORD,
18 ttl: 300,
19 max: 1000,
20 keyPrefix: `${CACHE_PREFIX}:`,
21 }),
22 }),
23 ],
24 providers: [CacheService, CacheLockService],
25 exports: [CacheService, CacheLockService, NestCacheModule],
26})
27export class CacheConfigModule {}
28 

Step 2: Cache Service

Build the core service with type-safe operations:

typescript
1// src/cache/cache.service.ts
2import { Inject, Injectable, Logger } from '@nestjs/common';
3import { CACHE_MANAGER } from '@nestjs/cache-manager';
4import { Cache } from 'cache-manager';
5import Redis from 'ioredis';
6import { CACHE_PREFIX, CACHE_TTL } from './cache.constants';
7 
8@Injectable()
9export class CacheService {
10 private readonly logger = new Logger(CacheService.name);
11 private readonly redis: Redis;
12 
13 constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: Cache) {
14 // Access the underlying ioredis client for advanced operations
15 this.redis = (this.cacheManager.store as any).client;
16 }
17 
18 async get<T>(key: string): Promise<T | null> {
19 try {
20 const value = await this.cacheManager.get<T>(key);
21 return value ?? null;
22 } catch (error) {
23 this.logger.warn(`Cache get failed for ${key}: ${error.message}`);
24 return null;
25 }
26 }
27 
28 async set<T>(key: string, value: T, ttl: number = CACHE_TTL.MEDIUM): Promise<void> {
29 try {
30 await this.cacheManager.set(key, value, ttl * 1000);
31 } catch (error) {
32 this.logger.warn(`Cache set failed for ${key}: ${error.message}`);
33 }
34 }
35 
36 async delete(key: string): Promise<void> {
37 try {
38 await this.cacheManager.del(key);
39 } catch (error) {
40 this.logger.warn(`Cache delete failed for ${key}: ${error.message}`);
41 }
42 }
43 
44 async deletePattern(pattern: string): Promise<number> {
45 let deleted = 0;
46 const fullPattern = `${CACHE_PREFIX}:${pattern}`;
47 const stream = this.redis.scanStream({
48 match: fullPattern,
49 count: 100,
50 });
51 
52 return new Promise((resolve, reject) => {
53 stream.on('data', async (keys: string[]) => {
54 if (keys.length > 0) {
55 stream.pause();
56 const pipeline = this.redis.pipeline();
57 keys.forEach((key) => pipeline.del(key));
58 await pipeline.exec();
59 deleted += keys.length;
60 stream.resume();
61 }
62 });
63 stream.on('end', () => resolve(deleted));
64 stream.on('error', reject);
65 });
66 }
67 
68 async getOrSet<T>(
69 key: string,
70 fetcher: () => Promise<T>,
71 ttl: number = CACHE_TTL.MEDIUM,
72 ): Promise<T> {
73 const cached = await this.get<T>(key);
74 if (cached !== null) return cached;
75 
76 const value = await fetcher();
77 await this.set(key, value, ttl);
78 return value;
79 }
80 
81 async mget<T>(keys: string[]): Promise<Map<string, T | null>> {
82 const pipeline = this.redis.pipeline();
83 keys.forEach((key) => pipeline.get(`${CACHE_PREFIX}:${key}`));
84 
85 const results = await pipeline.exec();
86 const map = new Map<string, T | null>();
87 
88 keys.forEach((key, index) => {
89 const [err, val] = results![index];
90 if (err || !val) {
91 map.set(key, null);
92 } else {
93 try {
94 map.set(key, JSON.parse(val as string));
95 } catch {
96 map.set(key, null);
97 }
98 }
99 });
100 
101 return map;
102 }
103}
104 

Step 3: Distributed Lock Service

Prevent cache stampedes with Redis-based locking:

typescript
1// src/cache/cache-lock.service.ts
2import { Inject, Injectable, Logger } from '@nestjs/common';
3import { CACHE_MANAGER } from '@nestjs/cache-manager';
4import { Cache } from 'cache-manager';
5import Redis from 'ioredis';
6import { randomUUID } from 'crypto';
7 
8@Injectable()
9export class CacheLockService {
10 private readonly logger = new Logger(CacheLockService.name);
11 private readonly redis: Redis;
12 
13 private readonly RELEASE_SCRIPT = `
14 if redis.call("get", KEYS[1]) == ARGV[1] then
15 return redis.call("del", KEYS[1])
16 else
17 return 0
18 end
19 `;
20 
21 constructor(@Inject(CACHE_MANAGER) cacheManager: Cache) {
22 this.redis = (cacheManager.store as any).client;
23 }
24 
25 async acquireLock(
26 key: string,
27 ttlMs: number = 5000,
28 ): Promise<{ acquired: boolean; lockValue: string }> {
29 const lockValue = randomUUID();
30 const result = await this.redis.set(
31 `lock:${key}`, lockValue, 'PX', ttlMs, 'NX',
32 );
33 
34 return { acquired: result === 'OK', lockValue };
35 }
36 
37 async releaseLock(key: string, lockValue: string): Promise<void> {
38 await this.redis.eval(
39 this.RELEASE_SCRIPT, 1, `lock:${key}`, lockValue,
40 );
41 }
42 
43 async withLock<T>(
44 key: string,
45 fn: () => Promise<T>,
46 options: { ttlMs?: number; waitMs?: number; retryIntervalMs?: number } = {},
47 ): Promise<T> {
48 const { ttlMs = 5000, waitMs = 5000, retryIntervalMs = 50 } = options;
49 const start = Date.now();
50 
51 while (Date.now() - start < waitMs) {
52 const { acquired, lockValue } = await this.acquireLock(key, ttlMs);
53 
54 if (acquired) {
55 try {
56 return await fn();
57 } finally {
58 await this.releaseLock(key, lockValue);
59 }
60 }
61 
62 await new Promise((r) => setTimeout(r, retryIntervalMs));
63 }
64 
65 throw new Error(`Lock acquisition timeout for key: ${key}`);
66 }
67}
68 

Combine the lock with caching:

typescript
1// Add to CacheService
2async getOrSetLocked<T>(
3 key: string,
4 fetcher: () => Promise<T>,
5 ttl: number = CACHE_TTL.MEDIUM,
6): Promise<T> {
7 const cached = await this.get<T>(key);
8 if (cached !== null) return cached;
9 
10 return this.lockService.withLock(`cache:${key}`, async () => {
11 // Double-check after acquiring lock
12 const rechecked = await this.get<T>(key);
13 if (rechecked !== null) return rechecked;
14 
15 const value = await fetcher();
16 await this.set(key, value, ttl);
17 return value;
18 });
19}
20 

Step 4: Custom Cache Interceptor

Create a reusable interceptor for automatic HTTP response caching:

typescript
1// src/cache/cache.interceptor.ts
2import {
3 CallHandler,
4 ExecutionContext,
5 Injectable,
6 NestInterceptor,
7} from '@nestjs/common';
8import { Observable, of, tap } from 'rxjs';
9import { Request, Response } from 'express';
10import { CacheService } from './cache.service';
11import { Reflector } from '@nestjs/core';
12import { CACHE_KEY_METADATA, CACHE_TTL_METADATA } from './cache.decorator';
13import { CACHE_TTL } from './cache.constants';
14 
15@Injectable()
16export class HttpCacheInterceptor implements NestInterceptor {
17 constructor(
18 private readonly cacheService: CacheService,
19 private readonly reflector: Reflector,
20 ) {}
21 
22 async intercept(
23 context: ExecutionContext,
24 next: CallHandler,
25 ): Promise<Observable<any>> {
26 const request = context.switchToHttp().getRequest<Request>();
27 const response = context.switchToHttp().getResponse<Response>();
28 
29 if (request.method !== 'GET') return next.handle();
30 
31 const keyPrefix = this.reflector.get<string>(
32 CACHE_KEY_METADATA,
33 context.getHandler(),
34 );
35 if (!keyPrefix) return next.handle();
36 
37 const ttl = this.reflector.get<number>(
38 CACHE_TTL_METADATA,
39 context.getHandler(),
40 ) ?? CACHE_TTL.MEDIUM;
41 
42 const cacheKey = `http:${keyPrefix}:${request.originalUrl}`;
43 const cached = await this.cacheService.get<any>(cacheKey);
44 
45 if (cached) {
46 response.setHeader('X-Cache', 'HIT');
47 return of(cached);
48 }
49 
50 response.setHeader('X-Cache', 'MISS');
51 return next.handle().pipe(
52 tap(async (data) => {
53 await this.cacheService.set(cacheKey, data, ttl);
54 }),
55 );
56 }
57}
58 

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

Step 5: Custom Decorators

Create clean decorator APIs for cache configuration:

typescript
1// src/cache/cache.decorator.ts
2import { SetMetadata, applyDecorators } from '@nestjs/common';
3 
4export const CACHE_KEY_METADATA = 'cache:key';
5export const CACHE_TTL_METADATA = 'cache:ttl';
6 
7export function Cached(keyPrefix: string, ttlSeconds?: number) {
8 const decorators = [SetMetadata(CACHE_KEY_METADATA, keyPrefix)];
9 if (ttlSeconds !== undefined) {
10 decorators.push(SetMetadata(CACHE_TTL_METADATA, ttlSeconds));
11 }
12 return applyDecorators(...decorators);
13}
14 
15// Usage in controllers:
16// @Cached('products', 60)
17// @Get()
18// findAll() { ... }
19 

Step 6: Product Module Implementation

Build a complete CRUD module with integrated caching:

typescript
1// src/products/dto/product.dto.ts
2export class ProductDto {
3 id: number;
4 name: string;
5 price: number;
6 category: string;
7 stock: number;
8}
9 
10export class ProductListDto {
11 items: ProductDto[];
12 total: number;
13 page: number;
14 perPage: number;
15}
16 
typescript
1// src/products/products.service.ts
2import { Injectable, NotFoundException } from '@nestjs/common';
3import { CacheService } from '../cache/cache.service';
4import { CACHE_TTL } from '../cache/cache.constants';
5import { ProductDto, ProductListDto } from './dto/product.dto';
6 
7@Injectable()
8export class ProductsService {
9 // Simulated database
10 private readonly products: ProductDto[] = Array.from({ length: 100 }, (_, i) => ({
11 id: i + 1,
12 name: `Product ${i + 1}`,
13 price: Math.round((9.99 + i * 5.5) * 100) / 100,
14 category: ['electronics', 'clothing', 'books'][i % 3],
15 stock: (i + 1) * 10,
16 }));
17 
18 constructor(private readonly cache: CacheService) {}
19 
20 async findAll(
21 category?: string,
22 page: number = 1,
23 perPage: number = 20,
24 ): Promise<ProductListDto> {
25 const cacheKey = `products:list:${category ?? 'all'}:${page}:${perPage}`;
26 
27 return this.cache.getOrSet(
28 cacheKey,
29 async () => {
30 let filtered = this.products;
31 if (category) {
32 filtered = filtered.filter((p) => p.category === category);
33 }
34 const start = (page - 1) * perPage;
35 const items = filtered.slice(start, start + perPage);
36 return { items, total: filtered.length, page, perPage };
37 },
38 CACHE_TTL.SHORT,
39 );
40 }
41 
42 async findOne(id: number): Promise<ProductDto> {
43 const cacheKey = `products:${id}`;
44 
45 return this.cache.getOrSet(
46 cacheKey,
47 async () => {
48 const product = this.products.find((p) => p.id === id);
49 if (!product) throw new NotFoundException(`Product ${id} not found`);
50 return product;
51 },
52 CACHE_TTL.MEDIUM,
53 );
54 }
55 
56 async update(id: number, data: Partial<ProductDto>): Promise<ProductDto> {
57 const index = this.products.findIndex((p) => p.id === id);
58 if (index === -1) throw new NotFoundException(`Product ${id} not found`);
59 
60 this.products[index] = { ...this.products[index], ...data };
61 
62 // Invalidate related cache entries
63 await Promise.all([
64 this.cache.delete(`products:${id}`),
65 this.cache.deletePattern('products:list:*'),
66 ]);
67 
68 return this.products[index];
69 }
70}
71 
typescript
1// src/products/products.controller.ts
2import {
3 Controller,
4 Get,
5 Put,
6 Param,
7 Query,
8 Body,
9 ParseIntPipe,
10 UseInterceptors,
11} from '@nestjs/common';
12import { ProductsService } from './products.service';
13import { HttpCacheInterceptor } from '../cache/cache.interceptor';
14import { Cached } from '../cache/cache.decorator';
15import { ProductDto, ProductListDto } from './dto/product.dto';
16 
17@Controller('api/products')
18@UseInterceptors(HttpCacheInterceptor)
19export class ProductsController {
20 constructor(private readonly productsService: ProductsService) {}
21 
22 @Get()
23 @Cached('products', 60)
24 async findAll(
25 @Query('category') category?: string,
26 @Query('page', new ParseIntPipe({ optional: true })) page?: number,
27 @Query('perPage', new ParseIntPipe({ optional: true })) perPage?: number,
28 ): Promise<ProductListDto> {
29 return this.productsService.findAll(category, page, perPage);
30 }
31 
32 @Get(':id')
33 @Cached('product-detail', 300)
34 async findOne(@Param('id', ParseIntPipe) id: number): Promise<ProductDto> {
35 return this.productsService.findOne(id);
36 }
37 
38 @Put(':id')
39 async update(
40 @Param('id', ParseIntPipe) id: number,
41 @Body() data: Partial<ProductDto>,
42 ): Promise<ProductDto> {
43 return this.productsService.update(id, data);
44 }
45}
46 

Step 7: Wire Everything Together

typescript
1// src/app.module.ts
2import { Module } from '@nestjs/common';
3import { CacheConfigModule } from './cache/cache.module';
4import { ProductsModule } from './products/products.module';
5import { HealthModule } from './health/health.module';
6 
7@Module({
8 imports: [CacheConfigModule, ProductsModule, HealthModule],
9})
10export class AppModule {}
11 

Step 8: Testing

typescript
1// src/cache/cache.service.spec.ts
2import { Test, TestingModule } from '@nestjs/testing';
3import { CACHE_MANAGER } from '@nestjs/cache-manager';
4import { CacheService } from './cache.service';
5 
6describe('CacheService', () => {
7 let service: CacheService;
8 const mockCache = {
9 get: jest.fn(),
10 set: jest.fn(),
11 del: jest.fn(),
12 store: { client: { scanStream: jest.fn(), pipeline: jest.fn() } },
13 };
14 
15 beforeEach(async () => {
16 const module: TestingModule = await Test.createTestingModule({
17 providers: [
18 CacheService,
19 { provide: CACHE_MANAGER, useValue: mockCache },
20 ],
21 }).compile();
22 
23 service = module.get<CacheService>(CacheService);
24 jest.clearAllMocks();
25 });
26 
27 describe('get', () => {
28 it('should return cached value', async () => {
29 mockCache.get.mockResolvedValue({ id: 1, name: 'Test' });
30 const result = await service.get('test-key');
31 expect(result).toEqual({ id: 1, name: 'Test' });
32 });
33 
34 it('should return null on miss', async () => {
35 mockCache.get.mockResolvedValue(undefined);
36 const result = await service.get('missing-key');
37 expect(result).toBeNull();
38 });
39 
40 it('should return null and log on error', async () => {
41 mockCache.get.mockRejectedValue(new Error('Connection lost'));
42 const result = await service.get('error-key');
43 expect(result).toBeNull();
44 });
45 });
46 
47 describe('getOrSet', () => {
48 it('should return cached value without calling fetcher', async () => {
49 mockCache.get.mockResolvedValue({ id: 1 });
50 const fetcher = jest.fn();
51 
52 const result = await service.getOrSet('key', fetcher);
53 expect(result).toEqual({ id: 1 });
54 expect(fetcher).not.toHaveBeenCalled();
55 });
56 
57 it('should call fetcher on miss and cache result', async () => {
58 mockCache.get.mockResolvedValue(undefined);
59 mockCache.set.mockResolvedValue(undefined);
60 const fetcher = jest.fn().mockResolvedValue({ id: 2 });
61 
62 const result = await service.getOrSet('key', fetcher, 60);
63 expect(result).toEqual({ id: 2 });
64 expect(fetcher).toHaveBeenCalledTimes(1);
65 expect(mockCache.set).toHaveBeenCalledWith('key', { id: 2 }, 60000);
66 });
67 });
68});
69 

Run tests:

bash
npm run test -- --testPathPattern=cache

Step 9: Production Deployment

Environment Configuration

env
1REDIS_HOST=redis
2REDIS_PORT=6379
3REDIS_PASSWORD=your_secure_password
4 

Docker Setup

dockerfile
1FROM node:20-alpine AS build
2WORKDIR /app
3COPY package*.json ./
4RUN npm ci
5COPY . .
6RUN npm run build
7 
8FROM node:20-alpine
9WORKDIR /app
10COPY --from=build /app/dist ./dist
11COPY --from=build /app/node_modules ./node_modules
12COPY package*.json ./
13CMD ["node", "dist/main"]
14 

Conclusion

You've built a complete distributed caching system with NestJS that integrates naturally with the framework's module system. The key patterns—injectable cache service, custom decorators, HTTP interceptor, and distributed locking—follow NestJS conventions and are easy for other developers on your team to use.

The architecture separates concerns cleanly: the CacheService handles low-level Redis operations, the HttpCacheInterceptor provides automatic response caching, and the @Cached decorator configures caching declaratively. When data changes, the service layer handles invalidation explicitly, ensuring stale data doesn't persist.

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