Reading Clinic HeapProfiler Flame Graphs

You ran clinic heapprofiler against your service under load and got a flame graph full of wide bars labelled with Node internals, JSON.parse and framework code — and you need to turn it into a single line of your own code to change. This guide from Diagnosing Node Memory with Heapdump and Clinic, part of Node.js Server-Side Memory Management, explains how the HeapProfiler flame graph is built, how to read width and stacking correctly, and a repeatable routine for comparing runs.

Symptom Root Cause Immediate Action Measurable Impact
Widest bars are Node internals or JSON.parse Allocation happens in library code called from your code Look at the frames below the wide bar to find your caller Actionable call site identified
Flame graph dominated by startup code Profile covers process start and module loading Warm up before load, or run long enough that load dominates Steady-state picture
Two runs look completely different Different load, duration or data Fix the load script, duration and dataset Comparable before/after
Narrow but numerous bars everywhere Allocation spread across many small sites Use search to aggregate a function across stacks True total for a function
Unsure whether it shows leaks or churn Profile shows allocations sampled, not retainers Confirm retention with heap snapshots Right tool for the question

Root Cause: Width Is Allocated Bytes, Stacks Are Callers

Clinic.js HeapProfiler runs your process with V8’s sampling heap profiler enabled — the same mechanism described in using --heap-prof sampling in production — while you drive load against it, then renders the aggregated samples as an interactive flame graph. Each sample records the call stack at an allocation; stacks with common prefixes are merged.

Reading it correctly depends on three conventions. Width represents the share of sampled allocation attributed to that frame including everything it called; a wide bar is a function under which a lot of memory was allocated, not necessarily one that allocated it directly. Vertical position represents call depth: a frame sits on top of its caller, so the frames directly above a bar are what it called and the frames below are who called it. The top edge of each column — the frames with nothing above them — are where bytes were actually allocated, the flame-graph equivalent of Self Size in DevTools’ Heavy view. Colour distinguishes your application code from dependencies and Node core in Clinic’s default scheme, which is the fastest way to find the boundary where your code hands work to a library.

The usual finding looks like this: a wide tower whose top frames are JSON.stringify, Buffer.from or a template engine, and somewhere in the middle of the tower a frame from your code — logRequest, renderProduct, mapRow. That middle frame is the decision point: it chooses to serialise the whole body, to render per item, to map every column. Just as with reading Bottom-Up and Call Tree views, the rule is to follow the tower down from the hot leaf until you reach the first frame you own.

A sampling flame graph shows where allocation happens, which covers both leaks and churn. It does not show what is retained or by whom; for that you still need heap snapshots. Check the maintenance status of Clinic.js for your Node.js version before adopting it; the underlying sampling profiler is built into Node and available without it.

Reading an allocation flame graph The bottom row is the request handler, spanning the full width. Above it, logRequest takes 45 percent of the width and renderProduct 35 percent. Above logRequest sits JSON.stringify, the leaf where bytes are allocated. Above renderProduct sit template render and string concatenation leaves. The first application frame beneath each hot leaf, logRequest and renderProduct, is where to change code. handleRequest (your code) — 100% of sampled bytes logRequest (yours) — 45% renderProduct (yours) — 35% other 20% JSON.stringify (leaf: allocates) template.render concat (leaf) escapeHtml (leaf) width = sampled bytes under a frame · top edges = where bytes are allocated green = your code: follow each tower down to the first green frame

Step-by-Step Fix

  1. Write a fixed load script. Use a tool such as autocannon with a fixed URL mix, concurrency and duration (for example 60 seconds at 50 connections) against realistic data. Verification: repeated runs produce similar request rates.
  2. Record under that load. Run clinic heapprofiler --on-port 'autocannon -c 50 -d 60 localhost:$PORT/api/products' -- node server.js. Verification: Clinic opens an HTML report with the flame graph after the run.
  3. Find the widest towers. Look for the widest columns at the second and third levels; note their share. Verification: you have two or three candidate towers covering most of the width.
  4. Walk each tower down to your code. From the hot leaves at the top, move downward until you reach the first frame from your application (use the colour key). Verification: each tower has a named application frame and file.
  5. Aggregate spread-out functions. Use the flame graph’s search to highlight a function that appears in many stacks and read its combined share. Verification: you know the true total for helpers called from many places.
  6. Fix and re-run the identical load. Change the application frame (log less, stream instead of stringify, render once), then repeat steps 2–3. Verification: the tower narrows in the new graph, and allocation rate in production metrics falls.
logRequest share before and after the fix Before the fix, the logRequest tower accounts for 45 percent of sampled allocation because it serialises full request bodies. After logging only method, path, status and duration, it accounts for 4 percent under the same load, and total sampled allocation per request drops by about 40 percent. logRequest share of sampled bytes, identical load Before 45% After 4% — metadata only

Command and Code Reference

Use case: a reproducible HeapProfiler run.

# Install once (dev dependency); check compatibility with your Node version
npm i -D clinic autocannon

# Clinic starts the server, runs the load when the port opens, then stops and reports
npx clinic heapprofiler \
  --on-port 'npx autocannon -c 50 -d 60 localhost:$PORT/api/products?page=1' \
  -- node server.js

Use case: the typical fix for a logging tower.

// Before: every request serialises headers and the full body
function logRequest(req, res, ms) {
  logger.info(JSON.stringify({ headers: req.headers, body: req.body, status: res.statusCode, ms }));
}

// After: fixed, small fields; the logger formats them lazily
function logRequestLean(req, res, ms) {
  logger.info({ method: req.method, path: req.path, status: res.statusCode, ms });
}

Verification and Regression Prevention

A fix is confirmed when the same load script, duration and dataset produce a narrower tower for the changed function, and production allocation rate or GC share falls after deployment. Keep the load script and the Clinic command in the repository so anyone can reproduce the graph, and store before/after reports with the pull request.

For ongoing protection, run a short sampling profile of the main endpoints in CI on performance-sensitive services and alert when the share of a known hot function grows beyond its previous level. When the flame graph points at retention rather than churn — a tower that keeps growing across longer runs — switch to snapshot diffing with taking heap snapshots from a live Node.js process.

Width of the changed function’s tower With the same load script, duration and dataset, the changed function’s tower in the Clinic HeapProfiler flame graph is much narrower after the fix. In production, allocation rate and GC share fall after deployment. alloc share same load script, before → after deploy changed function: tower width production GC share Keep the load script and clinic command in the repository with the before/after reports.

Edge Cases and Gotchas

Inlined frames

Optimised code may inline small functions into their callers, so a helper can disappear from stacks and its allocation is attributed to the caller. If a frame you expected is missing, look at its callers.

Async stacks

Allocations in callbacks and promise continuations show the stack at the time they run, which may start at an event-loop entry point rather than the request handler. Name your async functions so the continuation frames are recognisable.

Short runs overweight startup

A 10-second run of a service that takes 5 seconds to start is mostly startup. Run long enough — a minute or more — for steady-state load to dominate, or discount the startup towers.

Data-dependent allocation

Allocation per request depends on payload sizes. Use production-like data in the load script; tiny fixtures hide the towers that matter.

Frequently Asked Questions

What does the width of a bar mean in a Clinic HeapProfiler flame graph?

The share of sampled allocated bytes attributed to that frame and everything it called during the recording. Wide bars lie on stacks that allocated a lot; the frames at the top edge of a column are where the allocation actually happened.

Why are the widest bars in Node or library code?

Because the actual allocation often happens in serialisers, parsers, buffers or template engines. Your code decides how much work they do. Follow the tower down to the first frame from your application; that is where the fix belongs.

Does HeapProfiler find memory leaks?

It shows where memory is allocated, which reveals both leaks and churn, but not what retains objects. Use it to find heavy allocators, then confirm and diagnose retention with heap snapshots.

Can I get the same data without Clinic?

Yes. Node’s --heap-prof flag and the inspector’s HeapProfiler.startSampling produce .heapprofile files that Chrome DevTools and other viewers can display, including as flame charts.

How do I compare two flame graphs fairly?

Use the same load script, concurrency, duration and dataset, run on the same machine type, and compare the share of the functions you care about rather than absolute byte figures. Save both reports with the commit hashes they came from.

How long should I record?

Long enough that steady-state load dominates the profile — typically one to five minutes under realistic load. Use the same duration and load for every comparison.