clinic.js Suite
Profile Node.js services with NearForm's clinic.js tools - Doctor for holistic health, Flame for CPU hotspots, and Bubbleprof for async I/O delays.
Recipe
Quick-reference recipe card - copy-paste ready.
npm install -g clinic
clinic doctor -- node dist/server.js
# In another terminal, generate load:
npx autocannon -c 50 -d 30 http://localhost:3000/health
# Press Ctrl+C on the server - Doctor opens an HTML reportWhen to reach for this:
- p95 latency rising without obvious error spikes.
- CPU high but you cannot name the hot function.
- Async waterfalls - many sequential
awaitcalls adding latency. - Before migrating frameworks or rewriting hot paths.
Working Example
# 1. Doctor - overall health (loop delay, CPU, memory)
clinic doctor -- node --import tsx src/server.ts
# 2. Flame - CPU flame graph (run under load, then stop)
clinic flame -- node --import tsx src/server.ts
# 3. Bubbleprof - async delay visualization
clinic bubbleprof -- node --import tsx src/server.ts// src/server.ts - minimal app to profile
import Fastify from "fastify";
const app = Fastify({ logger: false });
app.get("/health", async () => ({ ok: true }));
app.get("/users/:id", async (req) => {
// Simulate sequential I/O - Bubbleprof will highlight this
const profile = await fakeDb("profiles", req.params.id);
const orders = await fakeDb("orders", req.params.id);
return { profile, orders };
});
async function fakeDb(table: string, id: string) {
await new Promise((r) => setTimeout(r, 20));
return { table, id };
}
await app.listen({ port: 3000, host: "0.0.0.0" });What this demonstrates:
- Doctor runs the process and collects metrics until you stop it.
- Flame requires load during capture - idle servers produce empty graphs.
- Bubbleprof exposes sequential
awaitchains thatPromise.allcould parallelize. - TypeScript entry via
--import tsxavoids a separate build step during investigation.
Deep Dive
How It Works
- clinic.js wraps your Node process and injects low-overhead collectors.
- Doctor correlates event loop delay, active handles, and CPU usage on one timeline.
- Flame samples the call stack at intervals and renders a V8-style flame graph.
- Bubbleprof traces async operations from initiation to callback completion.
Tool Selection
| Tool | Best for | Not for |
|---|---|---|
| Doctor | First pass - "is the loop blocked?" | Pinpointing exact function names |
| Flame | CPU-bound hot paths, sync JSON, tight loops | Network-only latency (no CPU burn) |
| Bubbleprof | Slow downstream HTTP/DB, serial awaits | Pure CPU math without I/O |
Running Under Load
# Terminal 1
clinic flame -- node dist/server.js
# Terminal 2 - sustain concurrency
npx autocannon -c 100 -d 60 http://localhost:3000/users/abc- Profile duration: 30-120 seconds of sustained load.
- Match production concurrency (
-c) and payload sizes. - Disable verbose logging during capture - log I/O skews results.
TypeScript Notes
clinic doctor -- node --import tsx src/main.ts
# or compile first for closer-to-prod symbols:
npm run build && clinic flame -- node dist/main.js- Source maps in compiled output improve Flame readability.
- Profile staging builds, not dev with
pino-prettytransport.
Gotchas
- Profiling without load - empty Flame graphs. Fix: run autocannon or k6 concurrently.
- Short capture windows - miss periodic GC pauses. Fix: 60+ seconds at target RPS.
- Logging at debug during capture - I/O dominates the graph. Fix:
LOG_LEVEL=warn. - Profiling locally on a laptop - different CPU count than production. Fix: match k8s CPU limits in staging.
- Only using Doctor once - does not name the function. Fix: follow up with Flame on the same scenario.
- Ignoring Bubbleprof for "fast" APIs - hidden serial DB calls add 100ms+ each. Fix: parallelize independent queries.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| clinic.js | Node HTTP services, event loop issues | Non-Node runtimes |
| 0x / flamegraph | Quick one-off CPU samples | Need async waterfall view |
| OpenTelemetry traces | Continuous production sampling | Deep V8-level CPU attribution |
--inspect + Chrome DevTools | Interactive debugging one request | Sustained load under concurrency |
FAQs
Is clinic.js safe for production?
No. Run in staging with anonymized data. Overhead is low but not zero; stop traffic to the profiled instance.
Doctor says "event loop delay" - what next?
Run Flame under the same load. Search for wide bars in sync code: JSON.parse, bcrypt, *Sync fs calls.
Can I use clinic with NestJS?
Yes. Point clinic at your compiled main.js or node --import tsx src/main.ts. Disable Swagger and verbose logging during capture.
How does this compare to OpenTelemetry?
clinic.js is for deep, time-boxed investigations. OTel is for continuous traces and metrics in production. Use both.
Flame graph is flat - no spikes?
Either load is too low or the bottleneck is outside Node (DB, network). Check Bubbleprof and DB EXPLAIN.
Does clinic work on Node 24?
Yes. Install the latest clinic globally. Pin Node 24.18.0 to match production.
Can I automate clinic in CI?
Possible but heavy. Prefer k6 thresholds for regression gates; reserve clinic for manual deep dives on failures.
What about memory leaks?
Doctor shows rising heap over time. Pair with writeHeapSnapshot from Memory & GC Tuning.
Related
- Performance Basics - measure before optimizing
- Load Testing - generate realistic traffic for profiling
- Memory & GC Tuning - heap limits and snapshots
- Detecting Event-Loop Blockage - loop delay metrics
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.