GoogleChrome / GoogleChrome/lighthouse

Lighthouse Punishes Good Asset Preloading

Open
#16,539 23 comments 119 reactions 0 assignees View on GitHub
needs-priority
Dominant language
JavaScript
Stars
30.8k
Forks
9.8k
Avg merge
1d 20h
Merged PRs (30d)
19

Description

Imagine you have a page with fast real-world FCP and LCP. A page like [this one](https://entire-darlings-348663.framer.app/page-with-instant-lcp):

![Image](https://github.com/user-attachments/assets/8f16a8f5-304f-4f66-8369-578683e879af)

It doesn’t have any render-blocking resources. Its only contentful element above the fold is a text block (which uses system fonts). Its real-world loading speed metrics (FCP and LCP) are around 1.0-1.1s.

Now, run this page [through Lighthouse](https://pagespeed.web.dev/analysis/https-entire-darlings-348663-framer-app-page-with-instant-lcp/386k6pjja5?form_factor=mobile):

![Image](https://github.com/user-attachments/assets/cc36cd13-07bb-4cd0-8694-533e752bc7f7)

Suddenly:

- the FCP of the page is yellow or red
- the LCP of the page is much higher than FCP, even though the FCP and the LCP element *is the same*

Can you guess why?

### Simulated Throttling

Lighthouse (and, therefore, PageSpeed Insights), by default, [uses simulated throttling](https://github.com/GoogleChrome/lighthouse/blob/main/docs/throttling.md). The idea behind it [is simple](https://calendar.perfplanet.com/2021/how-does-lighthouse-simulated-throttling-work/): instead of actually throttling the network while loading the page (which is slow), let’s load the page on a fast connection → look at the network graph and real FCP/LCP → *simulate* how the network graph would behave on a slow connection → derive slow FCP/LCP from that graph.

The challenge? That last step is far from perfect. For example:

- To simulate slow LCP, Lighthouse looks at all requests that happened before real LCP – and assumes that [LCP requires *all* of them](https://source.chromium.org/chromium/chromium/src/+/main:out/mac-Debug/gen/third_party/devtools-frontend/src/front_end/models/trace/lantern/metrics/LargestContentfulPaint.js;l=27-48?q=getEstimateFromSimulation&ss=chromium):

![Image](https://github.com/user-attachments/assets/1b34a4fe-15e5-4104-bb67-eab4e9884ecb)

This causes a bunch of issues.

- Preloaded a font? Well, now simulated LCP will be delayed by that font load time, even if the LCP element is actually an image.
- Fetched a tiny, non-blocking script that just so happened to load before real LCP? Too bad, now that script will delay simulated LCP as well.
- To simulate slow FCP, Lighthouse [does the same thing](https://source.chromium.org/chromium/chromium/src/+/main:out/mac-Debug/gen/third_party/devtools-frontend/src/front_end/models/trace/lantern/metrics/FirstContentfulPaint.js;l=95-109). (The code path is literally the same!) The only difference is that for FCP, nodes with a low fetch priority are ignored. This means offscreen images and non-blocking scripts won’t delay FCP (yay!), but `@font-face` requests (meh) or `modulepreload`s (eh?) still will.

### This Punishes (Good) Preloading

_(and, in general, any kind of good early-loaded assets)_

Framer is a no-code website platform that deeply cares about performance. At Framer sites, we try to help the browser load critical resources ASAP. To do this, we:

- emit ``s for all JS modules that the current page needs
- inline the page CSS, including `@font-face`s, straight into the HTML

These optimizations directly improve real-world performance, making the site visible and interactive sooner. These optimizations also dramatically worsen the Lighthouse score.

Demo

Above, you saw a loading trace of a demo page ([URL](https://entire-darlings-348663.framer.app/page-with-instant-lcp)). For this trace, `lighthouse -A` computes the following FCP and LCP:

![Image](https://github.com/user-attachments/assets/8d12db53-b232-464e-9920-f446e5465ed0)

Now, here’s the same page, modified to not have any `` elements ([URL](https://framer-cloudflare-proxy.iamakulov.workers.dev/page-with-instant-lcp?host=entire-darlings-348663.framer.app&remove-elements=%5Brel%3Dmodulepreload%5D)):

![Image](https://github.com/user-attachments/assets/6a8fecbb-5b11-4c7d-847f-fba9542bdfeb)

We removed ``s, so:

- the real-world LCP is still ~same (200-300 ms) because ``s don’t affect it
- website hydrates much later (at ~2000 ms instead of ~500 ms), making the real user experience worse

However, `lighthouse -A` now simulates a much better LCP:

![Image](https://github.com/user-attachments/assets/41fc0ffb-4953-4f0d-8031-cab7316623b6)

### This is Bad

This is bad because it creates bad incentives:

- Developers are forced to pick between doing deep research (and convincing stakeholders that Lighthouse isn’t accurate) – or making the site *slower for real users* just to get the score higher
- Companies (web platforms like Framer, agencies, etc.) are forced to pick between shipping fast sites – or retaining the business of customers who look at PageSpeed Insights scores

### This is Serious

In Nov 2024, Google Amsterdam hosted [WebPerfDays Unconference](https://x.com/HenriHelvetica/status/1854279159247524204), an informal discussion event for Googlers, GDEs, and web perf specialists. At the event, the mismatch between Lighthouse and Core Web Vitals was (to my memory) one of the most-discussed points.

See also other people being struck by this issue: WebPerf Slack [one](https://webperformance.slack.com/archives/C04BK7K1X/p1742922067789419), [two](https://webperformance.slack.com/archives/C04BK7K1X/p1676553293876909), https://github.com/GoogleChrome/lighthouse/issues/11460

### Solutions

There are easy solutions, and there are hard ones.

- *Easy solution 1:* Fine-tune the simulation algo.
- Ignore `modulepreload`s when computing FCP and LCP
- Ignore fonts when computing FCP and LCP if 1) the FCP/LCP element is not text, or 2) the FCP/LCP element is text that uses a system font, or 3) `@font-face` uses `font-display: swap` [or similar]

At least some of those changes would have to be upstreamed to `@paulirish/trace_engine` which is not on GitHub.

*This will reduce the punishment that Lighthouse gives to early requests.*

- *Easy solution 2:* For simulated FCP and LCP, pick `min(simulated FCP value, simulated LCP value)` when 1) their real-world values are the same, and 2) they were triggered by the same element.

*This will avoid an artificial mismatch between FCP and LCP when they are actually the same in the real world.*

I would be happy to contribute these changes if you’re open to accepting them.

The harder solution would be to get smarter about figuring out what assets are *actually* render-blocking. We might have to look at 1) where a script is positioned in the document, 2) whether a script applies an anti-flicker effect, etc. This is harder and much less defined, but perhaps this issue could be a start of a discussion.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.