RUM finds the slow segment; WebPageTest shows the symptoms. To find the line of code at fault, you record a trace in the Chrome DevTools Performance panel. It looks intimidating, but it's really just a few linked views — and once you can read the flame graph, you can catch the classic killer: a forced reflow.
Orientation
The panel's linked views
Don't be scared by the wall of color. The Performance panel is a stack of time-aligned tracks — scroll/zoom one and they all move together:
Summary — where the time went: scripting, rendering, painting, idle.
CPU / Frames — frame rate over time; red means dropped frames.
Interactions — each click/tap split into input delay, processing, presentation (your INP breakdown).
Main thread — the flame graph: every task and its call stack. The most useful track by far.
The colour convention
Throughout the Performance panel, red means “problem here” — a long task, a dropped frame, a forced layout. Hunt the red.
The key skill
Reading the main-thread flame graph
The flame graph reads top-down: a task at the top calls functions below it, each bar a call in the stack. Wider = longer. A task over 50 ms gets flagged as a long task (the red corner). Click into the stack to find who's eating the time — and hover any frame to see whether it's scripting or rendering:
InteractiveA 93 ms long task, drilled into
46%
42%
🔴 Long task · 93ms
Layout (Forced) ⚠Rendering36 ms
Forced synchronous layout — the smoking gun. Reading offsetTop after a write forced this.
Scripting Rendering Painting System
Task → Animation Frame Fired → app.update() → Recalculate Style + Layout (Forced). The red-bordered 'Layout (Forced)' is the real culprit.
The smoking gun
The classic bug: forced reflow
That “Layout (Forced)” entry is the most common surprise in real traces — layout thrashing. It happens when you write a style and then immediately read a layout property (like offsetTop, offsetHeight, getBoundingClientRect()) in the same loop. The read can't be answered until layout is up to date, so the browser is forced to re-run layout synchronously — once per iteration. Toggle the fix:
InteractiveRead-after-write forces a layout every iteration
for (const img of images) {
img.style.top = top + "px"; // WRITE
top = img.offsetTop + 3; // READ → forced synchronous layout!
}
Reading offsetTop right after writing top forces the browser to recalculate layout immediately — for every image, every frame. That's the long task you saw in the flame graph.
Naïve: write → read → forced layout, ×N. Optimized: compute from a local variable, write only, and let the browser lay out once. Same logic, no jank.
The rule of thumb
Batch your reads and writes. Read all the layout values you need first, then do all your writes — never interleave them in a loop. Or, as in the demo, compute from a local variable so you never read the DOM back at all.
Don't take the diagram's word for it — benchmark it on the device you're holding. Both buttons below restyle 360 real DOM elements; one forces 360 synchronous layouts, the other forces exactly one. The numbers come from performance.now(), live:
InteractiveBenchmark it on your machine — 360 real elements
↑ 360 real DOM elements. Both buttons restyle every one of them — the only difference is when the layout reads happen.
Thrashing
—
Batched
—
Speedup
run both
These numbers are measured live on your device — run them a few times and on a phone if you can. Same writes, same final layout; the thrashing version just asks “how wide is it now?” after every single write, and each answer costs a full synchronous layout pass.
Same writes, same final layout. The thrashing version just asks 'how wide is it now?' after every write — and each answer costs a full layout pass. Expect a 10–100× gap.
In practice
How to record a trace
Two ways to record
Record (then interact, then stop) captures a specific interaction — perfect for debugging a janky scroll or click. Start profiling and reload captures the whole page load from the first byte, and adds a Timings track with DCL, FP, FCP, and LCP marked.
Reproduce your users' conditions
Before recording, set CPU throttling (4×/6× slower to mimic a cheap phone) and network throttling (Slow 3G). A bug that's invisible on your M-series laptop is glaring at 6× CPU — which is exactly what your real users feel. This is how you turn the RUM finding (“mobile users in India are slow”) into a reproducible trace.
Q1Multiple choice
Scanning a Performance recording, where do you start your investigation?
Q2Multiple choice
Inside a loop you set el.style.width then read el.offsetHeight, for 200 elements. Why does this run ~200 layouts instead of one?
Q3Multiple choice
A teammate 'fixes' a layout-thrashing loop by wrapping each read in requestAnimationFrame. It's still slow. Why?
Q4Multiple choice
RUM says the slow segment is mid-tier Android on Slow 3G, but the bug won't show on your laptop. Most faithful repro in DevTools?
Q5Sort each scenario
Does each operation force a synchronous layout (reflow)? Watch for reads of layout properties after a write.
Reading el.offsetWidth right after changing a style
Calling el.getBoundingClientRect() after toggling a class
Setting el.style.transform (write only, no read back)
Reading a width you cached in a local variable earlier
Reading el.clientHeight inside a loop that also writes styles
Key takeaways
→The Performance panel is time-aligned tracks: Summary, CPU/Frames, Interactions, Main thread.
→The flame graph shows tasks + call stacks; wider = longer; red = problem (long task).
→Forced reflow = reading a layout prop right after a write — the classic jank bug.
→Fix it by batching reads and writes (or caching in a local variable).
→Record with CPU + network throttling to reproduce real users' conditions.