Performance · Chromium · Viz
Why Viz delays BeginFrame: surfaces, the scheduler and a one-second threshold
Author: Grundy · Checked: October 25, 2026
The previous article located a local wait between activation and frame submission. This follow-up examines a separate Chrome 153 series: after CSS was ready, the renderer still waited roughly half a second before sending a main frame. Traces and matching source code link this wait to BeginFrame delivery, mismatched surfaces and Viz's one-second threshold. This is an investigation, not a website fix or an established explanation for slow Google PageSpeed runs.
1. One paragraph, a separate Chrome version and all 12 loads
We reduced the fixture to 259 bytes of HTML and 91 bytes of CSS: one paragraph in platform Arial, without an application, web fonts, images or animation. The collector injects passive timing observers through CDP. We used Chrome 153.0.8010.36 on Windows x64, headless, a 412 × 823 viewport at DPR 1.75, HTTP/2 and CPU ×1. Each load received a fresh process, profile and private context; the viewport was configured before the remaining CDP setup. Network emulation used one Slow4G rule: 562.5 ms latency, 188,743.68 bytes/s download and 86,400 bytes/s upload, without an additional server-side CSS delay. We retained chrome-launcher 1.2.2 defaults, including disabled RenderDocument; this series has no treatment arm enabling it. Of 12 successful measurements, four waited 513.952–533.284 ms from main-frame deferral clearing to main-frame send; eight waited 0.303–0.974 ms. A setup failure before navigation was retained separately and retried; a separate schema pilot is excluded. All successful trace completions reported dataLossOccurred=false. Expanded tracing can change occurrence, so 4/12 is not an estimate of visitor incidence.
2. The browser expects a new surface; the renderer submits the old one
In all four delayed cases, the browser expected LocalSurfaceId 11 while the renderer kept submitting 9. These identify a surface within a frame sink; FrameSinkId identifies the sink itself. In run 5, the parent surface initially waited for the required child, then its deadline expired. After the last target aggregation, four further old-surface frames were accepted and activated, but their flow IDs were absent from aggregation. The matching Chromium deadline path clears dependencies and activates the parent; activation unregisters the blocked embedder. Consequently, the SendBlockedEmbedded exception need not keep protecting the child from throttling even while the new surface is still absent. Registration state is reconstructed from events and source, rather than read through a debugger. The table gives one run's event order, relative to navigationStart.
| Event | Time from navigationStart, ms |
|---|---|
| Browser expects surface 11 | 623.416 |
| Parent queued pending child | 645.971 |
| Parent deadline expires | 705.409 |
| First undrawn refusal in delivery gap | 755.416 |
| Main-frame deferral clears | 1,237.809 |
| Next target BeginFrame issued | 1,755.390 |
| Target main frame sent | 1,756.994 |
| Surface 11 received by Viz | 1,764.866 |
| Surface 11 aggregated | 1,767.691 |
3. What Viz actually throttles
In ShouldSendBeginFrame, the undrawn-frame check combines the three conditions below. An index difference greater than three is neither a queue length nor a count of unacknowledged submissions: these are monotonically advancing frame indexes. Four accepted frames without aggregation are consistent with this check, but do not replace a direct sample of the internal counters. last_frame_time_ is updated when BeginFrame delivery is permitted, not when CSS finishes loading. The source keeps ticking while delivery to the client is restricted. ThrottleUndrawnFrames is a different branch from the ThrottleUnresponsiveClient named in the Lighthouse issue title.
(frame_time - last_frame_time_ < base::Seconds(1)) &&
(active_frame_index - last_drawn_frame_index_ > 3) &&
active_metadata.may_throttle_if_undrawn_frames4. How the one-second threshold allows another delivery
We compared source frame_time with the previous permitted BeginFrame, rather than callback wall time. In run 5, a refusal was still recorded at a delta of 999,999 µs. The next available tick arrived at 1,016,739 µs: the strict less-than-one-second operand was now false. The other three resumed at 1,000,045–1,000,084 µs. All four positive decisions were SendFrameTiming. Once the undrawn check stops blocking delivery, that branch does not require a new successful presentation: retained feedback may contain only failed presentations. The required surface 11 arrived after BeginFrame delivery resumed. Permitting a send updates last_frame_time_, so this is a periodic opportunity to continue, rather than permanently disabling throttling. The roughly one-second interval starts at the previous delivery; the remaining wait after CSS was about 514–533 ms here.
| Run | Last refusal delta, µs | Resumed delivery delta, µs | Deferral clear to main-frame send, ms |
|---|---|---|---|
| 5 | 999,999 | 1,016,739 | 519.185 |
| 7 | 983,379 | 1,000,071 | 522.903 |
| 8 | 983,414 | 1,000,084 | 513.952 |
| 9 | 983,385 | 1,000,045 | 533.284 |
Source: published Viz report and complete traces in Lighthouse #17148. These are native fixture timings at CPU ×1, not PageSpeed LCP values.
5. Why the ready renderer does not send a main frame
Immediately after CSS deferral clears, the scheduler exports the state below: work is needed, but both main and impl remain IDLE and next_action is NONE. The one-main-frame-per-interval guard does not explain the wait because did_send is already false. The matching source guard is ShouldBlockBeginMainFrameWhenIdle() && !urgent_begin_main_frame_pending_. For an asynchronous compositor, the first part checks the impl frame's IDLE state. The next delivered BeginFrame changes it to INSIDE_BEGIN_FRAME and immediately selects SEND_BEGIN_MAIN_FRAME. In run 5, BeginFrame arrives at 1756.325 ms, the action is selected at 1756.940 ms, and the main frame is sent at 1756.994 ms. Urgent, pause_rendering and the synchronous-compositor setting are not exported in these snapshots: their state is reconstructed from initial values and setter paths traced from before page creation. No native breakpoint was placed on the guard's return.
needs_begin_main_frame = true
defer_begin_main_frame = false
did_send_begin_main_frame_for_current_frame = false
has_pending_tree = false
pending_submit_frames = 0
begin_main_frame_state = IDLE
begin_impl_frame_state = IDLE
next_action = NONE6. What is established and what still needs checking
Allowed deliveries are joined to the target renderer by exact flow IDs; frame submissions identify its unique FrameSinkId. Refusal records omit sink ID, so each refusal cannot be assigned to our surface by timestamp proximity alone. Internal active_frame_index, last_drawn_frame_index and may_throttle_if_undrawn_frames were not sampled directly; MarkAsDrawn also has a copy-output path, so aggregation cannot simply stand in for its counter. However, the observed source-time delta at resumed delivery alone makes the time operand false, regardless of the unknown exact indexes. The original Chrome 155 series found a post-activation wait; this Chrome 153 series finds a pre-main-frame-send wait. The samples must not be pooled. We have no full trace of a slow Google PSI run, and matching its browser version does not establish matching internal configuration.
7. A report, five full traces and a request to maintainers
The follow-up is published in Lighthouse #17148. It attaches the evidence archive and five complete sanitized traces: four delayed loads and one fast control. The report retains collection settings, derived event chains, source references and checksums. Personal paths and the fixture hostname were replaced in the trace streams; numbers and identifiers are preserved and events are not cropped. Run 9 uses a ZIP with LZMA compression: its extracted entries are byte-identical to the original Deflate archive; 7-Zip or Python zipfile can extract it. We asked maintainers to examine the parent-deadline/child-throttling transition and export FrameSinkId, both indexes, the metadata flag, source and last-sent times, blocked-embedder state and the positive exception used. Those fields would allow direct checks of the remaining reconstructed state. No production workaround was deployed on the strength of these results.