Skip to content

Profiling Node.js with Clinic.js and DoctorJS — A Real Case Study

Posted on:May 20, 2024 at 10:00 AM

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

Terminal window
npm install -g clinic
# Verify
clinic --version
# clinic.js v12.0.0 — https://clinicjs.org

Clinic 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:

  1. Accepts transaction data via POST /transactions
  2. Validates and enriches transaction records
  3. Writes to PostgreSQL via pg (node-postgres)
  4. 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?

Terminal window
# Start the server under Doctor
clinic doctor -- node server.js
# In another terminal, apply load
npx 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 report

Doctor measures:

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/O

Event 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).

Terminal window
clinic flame -- node server.js
# Apply the same load, then Ctrl+C

The flame graph revealed a thick block at the bottom:

validateTransaction → JSON.parse → (anonymous) → parseFloat

Digging into the code:

// Original code — the culprit
async 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:

  1. JSON.parse(JSON.stringify(obj)) — an expensive deep-clone hack for every request
  2. 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.

Terminal window
clinic bubbleprof -- node server.js

Bubbleprof generates a “bubble chart” where:

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) correctly

Fix 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:

MetricBeforeAfter
P50 latency310ms18ms
P99 latency1,240ms45ms
Event loop delay avg187ms3ms
Requests/sec~45~820
External HTTP calls/req1 (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:

But 80% of performance issues are local — one function, one misused API, one missing cache. Clinic finds those in minutes.

The toolchain is clinic doctorclinic 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.