A profiler samples the call stack thousands of times per second. A flame graph aggregates those samples into a picture: every distinct stack becomes a column, identical prefixes merge, and the width of each frame is proportional to how often it appeared.
It is the fastest way to answer "where is the time going", and it is routinely misread.
What changed in 2026
- Continuous profiling in production spread. Always-on low-overhead profiling replaced occasional manual sessions.
- Differential graphs became common. Comparing before and after as a single visualisation made regression analysis far quicker.
- Off-CPU profiling gained attention. Recognition that on-CPU graphs miss blocked time drove adoption of complementary views.
- Language runtime support improved. Native profiling became available across more managed runtimes without instrumentation.
Reading one correctly
Width is what matters. A frame's width is the proportion of samples in which it appeared. Wide means expensive.
Height is not significance. Height is stack depth — how deeply nested a call is. A tall narrow tower is deeply nested code that consumes almost nothing. People consistently look at the tall parts, which is the most common misreading.
The x-axis is not time. Frames are sorted alphabetically so identical stacks merge. Left-to-right position means nothing chronologically. A flame graph tells you what consumed time, never when.
Look for wide plateaus. A wide frame near the top of the graph, with little above it, is code actually executing rather than calling something else. That is where the work happens.
| Shape |
Meaning |
| Wide plateau near the top |
Real work; optimisation target |
| Tall narrow tower |
Deep nesting, little cost |
| Wide frame with many narrow children |
Dispatch point; look at callers |
| Broad flat base |
Many distinct paths; no single hot spot |
Where the fix belongs
A wide frame is not always where you change something.
If the widest frame is a framework function, a serialiser, or a standard library call, you probably cannot make it faster. What you can change is how often it is called.
So look below it in the graph — at the callers. A serialisation function consuming 40% of your time is a symptom; the code calling it a thousand times per request is the cause. The fix is upstream.
This is why reading only the top of the graph produces conclusions like "JSON encoding is slow" rather than "we serialise the same object repeatedly".
What on-CPU profiling misses
The important limitation. A standard flame graph shows where the program was executing. A program blocked waiting — on a database query, an HTTP call, a lock, disk I/O — is not executing, so it barely appears.
For an application spending most of its time waiting on a database, an on-CPU flame graph shows almost nothing interesting, and the natural conclusion is that the application is fine. It is not; it is waiting.
Off-CPU profiling captures the other half: where threads block and for how long. Together the two views cover the whole picture, and knowing which one you are looking at prevents an entire category of wrong conclusion.
For most web applications the waiting is the story — see slow query logs and pg_stat_statements for the database side of it.
Common mistakes
- Reading height as significance. Height is nesting.
- Interpreting the x-axis as time. It is not chronological.
- Optimising a framework frame you cannot change. Look at the callers.
- Using on-CPU profiling for an I/O-bound application. Shows nothing useful.
- Profiling with unrealistic load. Hot paths differ under real traffic.
- Not comparing before and after. A differential view is far more informative.
- Profiling a debug build. Different optimisation, different picture.
FAQ
What sampling rate should I use?
High enough to resolve the paths you care about, low enough that overhead does not distort the picture. Defaults in mature profilers are usually reasonable.
Can I profile in production?
Modern continuous profilers are designed for it, with overhead low enough to leave on. That is far more useful than reproducing load in a test environment, because the real hot paths are frequently not what you would have guessed.
Why does my graph show mostly framework internals?
Usually because your own code is thin and calls into libraries heavily. The information is in the call frequency rather than the leaf frames.
How do I compare two profiles?
A differential flame graph colours frames by whether they grew or shrank. Far quicker than comparing two graphs side by side.
Where to go next
For memory rather than CPU, read allocation profiling. For the database side of latency, slow query logs, and for the concurrency model that affects what you see, async vs threads.