Reading Bottom-Up and Call Tree Views in the Performance Panel
The flame chart shows when the main thread was busy, but when a recording spans hundreds of tasks you need a ranked answer to what was expensive — this guide from Performance Panel Flame Graph Analysis, in Browser DevTools & Performance Profiling Workflows, explains how the Bottom-Up, Call Tree and Event Log tabs aggregate the same data and which to use for which question.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
| Flame chart is too dense to find the cost | Hundreds of short tasks; no single outlier | Select the range and open Bottom-Up, sorted by Self Time | Top 3 rows usually explain 40–70% of scripting time |
| A framework function tops the list | Self time is spent inside library code called by your code | Expand the row to see callers; stop at your first frame | Finds the component or hook that drives the work |
Call Tree shows a huge (anonymous) root |
Event handler or microtask entry points are unnamed | Group by URL or name your handlers | Attribution moves to files and functions you own |
| GC, layout or style time hidden among functions | Activities mixed with script frames | Group Bottom-Up by Activity or filter by name | Shows how much time is GC or rendering vs JS |
| Total time far exceeds wall-clock | Total double-counts nested calls | Compare Self Time, not Total Time, across rows | Correct ranking of where time is actually spent |
Root Cause: One Profile, Three Aggregations
A Performance recording contains a sampled JavaScript call stack every fraction of a millisecond plus trace events for rendering, garbage collection and tasks. The flame chart draws those stacks over time. The three tabs under it — Bottom-Up, Call Tree and Event Log — throw the time axis away and aggregate the same stacks for whatever range you have selected in the overview.
Call Tree is top-down. Its roots are the entry points of work — Task, Event: click, Animation Frame Fired, Function Call — and each child is something they called. Each row has Self Time (time spent in that frame’s own code) and Total Time (self plus everything it called). It answers “which entry point is expensive, and how does its cost break down?”.
Bottom-Up inverts it. Its roots are the frames where time was actually spent — the leaves — ranked by self time, and expanding a row shows who called it. It answers “which functions actually consume the CPU?”, which is usually the first question during optimisation. It is also where non-JavaScript activities show up as first-class rows: Minor GC, Major GC, Recalculate Style, Layout, Parse HTML. That makes Bottom-Up the fastest way to quantify garbage collection pauses in a trace relative to script.
Event Log keeps the time order but lists events chronologically with durations, filterable by duration threshold — useful for “show me every task over 50 ms”.
Two reading errors are common. First, sorting by Total Time in Bottom-Up surfaces frames that sit on every stack (the framework’s scheduler, (program)), not the ones doing the work; sort by Self Time. Second, stopping at a library frame: the top Bottom-Up row is frequently inside React, a virtual DOM diff, JSON.parse or a date library. Expanding it reveals the chain of callers, and the first frame from your own bundle is what you can change — the same “follow up to your own code” rule as when reading the Retainers panel in a heap snapshot.
Step-by-Step Fix
- Record and select the slow range. In DevTools → Performance, record the slow interaction, then drag across the overview to select only the part that felt slow. Verification: the Summary tab’s pie shows the range duration and its split between Scripting, Rendering, Painting and System.
- Open Bottom-Up and sort by Self Time. Click the Bottom-Up tab and the Self Time column header. Verification: the top rows are specific functions or activities such as
JSON.parse,LayoutorMinor GC, not generic entries likeTask. - Group by activity or URL when needed. Use the grouping dropdown to choose Group by Activity to separate script from GC and rendering, or Group by URL to see which bundle or third-party script is responsible. Verification: you can say what share of the range is your code versus third-party code versus browser work.
- Expand the top row until you hit your code. Click the disclosure triangle of the top row to reveal its callers, and keep expanding the heaviest caller. Verification: you reach a function from your own source and can click its link to open the file in Sources.
- Cross-check in Call Tree. Switch to Call Tree, find the same entry point, and confirm the Total Time of your function accounts for the cost. Verification: the Total Time of your function is close to the Self Time sum you saw in Bottom-Up for its expensive children.
- Change, re-record, and compare the same row. Fix the call site — cache the parsed value, move work off the interaction, debounce — and re-record the same interaction. Verification: the row’s Self Time drops, and the range’s Scripting share in Summary falls accordingly.
Command and Code Reference
Use case: name anonymous entry points so the Call Tree is readable. Inline arrow functions registered as handlers all show up as (anonymous); naming them costs nothing and makes attribution immediate.
// Before: shows up as "(anonymous)" under "Event: click"
button.addEventListener('click', () => applyFilter(currentFilter));
// After: a named function appears as "onFilterClick" in Call Tree and Bottom-Up
function onFilterClick() {
applyFilter(currentFilter);
}
button.addEventListener('click', onFilterClick);
Use case: mark your own phases so they appear as labelled spans. User Timing marks appear in the Timings track and help you line up Bottom-Up ranges with application phases.
// Wrap the suspected phase; the measure appears in DevTools → Performance → Timings
function applyFilter(filter) {
performance.mark('filter:start');
const view = loadSavedView(filter.viewId); // expensive: parses stored JSON
const rows = filterRows(view.rows, filter);
performance.mark('filter:end');
performance.measure('applyFilter', 'filter:start', 'filter:end');
render(rows);
}
// The fix Bottom-Up pointed at: parse once and cache by id
const viewCache = new Map();
function loadSavedView(id) {
if (!viewCache.has(id)) {
viewCache.set(id, JSON.parse(localStorage.getItem(`view:${id}`)));
}
return viewCache.get(id);
}
Verification and Regression Prevention
Verify with the same selection method each time: select from the input event to the end of the next frame that shows the result, then compare the Bottom-Up Self Time of the function you changed and the Summary totals. A real fix changes both; a fix that only moves work elsewhere shows the time reappearing under a different row. If your change introduced a cache — as in the example — also check the memory side: an unbounded cache trades CPU for heap, so bound it or tie it to the view’s lifetime.
Be careful with recordings made on a fast development machine. Bottom-Up ratios are fairly stable across hardware, but absolute times are not, and a function that costs 12 ms on a desktop can cost 60 ms on a mid-range phone. Use the CPU throttling option in the Performance panel’s capture settings (4× or 6× slowdown) when you want absolute numbers that resemble real users, and always compare before/after recordings made with the same throttling. Also note that the profiler samples stacks at intervals, so functions that run for less than the sampling interval may be under- or over-represented in a single recording; repeat the interaction a few times inside one recording to smooth that out.
Source maps matter too. If your production bundle is minified and DevTools cannot load its source maps, Bottom-Up rows show mangled names such as a.b or t, and Group by URL collapses everything into one chunk. Record against a build with source maps available — even a local production build served with its .map files — so the function names in the tables match your code.
To prevent regressions, keep the performance.measure() calls in production builds and report them to your analytics; a rising median for applyFilter is an early signal before users notice. For lab regression tests, record the same interaction in CI and track the measure’s duration, or use the summary approach from exporting and analyzing DevTools performance traces offline to fail builds when a named function’s self time grows beyond a threshold.
Frequently Asked Questions
What is the difference between Self Time and Total Time?
Self Time is time spent executing a function’s own code, excluding anything it called. Total Time includes all of its callees. Bottom-Up should be read by Self Time to find where CPU is actually spent; Call Tree is read by Total Time to see which entry point is expensive overall.
Why does (program) or (idle) appear at the top?
(idle) is time the main thread had nothing to do and (program) is native browser work that was not attributed to JavaScript or a known activity. Neither is actionable directly. Exclude them mentally, or select a tighter range around the interaction.
Should I group by URL or by activity?
Group by Activity first to see whether script, GC or rendering dominates. If script dominates, switch to Group by URL to see whether your bundle, a framework chunk or a third-party script is responsible, then go back to the ungrouped view to find the specific function.
Related
- Performance Panel Flame Graph Analysis — the parent topic
- Spotting Garbage Collection Pauses in a Performance Trace — quantifying GC inside the same views
- Finding Forced Reflow and Layout Thrashing — when Layout tops the Bottom-Up list
- Browser DevTools & Performance Profiling Workflows — the section overview