How to Use the Performance Tab to Find Main Thread Jank
Main thread jank — dropped frames, delayed input responses, and visible stutter — occurs when synchronous execution exceeds the 16.67 ms frame budget. Identifying the exact call stack responsible requires a disciplined capture workflow inside the Performance Panel Flame Graph Analysis tools within the broader Browser DevTools & Performance Profiling workflow. This guide takes you from symptom to verified fix using Chrome DevTools, explicit panel commands, and measurable before/after metrics.
Symptom-to-Fix Diagnostic Matrix
Use this table first if you are in a production fire and need the fastest path to the right section.
| Symptom | Root Cause | Immediate Action |
|---|---|---|
| Red triangles on Main thread track | Long Task exceeding 50 ms | Open DevTools → Performance → Bottom-Up tab; sort by Self Time descending |
| Continuous yellow Scripting bars across 3–4 frame markers | Synchronous heavy computation on every scroll/input event | Wrap handler body in requestAnimationFrame with a ticking guard |
| Alternating purple Recalculate Style + Layout bars | Layout thrashing — DOM read after write in a loop | Batch all reads before writes; use requestAnimationFrame to defer reads |
| Flat or empty Main thread on the trace | Recording without triggering interaction, or DevTools not open during capture | Enable CPU throttling, start recording, then immediately trigger the interaction |
| GC pause bars interrupting frame boundaries | High allocation rate creating frequent minor GC pressure | Identify allocation sites via DevTools → Memory → Allocation instrumentation on timeline |
| Input Latency metric above 100 ms on Interaction track | Synchronous event handler blocking the thread for a full response cycle | Profile with DevTools → Performance → Interaction track enabled; move work off the event handler |
Root Cause: Why the Main Thread Blocks
The browser’s rendering pipeline runs entirely on the main thread: JavaScript execution, style recalculation, layout, paint, and compositing all compete for the same single thread. Chrome schedules frame callbacks every 16.67 ms (at 60 fps). When a JavaScript task runs longer than that budget, the browser cannot commit a new frame before the deadline, producing a dropped frame — the visual stutter users experience as jank.
The Performance Panel flame graph encodes each task as a colour-coded horizontal bar: yellow (Scripting), purple (Rendering), green (Painting), and gray (Idle). Any bar that crosses a frame boundary and causes the next frame to miss its deadline is the primary suspect. Chrome flags tasks longer than 50 ms with a red triangle to make them visible at a glance — these are formally called Long Tasks in the Long Tasks API.
V8’s garbage collector also runs on the main thread during minor GC cycles. If your code allocates objects at a high rate during an animation loop, the GC will fire during the frame window and consume milliseconds that were budgeted for rendering. You can expose these pauses by launching Chrome with --js-flags="--trace-gc" and looking for the GC track in the Performance panel. For a deeper look at how allocation pressure affects GC behaviour, see reading allocation timelines to identify memory leaks.
Understanding which phase consumes the budget — scripting, layout, or GC — determines the correct fix. The flame graph gives you that breakdown at microsecond resolution.
Main Thread Flame Graph — Anatomy
The diagram below shows how a janky frame looks in the Performance panel. A Long Task (the wide yellow bar) overlaps two frame boundaries, causing both frames to drop. The red GC spike occurs mid-task when allocation pressure triggers a minor collection.
Step-by-Step Fix
Step 1 — Prepare the capture environment
Open Chrome with GC trace output enabled (optional for allocation-heavy apps):
# Launch Chrome with GC event logging visible in the Performance panel GC track
chrome --js-flags="--trace-gc"
Open DevTools with F12 (or Ctrl+Shift+I / Cmd+Option+I), then navigate to DevTools → Performance.
Expected output: The Performance panel opens with an empty timeline and a toolbar showing Record, Reload, and Settings icons.
Step 2 — Configure throttling and instrumentation
Click the Settings gear (top-right of the Performance panel) and enable:
Screenshots— correlates visual frames with trace eventsAdvanced paint instrumentation— surfaces compositing and layer boundaries
In the top toolbar, set CPU throttling to 4x slowdown (or 6x for low-end device simulation). Toggle Disable cache ON.
Expected output: The CPU throttling badge appears in the toolbar. A 4x badge confirms the setting is active.
Verification checkpoint: Check that Network: No throttling is also shown — network throttling is a separate control in the Network panel and should stay off unless you are testing network-dependent interactions.
Step 3 — Capture the trace
Click Record (the circle button). Immediately perform the target interaction — scroll a long list, open a modal that hydrates data, or click the button that triggers the heavy computation. Click Stop the moment the UI visually settles.
Keep the recording under 5 seconds. Longer recordings dilute the trace with unrelated background GC cycles and idle callbacks, making it harder to find the janky window.
Expected output: DevTools loads the flame graph. The Summary panel at the bottom shows a breakdown: Scripting Xms, Rendering Xms, Painting Xms, System Xms, Idle Xms.
Step 4 — Identify Long Tasks on the Main thread
Zoom into the interaction window by holding Shift and scrolling vertically on the flame graph, or use Ctrl/Cmd + Scroll. Look at the Main thread track for:
- Red warning triangles in the top-right corner of a task bar — these mark Long Tasks (> 50 ms).
- Wide yellow bars that cross the vertical frame-boundary lines drawn at every 16.67 ms interval.
- Frame drop indicators in the Frames track at the bottom: dropped frames show as red bars with a low FPS value.
Verification checkpoint: A Long Task that spans two or more frame boundaries confirms main thread starvation. Note the start time and duration of the longest task.
Step 5 — Drill into Bottom-Up to surface the hot function
Click the Bottom-Up tab in the lower drawer (DevTools → Performance → Bottom-Up). Sort the Self Time column descending. Self Time isolates the time spent inside a function body itself, stripping wrapper frames — this immediately surfaces the exact method burning CPU cycles rather than the outer caller.
Expected output: The top row shows a function with 30–60 ms of Self Time. The Source column links to the exact file and line number in your code.
Cross-reference with the Call Tree tab (DevTools → Performance → Call Tree) to see the full invocation path from the event handler down to the hot function.
Step 6 — Correlate with the Frames track and Interaction track
The Frames track shows each rendered frame as a green bar (good) or red bar (dropped). The Interaction track (if present) shows Interaction to Next Paint (INP) timing as a horizontal span bar. A Long Task that overlaps the Interaction span is the direct cause of high INP latency.
Expected output: Dropped frame bars visually align with the Long Task in the Main thread track. The Interaction bar confirms the user-perceived delay.
Runnable Code and Command Reference
Before: synchronous heavy computation on every scroll event
// Anti-pattern: heavy work fires synchronously on every scroll event,
// blocking the main thread for 40–70 ms per event.
window.addEventListener('scroll', () => {
const data = heavyDataProcessing(); // blocks for 48 ms on 4x CPU throttle
updateDOM(data); // forced layout follows immediately
});
Performance tab signature: Continuous yellow Scripting bars of 40–70 ms width, overlapping 3–4 frame markers. Red triangles present on every Long Task.
After: decoupled with requestAnimationFrame and a ticking guard
// Fix: schedule the heavy work at the start of the next animation frame.
// The ticking flag coalesces multiple scroll events into one rAF callback,
// preventing queued callbacks from stacking up.
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
const data = heavyDataProcessing(); // now runs inside the rAF budget window
updateDOM(data);
ticking = false; // reset so the next scroll event re-arms the guard
});
ticking = true; // block further rAF registration until this one runs
}
});
Performance tab signature after fix: Scripting blocks shrink to < 8 ms, aligning precisely with green frame boundaries. Red triangles disappear.
Force a GC and capture heap delta (Node.js validation)
// Use this in a Node.js test harness to measure heap impact of the
// scroll handler's allocation pattern before and after the rAF refactor.
// Run with: node --expose-gc heap_delta.js
const before = process.memoryUsage().heapUsed;
global.gc(); // force a full GC cycle to flush unreachable objects
const afterGC = process.memoryUsage().heapUsed;
// Simulate 100 scroll-equivalent processing cycles
for (let i = 0; i < 100; i++) {
heavyDataProcessing(); // the function under test
}
global.gc();
const afterWork = process.memoryUsage().heapUsed;
// Delta should shrink after rAF refactor if allocation rate drops
console.log(`Heap delta: ${((afterWork - afterGC) / 1024).toFixed(1)} KB`);
Verification and Regression Prevention
After applying the fix, re-record with identical CPU throttling and verify these metric targets in the Performance panel:
| Metric | Target After Fix | Where to Read It |
|---|---|---|
| Max Script Self-Time | < 8 ms per frame | DevTools → Performance → Bottom-Up, Self Time column |
| Frame Drop Rate | 0% | Frames track — all bars green |
| GC Pause Duration | < 1.2 ms | GC track (requires --trace-gc flag at launch) |
| Heap Retention Delta | Stable (< +15 MB over 60s scroll) | DevTools → Memory → Heap Snapshot comparison |
| Input Latency (INP) | < 200 ms | Interaction track in Performance panel |
To prevent regression, add a Long Tasks observer to your application’s monitoring layer:
// Paste this into your app's performance monitoring bootstrap.
// Reports any Long Task (> 50 ms) to your analytics pipeline so
// regressions surface before users report them.
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) { // 50 ms is the Long Task threshold
console.warn(`Long Task detected: ${entry.duration.toFixed(1)} ms`, {
startTime: entry.startTime,
attribution: entry.attribution
});
// Replace with your analytics.track() or DataDog/New Relic call
}
}
});
observer.observe({ type: 'longtask', buffered: true }); // buffered:true catches tasks before observer attaches
For CI-level regression prevention, export a Performance trace offline and diff it against a baseline using the approach described in exporting and analysing DevTools performance traces offline.
FAQ
Why does my Performance profile show an empty or flat Main thread?
This occurs when DevTools is not open before recording starts (V8 profiling requires the inspector to be attached), when recording captures only idle time without triggering an interaction, or when using a headless browser without the --enable-automation and inspector flags. Open DevTools first, apply 4x CPU throttling, click Record, then immediately trigger the interaction. The Main thread should immediately show colored bars.
How do I tell layout thrashing apart from script execution jank?
Layout thrashing appears as alternating purple Recalculate Style and Layout bars with high self-time, typically triggered by a loop that reads a layout property (offsetHeight, clientTop, getBoundingClientRect) after making a style write. This forces the browser to flush the pending layout synchronously before returning the value. Script execution jank is a single, wide yellow Scripting block with no interleaved purple bars. The Bottom-Up tab isolates the exact function — check the call stack for DOM property accessors to distinguish the two.
Can the Performance tab profile Web Workers or Service Workers?
Yes. Enable Web Workers in the panel settings gear to add Worker thread tracks to the flame graph. Service Workers appear as a separate named thread track. For dedicated isolation of a single worker, open chrome://inspect/#workers and attach a fresh DevTools session directly to the worker context — this gives you a standalone Performance tab scoped only to that worker’s thread.
Related
- Performance Panel Flame Graph Analysis — parent: flame graph navigation, colour encoding, and call tree interpretation
- Exporting and Analysing DevTools Performance Traces Offline — sibling: automate trace comparison in CI
- Reading Allocation Timelines to Identify Memory Leaks — related: track GC-triggering allocation spikes that contribute to jank
- Browser DevTools & Performance Profiling Workflows — grandparent pillar