#17148·lighthouse

Observed FCP/LCP inflated by whole seconds in headless runs (viz ThrottleUnresponsiveClient BeginFrame throttling) - reproduces on PSI

Author: CikiCreated Jul 28, 2026Updated Sep 14, 2026

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    1

Not 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=1 on the renderer Compositor thread is followed by a BeginFrame within 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 observedLoad has already fired
    • the page is done, waiting for a frame.
  • Frame production is otherwise healthy: BeginFrame every ~7 ms outside the stall (DrawFrame largest 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 (WaitForVSync every ~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=1 posted, 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:

  • ThrottleUndrawnFrames when num_undrawn_frames > kUndrawnFrameLimit
  • can_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 lighthouse and 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:

  1. Run 15-20 Lighthouse iterations (--headless=new, --throttling-method=simulate) against a content-heavy production page.
  2. In audits.metrics.details.items[0], flag runs with observedLoad < observedFirstContentfulPaint && observedFCP == observedLCP.
  3. Flagged runs sit ~1 s or ~2 s above the page's own fast-run FCP, with empty bands between.
  4. For the viz-level view, re-run with --trace-startup=viz,... --trace-startup-format=json and look for the hole in ExternalBeginFrameSource::OnBeginFrame deliveries to the renderer Compositor thread while NeedsBeginFrameChanged needs=1 is pending, and for SendBeginFrameDecision with ThrottleUnresponsiveClient.

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