Repository navigation
Desktop: scroll sampling forces repeated synchronous layout on long conversations #8291
Replies: 1 comment
|
With a conversation containing hundreds of message rows and thousands of DOM nodes, synchronous layout during scroll can become expensive very quickly. I’d first check whether the scroll handler is repeatedly reading layout information such as offsetHeight, clientHeight, getBoundingClientRect(), or scrollTop after writes to the DOM. A read-after-write pattern inside a high-frequency scroll callback can force the browser to perform layout synchronously. A safer pattern is to batch scroll sampling with requestAnimationFrame() and avoid mixing DOM writes and layout reads in the same callback. For example, conceptually: scroll event I’d also profile a long conversation with Chrome/Chromium Performance tools and look specifically for repeated Layout events originating from the scroll handler. If virtualization is not already being used, that is another important consideration. Rendering thousands of message/tool DOM nodes at once makes every layout/paint operation more expensive. A useful regression test would compare scroll performance for a 20-message conversation versus a 300+ message conversation and verify that layout work doesn’t grow unnecessarily with the total number of off-screen rows. If this clears up the issue, please consider marking the answer as accepted so others who run into the same problem can find the solution more easily. |
Uh oh!
There was an error while loading. Please reload this page.
Desktop: scroll sampling forces repeated synchronous layout on long conversations
Environment
Symptom
In a long conversation, the window janks. It needs both of these at once:
Either one alone is fine. When both happen, the system IME janks in step with
the window — so the composer becomes unresponsive at the same moments.
Cause
The scroll sampler. During a scroll it runs on a 500 ms timer, and each run reads
layout geometry several times in a row.
flushSamplecallsreadScroll(), which callsmetrics(), which calls a freefunction that reads three layout properties:
When the reader is not following the tail,
flushSamplethen callscapturePosition():Eight or more layout reads per call, one inside a loop. On a document with
15,927 nodes and 156,483 px of scroll height in a 706 px viewport — 222 screens,
none of them virtualized — each read is expensive when layout is dirty.
Why the two triggers must combine
Evidence
1. Tracing
devtools.timeline+disabled-by-default-devtools.timeline.stack,ReturnAsStream, 50 s of streaming plus scrolling: 1,755,733 events, 372.6 MB.Renderer main thread, top events by total time:
Layout is 2.2x style recalculation and it is the single largest block. Frame
rate: 1,520
BeginMainFramein 50 s = 30.4 fps.Note for anyone else measuring this:
Performance.getMetricsoverCDPreportsLayoutDurationat roughly 4% here, which reads as negligible andis wrong. The trace disagrees by more than an order of magnitude.
The longest tasks:
2. Style recalculation is normally cheap
elementCountwas present on all 1,592UpdateLayoutTreeevents:Pearson correlation between
elementCountand duration: r = 0.9933.Retracted claim. I first believed that
body[data-*]-prefixed selectors inthe skin system forced every recalculation to be document-wide. They do not.
95.3% of recalcs touch 5-20 elements. Only 14 in 50 s are document-wide, and
those cost 200-290 ms each — so they matter, but they are not the mechanism.
3. A/B on the live document
Each round inserted 60 real nodes into the message list, then immediately forced
layout — reproducing the sampler's situation rather than a synthetic one:
contain: layout paint styleon rowscontent-visibility: auto+contain-intrinsic-sizeon rowsRetracted claim. An earlier version of this test dirtied layout by writing an
attribute no stylesheet referenced, then measured 0.00 ms and concluded the
sampler was cheap. That attribute never invalidated layout, so the reads hit
cache. The numbers above use real node insertion.
4. The IME symptom is queueing, not IME cost
EventDispatchby type:compositionstartfires twice, costs 424 ms, and its children add up to roughly830 ms of main-thread work per keystroke.
keydownaverages 21 ms, 3.5x the6.06 ms frame budget.
The IME is not the cost: the Weasel TSF service runs in the browser process and
consumes 0.031 cores. Chromium's IME path is a synchronous round trip — key to
browser process, browser to renderer, renderer back to browser with the caret
rect and surrounding text — and every leg crosses the busy renderer main thread.
So the IME is a queuer, and also a trigger.
Ruled out by measurement
--use-angle=vulkan: 22.01 ms/frame vs 6.06 ms on D3D11--disable-frame-rate-limit --disable-gpu-vsync: +232%Two of these looked like wins at first and are worth recording as traps.
--use-angle=vulkancut main-thread time by 79% — by rendering 72.5% fewerframes at 3.6x the per-frame cost, which is worse for the user. And
--disable-frame-rate-limitmade things substantially worse rather than better.A main-thread duration without a matching frame count is not evidence.
Suggested fixes
In the sampler.
interleaved with logic that could depend on them.
getBoundingClientRect()from the binary search.Either read the candidate rects once, or cache row offsets and invalidate on
resize.
querySelectorAll— in particular:not(:empty), which needscontent evaluation rather than an attribute test — with a row list maintained
by a
MutationObserver, or withIntersectionObserverstate.ResizeObservercache instead of readingclientHeight/scrollHeighton every sample.elementsFromPointis a hit test: it needs layout and allocates a stack. Ananchor key kept current in the scroll handler would avoid it.
In the message list.
content-visibility: autoon message rows is worth -92% on the forced layout inthe A/B above, and it is a one-property change. The caveat is
contain-intrinsic-size: with 345 rows over 156,483 px the average row is about453 px, so a guessed value has to be in that range or the scrollbar will shift as
rows first render. The
autokeyword makes the browser remember the lastrendered size, so it is only the first pass that can shift.
Longer term. Node count reached 15,927 with no windowing. A virtualized list
would address the unit cost rather than compensate for it.
Reproducing
"<install>/DeepSeek Harness.exe" --remote-debugging-port=9222Then drive CDP from any Node 22+ (global
WebSocket, no dependencies):Performance.getMetricsdeltas on a timerTracing.startwithdevtools.timelineanddisabled-by-default-devtools.timeline.stack,transferMode: ReturnAsStreamDebugger.getScriptSourceon the largestscriptParsedtarget to read theclient bundle's own source
Performance.getMetricsis not enough on its own; see the note in Evidence 1One counter gotcha:
Get-Counteron Windows needs two samples for percentagecounters. A single call returns 0 and reads as "the GPU is idle".
All reactions