Observed FCP/LCP inflated by whole seconds in headless runs (viz ThrottleUnresponsiveClient BeginFrame throttling) - reproduces on PSI
Summary
In headless Lighthouse runs - including on PageSpeed Insights' own infrastructure - observed
FCP/LCP intermittently comes out one or two whole seconds later than the page actually became
ready to paint. The extra seconds are added by viz: CompositorFrameSinkSupport::ShouldSendBeginFrame
stops offering BeginFrames to the page's frame sink during the load (recorded reason:
ThrottleUnresponsiveClient, should_send: false), while the renderer's SetNeedsBeginFrame(true)
request is pending. The first contentful commit then waits up to ~1-2 s for the next allowed
BeginFrame. Lantern amplifies the inflated observed FCP into LCP figures around 11 s.
The result is that lab measurements (Lighthouse CLI and PSI) report catastrophic paint metrics for pages that paint in well under a second for real users. The distribution is bimodal with empty bands between the modes, which also explains a chunk of commonly-reported PSI run-to-run variance.
Statistical evidence (PSI, Google infrastructure)
246 unique PSI mobile runs across 13 targets (4 stacks - PrestaShop, WordPress, Nette, static
HTML; 3 unrelated servers; page weights 18-250 KB). Restricting to the 145 runs where the page
finished loading before it painted (observedLoad < observedFirstContentfulPaint, which no
normal load produces), observed FCP falls into bands with nothing in between:
0- 249 ms 1 1750-2249 ms - empty -
250- 499 ms 11 2250-2499 ms 54
500-1249 ms - empty - 2500-2749 ms 52
1250-1499 ms 24 2750-3499 ms - empty -
1500-1749 ms 1 3500-3749 ms 1Not one of 145 runs lands between 500-1250 ms or 1750-2250 ms. The quanta are added to each page's own paint time (a page that paints at ~240 ms stalls to ~1300/~2330; one that paints at ~640 stalls to ~2450-2600). Rate varies by page (0-85 % of runs), the quantum does not. 10 of 12 sites measured with n ≥ 8 were affected, including a 19 KB static HTML page.
Affected runs additionally show observedFCP == observedLCP to the millisecond (everything
appears in one frame) and PSI's own filmstrip (screenshot-thumbnails) shows a plain white frame
at 1875 ms on a run whose fast siblings are fully rendered by 1125 ms - the frame genuinely was
not produced.
Ruled out by measurement: the site (12-site matrix), the server (foreign sites hit it more often
than ours: nay.sk, martinus.sk), hardware/GPU (PSI runs on Google's Linux/HeadlessChrome 149,
local repro on Windows 11/AMD/HeadlessChrome 150), IPv6, fonts (font-display: swap everywhere),
render-blocking CSS (survives a variant where all CSS is loaded non-blocking), and
occlusion/backgrounding throttling - chrome-launcher already passes
--disable-backgrounding-occluded-windows --disable-renderer-backgrounding --disable-background-timer-throttling in every run.
Not reproducible in interactive headful Chrome on the same pages (first paint ~708 ms, consistently, across many manual runs).
Trace evidence 1 - Lighthouse traces (devtools.timeline.frame)
Delayed runs vs fast runs of the same page (amperservis.sk, www.martinus.sk):
- Fast run: every
NeedsBeginFrameChanged needs=1on the renderer Compositor thread is followed by aBeginFramewithin 0.4-6.4 ms (7 consecutive resubscribes checked). - Delayed runs: the request pends 930 ms / 947 ms / 924 ms before the first BeginFrame arrives. In one trace the withheld BeginFrames arrive at exactly +1000.1 ms and +1007 ms after the last delivered one (144 Hz display ⇒ 1000 ms + one tick).
- During the silence the renderer main thread is ~97 % idle and
observedLoadhas already fired- the page is done, waiting for a frame.
- Frame production is otherwise healthy:
BeginFrameevery ~7 ms outside the stall (DrawFramelargest gap 2015 ms in the delayed run vs 140 ms in the fast run).
Trace evidence 2 - viz-category traces (the actual decisions)
Captured with --trace-startup=viz,cc,gpu,loading,blink.user_timing,disabled-by-default-devtools.timeline.frame
on plain chrome --headless=new loads of a production page (4 stalled loads caught; daytime
incidence was 3 catches in 3 attempts):
The GPU-process vsync source runs throughout (
WaitForVSyncevery ~7 ms) and the browser's frame sink keeps receiving (SendDefault/SendFrameTiming).The page's sink receives a handful of BeginFrames (
reason: SendNoActiveSurface- fresh surface after navigation, nothing submitted yet), then deliveries stop:trace deliveries before the hole hole in deliveries pending-request wait observed FCP STALL-1 3 (357-368 ms) 1620 ms (368 → 1988) 749 ms 2014 ms STALL-18 2 (251-259 ms) 733 ms (259 → 992) 742 ms 1046 ms STALL-2 28 (249-1508 ms) 782 ms (1508 → 2290) 717 ms 1049 ms STALL-3 32 (215-2026 ms) 1080 ms (2026 → 3106) 873 ms 1143 ms In STALL-1 the decision is recorded explicitly at 423.9 ms:
SendBeginFrameDecision {"reason": "ThrottleUnresponsiveClient", "should_send": false}- after which no evaluations happen for the sink at all until deliveries resume. Note the page had never painted yet; it is a client still parsing its document, not an unresponsive one.
Three measurements, three different numbers - how they relate
The throttle gate sets a period: at most one BeginFrame per second per sink
(< base::Seconds(1) ⇒ don't send), and delivery aligns to the next vsync tick.
- Full period is visible only when a sink stays throttled across two consecutive allowed sends: in the Lighthouse trace of amperservis.sk the withheld BeginFrames arrive at exactly +1000.1 ms and +1007 ms (1000 ms + one 144 Hz tick) - and the page missing the first allowed frame's deadline is what produces the "+2 s" cluster (2 × 1007 ≈ the observed 2015 ms DrawFrame gap).
- Pending-request wait (
needs=1posted, no BeginFrame processed) is the remainder of the currently closed window, so it scatters below one period: 717-930 ms across all catches. - Hole in deliveries additionally includes stretches where the client had nothing to commit
and was not requesting (
needs=0) - verified in STALL-1, where the renderer stopped requesting at 374 ms and requested again only near the resume. Holes therefore range 733-1620 ms and are not clean multiples of the period; the harmful part is the pending-request wait.
Where this comes from (source)
components/viz/service/frame_sinks/compositor_frame_sink_support.cc,
CompositorFrameSinkSupport::ShouldSendBeginFrame:
ThrottleUndrawnFrameswhennum_undrawn_frames > kUndrawnFrameLimitcan_throttle_if_unresponsive_or_excessive = frame_time - last_frame_time_ < base::Seconds(1);
i.e. a sink classified as unresponsive/excessive is offered at most ~one BeginFrame per second - which matches the whole-second quanta measured above. During a cold page load the page's sink naturally looks "unresponsive" (BeginFrames delivered while the document is still being parsed produce nothing), so the very first contentful commit lands in the penalty window and waits.
Environments
- PSI: HeadlessChrome/149.0.7827.155, X11 Linux (benchmarkIndex ≈ 644) - Google infrastructure
- Local: HeadlessChrome/150 via
npx lighthouseand plain--headless=new, Windows 11, AMD Radeon 860M, 144 Hz display (benchmarkIndex ≈ 2347) - Not reproducible in interactive (headful, user-attended) Chrome on the same pages
Reproduction
No minimal synthetic reproducer found so far - a page that merely idles blank and injects content late always paints at readiness +40 ms; no-op rAF streaks (pure DidNotProduceFrame), a blocked main thread, image storms and iframes do not trigger it either. The jailing state appears tied to surface-embedding / frame-timing conditions of real cold loads.
Statistical reproduction is straightforward:
- Run 15-20 Lighthouse iterations (
--headless=new,--throttling-method=simulate) against a content-heavy production page. - In
audits.metrics.details.items[0], flag runs withobservedLoad < observedFirstContentfulPaint && observedFCP == observedLCP. - Flagged runs sit ~1 s or ~2 s above the page's own fast-run FCP, with empty bands between.
- For the viz-level view, re-run with
--trace-startup=viz,... --trace-startup-format=jsonand look for the hole inExternalBeginFrameSource::OnBeginFramedeliveries to the renderer Compositor thread whileNeedsBeginFrameChanged needs=1is pending, and forSendBeginFrameDecisionwithThrottleUnresponsiveClient.
Trace files available: 4 viz-category traces of stalled loads (incl. the explicit
ThrottleUnresponsiveClient, should_send: false record), plus Lighthouse trace pairs
(delayed vs fast) for two unrelated sites.
Impact / suggestion
Any page whose first contentful commit lands after the sink has been classified throttleable gets +1-2 s added to observed FCP in lab tools only - and Lantern then reports LCP around 11 s on PSI for pages that paint at ~0.7 s for real users. Site owners cannot fix this on their side (we spent a day ruling out every site/server-side hypothesis before tracing it into viz).
Possible directions: exempt sinks with a pending SetNeedsBeginFrame(true) and no
first-activated-surface from the unresponsive classification; or reset the throttle when a
pending-needs client submits its first CompositorFrame; or have Lighthouse detect/flag the
signature (observedLoad < observedFCP && FCP == LCP) so affected runs are not reported as page
performance.
Attachments
Attaching the trace bundle referenced in the report - README inside maps each file to the tables lighthouse-17148-traces.zip
Source: GoogleChrome/lighthouse