GoogleChrome / GoogleChrome/lighthouse
Observed FCP/LCP inflated by whole seconds in headless runs (viz ThrottleUnresponsiveClient BeginFrame throttling) - reproduces on PSI
- Dominant language
- JavaScript
- Stars
- 30.8k
- Forks
- 9.8k
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 19
Description
## 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](https://github.com/user-attachments/files/30456179/lighthouse-17148-traces.zip)
Contributor guide
Research direction
Start with components/viz/service/frame_sinks/compositor_frame_sink_support.cc and CompositorFrameSinkSupport::ShouldSendBeginFrame. Reproduce with 15-20 Lighthouse --headless=new iterations, then inspect observedLoad, observedFirstContentfulPaint, observedFCP, and observedLCP; use --trace-startup=viz,... to confirm pending NeedsBeginFrameChanged and ThrottleUnresponsiveClient decisions. Done means the cold-load stall is prevented or reliably identified without inflating reported paint metrics.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- devtools, performance
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 38/100