NestJS course Β· Module 8: Caching and Performance
PROJECT - a performance optimisation system
In this lesson6
This module showed several tools: a cache in the process's memory and in Redis, database indexes, response compression, limiting the number of requests. The project is where you must decide which one to use - and that is a quite different skill from knowing the syntax.
You will build a legion management API that copes with more traffic than a naive version would bear, and can show why it copes.
One rule before all the others
Optimisation without measurement is guesswork. It sounds obvious, and it is the most frequently broken rule in the field: a cache is added wherever it happened to come to mind, and an improvement nobody measured is duly recorded.
The order of work is always the same:
- Measure - how long a request takes, how much memory it uses, how many queries go to the database.
- Find the bottleneck - one, the most costly.
- Apply the cheapest fix that removes it.
- Measure again - and check that the fix worked rather than merely moving the problem.
That is why the project begins with measurement, not with a cache.
Step 1 - the metrics endpoint
The first thing you expose is a view of the process's state:
1@Controller('metrics')
2export class MetricsController {
3 @Get()
4 getMetrics() {
5 const memory = process.memoryUsage();
6
7 return {
8 heapUsed: memory.heapUsed,
9 heapTotal: memory.heapTotal,
10 rss: memory.rss,
11 uptime: process.uptime(),
12 };
13 }
14}process.memoryUsage() returns several numbers, and it is worth knowing how they differ. heapUsed is the memory actually occupied by your application's objects - it grows when you keep too much in a cache. heapTotal is the memory the V8 engine has reserved for the heap; it is always larger and changes in jumps. rss (Resident Set Size) is the whole process's memory as the operating system sees it - heap, stack, code and buffers together.
The difference between heapUsed and rss matters most. When heapUsed alone grows, you are accumulating objects - usually in some cache without a limit. When rss grows while heapUsed stands still, the leak is outside the heap: buffers, open connections, native libraries.
process.uptime() gives the number of seconds the process has been running. It says little by itself, but a great deal alongside the rest: memory use rising linearly with running time is a leak, not load.
Step 2 - a cache in Redis
The in-process cache you met earlier has one flaw: every instance of the application has its own. With two instances the same read reaches the database twice, and an invalidation in one never reaches the other. Redis fixes that:
1CacheModule.register({
2 store: redisStore,
3 host: 'localhost',
4 port: 6379,
5 ttl: 300 })The written order is fixed: CacheModule.register({ opens the configuration, store: redisStore, swaps the store from process memory to Redis, host: 'localhost', and port: 6379, say where that Redis stands, and ttl: 300 }) sets the default lifetime of an entry and closes.
store is the crucial line. Without it CacheModule still works, but in process memory - and that is treacherous, because locally everything looks fine and the problem appears only once a second instance starts in production.
Note that host and port sit directly in the configuration object here. Newer versions of cache-manager nest them in a separate connection field - check the version in your package.json before copying somebody else's example.
Step 3 - the database
A cache speeds up repeated reads. Queries that cannot be cached must be fixed at the source:
1@Entity('legionaries')
2@Index(['cohortId', 'isActive'])
3export class Legionary {
4 @Column()
5 cohortId: number;
6
7 @Column({ default: true })
8 isActive: boolean;
9}
10
11// in the repository
12findActiveByCohort(cohortId: number) {
13 return this.repo.find({
14 where: { cohortId, isActive: true },
15 select: ['id', 'name', 'rank'],
16 take: 50,
17 });
18}Three fixes at once, each acting differently. A composite index on the columns you genuinely filter by turns a scan of the whole table into a jump to particular rows. select fetches only the needed columns - without it you drag fields nobody will use out of the database, bulk and all. take limits the number of rows; an endpoint without a limit works splendidly until the day somebody has a hundred thousand of them.
Before adding an index, check the query plan with EXPLAIN. An index on a column nobody filters by speeds up no read and slows down every write.
Step 4 - the outward layer
Two things at the application's edge remain:
1async function bootstrap() {
2 const app = await NestFactory.create(AppModule);
3
4 app.use(compression());
5
6 await app.listen(3000);
7}compression() packs responses with gzip - for JSON lists that usually means several times less traffic, at the cost of a little processor time. Rate limiting with ThrottlerModule works from the other side: it speeds nothing up, it limits the number of requests you accept at all.
These two are worth understanding as a pair of opposites. Compression and caching make one request cost less. Rate limiting makes the requests fewer. When a system is choking you always have those two roads open - and usually you need both.
What you hand in
The project is finished when it contains:
- A
/metricsendpoint withheapUsed,heapTotal,rssanduptime. - A cache in Redis through
CacheModule.registerwithstore: redisStore, invalidated on write. - Indexes on the filtered columns, plus
selectandtakein listing queries. - Response compression and rate limiting on the write endpoints.
- A before-and-after measurement - figures for at least one endpoint, with a note on what changed.
The fifth point matters most and is most often skipped. Without it you are handing in a collection of techniques rather than an optimisation - because an optimisation is the difference between two measurements not a list of tools applied.
Send the link to your repository when you are done.
Code for this lesson: src/performance-project.ts
1// PROJECT: Legion Performance Optimization System
2import { Injectable, Inject, Module, Logger } from '@nestjs/common';
3import { CACHE_MANAGER } from '@nestjs/cache-manager';
4import { Cache } from 'cache-manager';
5
6// === COMPLETE PERFORMANCE SYSTEM ===
7
8// 1. Cache Service with metrics
9@Injectable()
10export class LegionCacheService {
11 private hits = 0;
12 private misses = 0;
13
14 constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}
15
16 async getOrFetch<T>(
17 key: string,
18 fetcher: () => Promise<T>,
19 ttl = 300
20 ): Promise<T> {
21 const cached = await this.cache.get<T>(key);
22 if (cached) {
23 this.hits++;
24 return cached;
25 }
26 this.misses++;
27 const data = await fetcher();
28 await this.cache.set(key, data, ttl * 1000);
29 return data;
30 }
31
32 getCacheStats() {
33 const total = this.hits + this.misses;
34 return {
35 hits: this.hits,
36 misses: this.misses,
37 hitRate: total > 0
38 ? `${((this.hits / total) * 100).toFixed(1)}%`
39 : 'N/A',
40 };
41 }
42}
43
44// 2. Performance Monitor
45@Injectable()
46export class PerformanceMonitor {
47 private readonly logger = new Logger('PerformanceMonitor');
48 private requestTimes: number[] = [];
49
50 recordRequest(durationMs: number) {
51 this.requestTimes.push(durationMs);
52 if (this.requestTimes.length > 1000) {
53 this.requestTimes = this.requestTimes.slice(-1000);
54 }
55 }
56
57 getMetrics() {
58 if (this.requestTimes.length === 0) {
59 return { avg: 0, p95: 0, p99: 0, max: 0 };
60 }
61
62 const sorted = [...this.requestTimes].sort((a, b) => a - b);
63 const len = sorted.length;
64
65 return {
66 avg: Math.round(
67 sorted.reduce((a, b) => a + b, 0) / len
68 ),
69 p95: sorted[Math.floor(len * 0.95)],
70 p99: sorted[Math.floor(len * 0.99)],
71 max: sorted[len - 1],
72 totalRequests: len,
73 };
74 }
75
76 getSystemMetrics() {
77 const mem = process.memoryUsage();
78 return {
79 uptime: Math.round(process.uptime()),
80 memory: {
81 heapUsed: Math.round(mem.heapUsed / 1024 / 1024),
82 heapTotal: Math.round(mem.heapTotal / 1024 / 1024),
83 rss: Math.round(mem.rss / 1024 / 1024),
84 },
85 cpu: process.cpuUsage(),
86 pid: process.pid,
87 };
88 }
89}
90
91// 3. Health Check aggregating all metrics
92@Injectable()
93export class LegionHealthService {
94 constructor(
95 private cacheService: LegionCacheService,
96 private perfMonitor: PerformanceMonitor,
97 ) {}
98
99 getHealthReport() {
100 const perf = this.perfMonitor.getMetrics();
101 const system = this.perfMonitor.getSystemMetrics();
102 const cache = this.cacheService.getCacheStats();
103
104 return {
105 status: perf.p95 < 500 ? 'healthy' : 'degraded',
106 performance: perf,
107 system,
108 cache,
109 timestamp: new Date().toISOString(),
110 };
111 }
112}
113Spotted a mistake in this lesson?
Hands-on tasks in the game
- Code editor
Create a /metrics endpoint returning heapUsed, heapTotal, rss, and uptime
- Vertical ordering
Arrange the correct CacheModule configuration with Redis store: