Your Node.js API is slow. Response times have been creeping up over the past two weeks. New Relic shows P99 latency at 800ms — it was 120ms last month. You’ve read the code. Nothing obvious jumps out. This is exactly the scenario where Clinic.js earns its keep.
This post walks through a real diagnosis using the three Clinic.js tools: Doctor (overall health), Flame (CPU profiling), and Bubbleprof (async profiling). The service we’re diagnosing: a financial transaction processing API I worked on, anonymized.
Table of contents
Open Table of contents
Installing Clinic.js
npm install -g clinic
# Verifyclinic --version# clinic.js v12.0.0 — https://clinicjs.orgClinic wraps your Node.js process, collects diagnostic data, and generates an interactive HTML report. No code changes required — it instruments at the process level.
The System Under Test
A REST API that:
- Accepts transaction data via POST
/transactions - Validates and enriches transaction records
- Writes to PostgreSQL via
pg(node-postgres) - Returns a confirmation with generated transaction ID
Simple enough. Yet it’s slow. Let’s find out why.
Step 1: Clinic Doctor — The Health Check
Doctor gives you a high-level overview: is the issue CPU? Memory? Event loop? I/O?
# Start the server under Doctorclinic doctor -- node server.js
# In another terminal, apply loadnpx autocannon -c 50 -d 30 http://localhost:3000/transactions \ -m POST \ -H "Content-Type: application/json" \ -b '{"amount": 1500.00, "currency": "EUR", "merchant": "test"}'
# Stop the server with Ctrl+C — Doctor generates the reportDoctor measures:
- Event loop delay: How long callbacks wait beyond their scheduled time
- CPU usage: % of a single core being used
- Active handles: Number of open file descriptors, connections, etc.
- Memory usage: Heap used vs total
Our Doctor report showed:
⚠️ Detected a potential Event Loop issue The event loop was slow on 67% of samples Average event loop delay: 187ms Maximum event loop delay: 1,240ms
⚠️ Detected a potential I/O issue There were periods of blocking I/OEvent loop delay of 187ms average is severe. Something is blocking the event loop or the thread pool is saturated.
Step 2: Clinic Flame — CPU Profiling
Flame generates a flame graph — a visualization of where CPU time is being spent. Each block is a function; width represents time spent (including all callees).
clinic flame -- node server.js# Apply the same load, then Ctrl+CThe flame graph revealed a thick block at the bottom:
validateTransaction → JSON.parse → (anonymous) → parseFloatDigging into the code:
// Original code — the culpritasync function validateTransaction(rawBody) { // rawBody is already a parsed object! This is pointless: const data = JSON.parse(JSON.stringify(rawBody));
// But this is the real issue: const enriched = await enrichWithExchangeRates(data); return enriched;}
async function enrichWithExchangeRates(data) { // Called for EVERY request — uncached const rates = await fetch('https://api.exchangerate.host/latest'); const json = await rates.json(); data.exchangeRate = json.rates[data.currency]; return data;}Two bugs found:
JSON.parse(JSON.stringify(obj))— an expensive deep-clone hack for every request- Uncached external HTTP call to exchange rate API on every single transaction
The exchange rate API has ~300ms latency. With 50 concurrent clients, we’re hammering it with 50 simultaneous requests per second. Sometimes it rate-limits us and we wait even longer.
Step 3: Clinic Bubbleprof — Async Profiling
While Flame shows CPU usage, Bubbleprof visualizes async operations — what your code is waiting for and how async chains connect.
clinic bubbleprof -- node server.jsBubbleprof generates a “bubble chart” where:
- Each bubble = a group of async operations
- Bubble size = total async delay
- Lines = flow of async operations
Our Bubbleprof showed a massive bubble labeled HTTP → HTTPS pointing to api.exchangerate.host. It was responsible for 73% of all async wait time in the system.
The second large bubble: pg.Pool with surprisingly long wait times — not query execution, but connection acquisition. Our pool was configured with max: 2 (a misconfiguration — the pg.Pool default is 10), meaning we were queuing database connection requests while processing high load.
The Fixes
Fix 1: Remove the pointless deep clone
// Before:const data = JSON.parse(JSON.stringify(rawBody));
// After: Just use the object directly (or clone with structuredClone if needed)const data = structuredClone(rawBody); // native, handles more types (Date, Map, ArrayBuffer) correctlyFix 2: Cache exchange rates
class ExchangeRateCache { constructor() { this.rates = null; this.lastFetch = null; this.TTL = 5 * 60 * 1000; // 5 minutes }
async getRates() { const now = Date.now(); if (this.rates && (now - this.lastFetch) < this.TTL) { return this.rates; // cache hit }
const response = await fetch('https://api.exchangerate.host/latest'); const json = await response.json(); this.rates = json.rates; this.lastFetch = now; return this.rates; }}
const rateCache = new ExchangeRateCache();
async function enrichWithExchangeRates(data) { const rates = await rateCache.getRates(); data.exchangeRate = rates[data.currency]; return data;}Fix 3: Increase the connection pool
// Before:const pool = new Pool({ connectionString: process.env.DATABASE_URL // max defaults to 10, but effective was 2 due to misconfiguration});
// After:const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 20, // max connections (match your DB's max_connections / number of app instances) idleTimeoutMillis: 30000, connectionTimeoutMillis: 2000,});Results After Fixes
Running the same Doctor + Flame + autocannon benchmark after fixes:
| Metric | Before | After |
|---|---|---|
| P50 latency | 310ms | 18ms |
| P99 latency | 1,240ms | 45ms |
| Event loop delay avg | 187ms | 3ms |
| Requests/sec | ~45 | ~820 |
| External HTTP calls/req | 1 (300ms+) | ~0 (cached) |
P99 improved by 27x. Requests per second improved by 18x. Both without changing a single line of business logic — just removing unnecessary work.
Reading Flame Graphs
The most common misreading of flame graphs: looking at the tallest columns rather than the widest ones. Width = time. Height = call depth.
Key patterns to recognize:
Wide plateau at top: A function taking significant self-time (not waiting on callees). Usually where optimization is needed.
Wide block in the middle: A common ancestor of many call paths — an abstraction layer that touches everything.
Thin, tall spikes: Deep call stacks with little time each — usually fine.
Flat bottom, wide: A framework’s request handler that calls your code. Normal.
The Clinic.js flame graph is interactive — you can click to zoom, filter by path, and see exact timing percentages.
Other Clinic Tools
Clinic Heap Profiler (clinic heapprofiler): For memory leaks. Samples heap allocations over time, identifies what’s being created and retained. Essential for diagnosing the “memory grows until OOM” class of bugs.
Clinic CPU Profiler (clinic cpuprofiler): More detailed than Flame, generates a .cpuprofile file compatible with Chrome DevTools. Useful when you need to share the profile with someone who prefers the Chrome UI.
When Clinic Isn’t Enough
Clinic is process-local — it sees one Node.js instance. For distributed tracing (microservices, multiple instances), you need:
- OpenTelemetry: Vendor-neutral tracing across services
- Jaeger or Zipkin: Distributed trace visualization
- Datadog APM / New Relic APM: Commercial, more turnkey
But 80% of performance issues are local — one function, one misused API, one missing cache. Clinic finds those in minutes.
The toolchain is clinic doctor → clinic flame → fix → measure again. The cycle is fast enough that you can iterate in a development environment before deploying a fix. That’s the kind of feedback loop that produces consistently fast code.