Profile a NestJS API with clinic.js flame graphs and 0x to identify CPU hot paths, memory leaks, and I/O bottlenecks revealed by k6 load tests.
k6 tells you that your API is slow. clinic.js and 0x tell you exactly why. Performance profiling is the discipline of measuring where CPU time, memory, and I/O are actually spent at the function level — not the request level.
This lesson covers the complete profiling workflow: run a k6 load test to identify which endpoints are slow, profile the Node.js process with clinic.js flame graphs while under load, interpret the flame graph to find the root cause, and apply targeted fixes that move the metric from 'Needs Improvement' to within SLO.
clinic.js is an open-source Node.js diagnostics toolkit with three tools: clinic flame (CPU profiling), clinic doctor (event loop analysis), and clinic bubbles (libuv I/O). Install it globally and run it wrapping your Node.js command.
1# Install globally
2npm install -g clinic
3# or: npm install -g 0x # alternative flamegraph tool
4
5# Profile your NestJS API while running k6 load test
6# Terminal 1: Start API with profiling
7clinic flame -- node dist/main.js
8
9# Terminal 2: Drive load for 60 seconds
10k6 run --vus 20 --duration 60s tests/perf/scenarios/load.js
11
12# After k6 finishes, Ctrl+C the clinic process
13# clinic.js generates an HTML flamegraph report
14clinic flame -- node dist/main.js
15# Opens: file:///tmp/my-app.clinic-flame-4231/flamegraph.htmlFor NestJS with TypeScript, compile first:
1# Compile TypeScript
2pnpm build # or: npx nest build
3
4# Profile compiled output
5clinic flame -- node dist/main.js
6
7# OR profile with ts-node for development
8clinic flame -- npx ts-node -r tsconfig-paths/register src/main.tsclinic doctor is better for event loop issues:
1clinic doctor -- node dist/main.js
2# Generates a doctor report showing:
3# - Event loop delay over time
4# - CPU usage patterns
5# - Memory growth trends
6# - I/O throughputA flame graph is a visualization of CPU stack traces sampled during execution. The X axis represents time (wider = more CPU); the Y axis is the call stack (higher = deeper in the call stack).
How to read a k6/clinic flame graph:
1 ┌─────────────────────────────────────────────┐ ← application code (hot path)
2 │ JSON.parse (wide = expensive) │
3 └─────────────────────────────────────────────┘
4 ┌────────────────┐
5 │ formatResponse │
6 └────────────────┘
7 ┌──────────┐
8 │ serialize│
9 └──────────┘
10 ┌──────────────────────────────────┐
11 │ handleRequest │
12 └──────────────────────────────────┘
13 ┌──────────────────────────────────────────┐
14 │ (anonymous) / router / express.Layer.handle│
15 └──────────────────────────────────────────┘
16 ┌──────────────────────────────────────────────────────┐
17 │ (platform internals — V8, libuv, node http_parser) │
18 └──────────────────────────────────────────────────────┘In this example, JSON.parse is consuming 40% of CPU time — a sign that large JSON payloads are being parsed on every request without caching.
After identifying hot paths in the flame graph, apply targeted fixes:
1. Synchronous I/O in Request Handlers (blocks event loop):
1// BAD: fs.readFileSync blocks the event loop during load
2@Get('/config')
3getConfig() {
4 const data = fs.readFileSync('./config.json', 'utf8'); // BLOCKS
5 return JSON.parse(data);
6}
7
8// GOOD: async + cached
9const configCache = new Map<string, unknown>();
10
11@Get('/config')
12async getConfig() {
13 if (configCache.has('main')) return configCache.get('main');
14 const data = await fs.promises.readFile('./config.json', 'utf8');
15 const parsed = JSON.parse(data);
16 configCache.set('main', parsed);
17 return parsed;
18}2. Sequential await in loops (N+1 async pattern):
1// BAD: 20 awaits sequentially = 20x latency
2async getCourseDetails(ids: string[]) {
3 const results = [];
4 for (const id of ids) {
5 results.push(await this.courseService.findById(id)); // sequential!
6 }
7 return results;
8}
9
10// GOOD: Promise.all runs all in parallel
11async getCourseDetails(ids: string[]) {
12 return Promise.all(ids.map((id) => this.courseService.findById(id)));
13}3. JSON.parse in hot paths (CPU-intensive):
1// BAD: parse Redis JSON on every request
2async getUser(id: string) {
3 const raw = await redis.get(`user:${id}`);
4 return JSON.parse(raw); // expensive if called 1000x/second
5}
6
7// GOOD: use Redis OBJECT ENCODING or cache the parsed object in memory
8const parsedCache = new LRUCache<string, User>({ max: 1000, ttl: 30_000 });
9
10async getUser(id: string) {
11 if (parsedCache.has(id)) return parsedCache.get(id);
12 const raw = await redis.get(`user:${id}`);
13 const parsed = JSON.parse(raw);
14 parsedCache.set(id, parsed);
15 return parsed;
16}Memory leaks in Node.js cause the process to grow over time until OOM-killed. clinic.js doctor shows memory growth trends; heap snapshots identify what is accumulating.
1# Run with --expose-gc to enable manual GC in heap snapshots
2clinic doctor -- node --expose-gc dist/main.js1// NestJS endpoint to capture heap snapshot during load testing
2import { Controller, Get, Res } from '@nestjs/common';
3import { Response } from 'express';
4import v8 from 'v8';
5import fs from 'fs';
6
7@Controller('debug')
8export class DebugController {
9 @Get('heap-snapshot')
10 // IMPORTANT: Only expose in development/staging, never production
11 captureHeap(@Res() res: Response) {
12 const filename = `heap-${Date.now()}.heapsnapshot`;
13 const writeStream = fs.createWriteStream(filename);
14 const session = new v8.Session();
15 session.connect();
16 session.post('HeapProfiler.takeHeapSnapshot', null, (err, chunk) => {
17 if (chunk?.chunk) writeStream.write(chunk.chunk);
18 });
19 session.on('HeapProfiler.addHeapSnapshotChunk', ({ params }) => {
20 writeStream.write(params.chunk);
21 });
22 // Load the .heapsnapshot file in Chrome DevTools Memory tab
23 // Look for objects that grow between two snapshots taken 5 minutes apart
24 res.json({ filename });
25 }
26}Common memory leaks in NestJS:
emitter.removeAllListeners() in onModuleDestroy)setInterval without clearInterval in servicesEvent loop delay is the gap between when a callback is scheduled and when it actually runs. Under load, delays above 10ms indicate the event loop is saturated — usually by CPU-intensive synchronous code.
1# clinic doctor shows event loop delay over time
2clinic doctor -- node dist/main.js
3# After load test: generates report with:
4# - Event loop delay timeline
5# - CPU usage (if >80% for sustained periods = CPU bottleneck)
6# - I/O wait (if high = database or file I/O bottleneck)1// Measure event loop delay programmatically
2import { monitorEventLoopDelay } from 'perf_hooks';
3
4const histogram = monitorEventLoopDelay({ resolution: 20 });
5histogram.enable();
6
7// In your NestJS health check endpoint:
8@Get('health')
9getHealth() {
10 return {
11 status: 'ok',
12 eventLoopDelay: {
13 p50: histogram.percentile(50), // median delay
14 p99: histogram.percentile(99), // tail delay
15 max: histogram.max,
16 },
17 memoryMB: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
18 uptime: process.uptime(),
19 };
20}When event loop delay p99 exceeds 100ms during load, the root cause is almost always:
JSON.parse/JSON.stringify of a large object (>1 MB)End-to-end profiling: run k6 to identify a slow endpoint, profile with clinic flame, identify the hot path, apply the fix, and re-run k6 to verify improvement.
1// Step 1: k6 script to identify the slow endpoint
2// tests/perf/profile-trigger.js
3import http from 'k6/http';
4import { check, sleep } from 'k6';
5
6export const options = {
7 vus: 30,
8 duration: '60s',
9 thresholds: {
10 'http_req_duration{name:search}': ['p(95)<500'], // SLO
11 'http_req_duration{name:courses}': ['p(95)<300'],
12 },
13};
14
15export default function () {
16 // These are the endpoints we profile
17 http.get('http://localhost:3004/courses', { tags: { name: 'courses' } });
18 sleep(0.2);
19 http.get('http://localhost:3004/search?q=typescript', { tags: { name: 'search' } });
20 sleep(1);
21}
22
23// Step 2: Run profiling while k6 is active
24// Terminal 1: clinic flame -- node dist/main.js
25// Terminal 2: k6 run tests/perf/profile-trigger.js
26// Ctrl+C clinic after k6 completes
27
28// Step 3: Apply the fix revealed by the flame graph
29// src/modules/search/search.service.ts — BEFORE (slow)
30import { Injectable } from '@nestjs/common';
31import { PrismaService } from '../prisma/prisma.service';
32
33@Injectable()
34export class SearchService {
35 constructor(private prisma: PrismaService) {}
36
37 // BAD: No pagination cap, full-text search without index
38 async search(query: string) {
39 const results = await this.prisma.course.findMany({
40 where: {
41 OR: [
42 { title: { contains: query } }, // sequential scan — no index
43 { description: { contains: query } }, // sequential scan
44 ],
45 },
46 // No LIMIT — could return 10,000 rows and serialize all of them
47 });
48
49 // BAD: Parsing + serializing full course objects including description (2KB each)
50 return results.map((r) => ({
51 id: r.id,
52 title: r.title,
53 description: r.description, // 2KB per result, unnecessary
54 price: r.price,
55 rating: r.rating,
56 lessonCount: r.lessonCount,
57 thumbnailUrl: r.thumbnailUrl,
58 createdAt: r.createdAt.toISOString(), // toISOString in hot path
59 }));
60 }
61}
62
63// Step 3 AFTER: Fixed with full-text search index, pagination, projection
64// First, add to Prisma schema and create migration:
65// @@fulltext([title, description]) -- requires MySQL or Postgres with pg_trgm
66// CREATE INDEX courses_search_idx ON courses USING gin(to_tsvector('english', title || ' ' || description));
67
68@Injectable()
69export class SearchServiceFixed {
70 constructor(private prisma: PrismaService) {}
71
72 // GOOD: Full-text search with index, pagination, minimal projection
73 async search(query: string, limit = 20, page = 1) {
74 const offset = (page - 1) * limit;
75
76 // Use raw SQL for full-text search with GIN index (10-50x faster)
77 const results = await this.prisma.$queryRaw`
78 SELECT id, title, price, rating, thumbnail_url,
79 ts_rank(to_tsvector('english', title || ' ' || description),
80 plainto_tsquery('english', ${query})) AS rank
81 FROM courses
82 WHERE to_tsvector('english', title || ' ' || description)
83 @@ plainto_tsquery('english', ${query})
84 ORDER BY rank DESC
85 LIMIT ${limit} OFFSET ${offset}
86 `;
87
88 // GOOD: Return only what the client needs (no 2KB descriptions)
89 return results;
90 }
91}
92
93// Step 4: Verify fix with same k6 script
94// Expected: p95 drops from 2.3s to under 300msThis end-to-end workflow demonstrates the profiling cycle: identify the slow endpoint with k6 (p95 > 500ms on /search), run clinic flame while k6 drives 30 VUs, read the flame graph (JSON.stringify of large objects + sequential full-text scan taking 60% of CPU), apply the fix (GIN index + raw SQL + minimal projection), and re-run k6 to verify p95 drops from 2.3s to under 300ms. The key insight is that Prisma's contains: {} operator generates a LIKE '%query%' query which cannot use B-tree indexes and requires a full table scan — switching to full-text search with a GIN index eliminates this.
The next lesson covers memory leak detection patterns in detail — using clinic bubbles to visualize libuv I/O, detecting EventEmitter listener accumulation, and fixing common NestJS service memory leaks.
Your progress is saved in this browser, on this device only.