LCP varies wildly across runs with no code difference

I’m trying to understand why a particular page on our site seems to have a LCP that varies wildly:

This test has 3 runs from Paris/4G. It’s using a script that sets a cookie to avoid an A/B test we are performing client-side at the moment that may otherwise skew the results.

The 3rd run behaves like I’d expect : We get the FCP around 2.5s, and LCP at 2.7s once text is rendered. The LCP element is the first paragraph of text, and it only depends on the HTML, the CSS and a subset font (which is preloaded). Meanwhile JavaScript is downloaded and eventually executes, some images are downloaded, but that doesn’t change anything.

On the first 2 runs however, the LCP is 3.5 and 3.8 seconds respectively. The first run is not served from cache and we have issues with flushing that delays an important preconnect, so I’m going to focus on the second one, where the LCP is at 3.5s. That’s quite late, as if it had waited on the JavaScript execution to be finished. The LCP element is still the same paragraph, but in those runs it appears twice: once around the time of the first run, but with somehow a slightly smaller size than expected, and the other at the 3.5s mark, with the correct size. Looking at the filmstrip closely it appears that this is caused by the text being rendered without the preloaded font first (despite it seemingly being downloaded in time), and then swapping with the correct one… but very late. Something that doesn’t happen with the “good” run. Note that there is no font-display: swap on our subset, only on the full, larger font that the rest of the page needs.

How can I avoid this ? I understand why, once JavaScript is executing, the browser might delay rendering and that might delay the LCP. But I’m preloading the font, shouldn’t it appear early enough ? Would adding a defer attribute to the help ?

(I’m aware that fixing the site to serve a leaner HTML, faster, over a single domain under a CDN would help a lot in general. I’m working on this, but it’s going to take a lot of time, so I’m working on fixing all the little details I can in the meantime)

1 Like

It very much feels like there is something racy going on with the layout/render and the fonts. The same paragraph triggers all of the LCP candidate events.

In the fast case (run 3) there is only a single candidate.

In the slow case (run 2) there is an initial candidate at 2.5s and then another one at 3.8 seconds that is slightly larger and becomes the LCP.

It feels like it’s racy on if the font happened to be completely ready at the first layout or not.

One option to make it more deterministic from a metric perspective would be if the fallback font was larger than the real font instead of smaller (by the tiniest of margins) so that when the font is swapped, the new paint isn’t larger than the fallback one.

1 Like

I’m not sure I understand the race condition though: Chrome’s font-display: auto should be equivalent to font-display: block. If the font was not ready, no text should be displayed between 2.5s and 3.8s, right?

1 Like

Depending on the unicode ranges involved, the 2nd font looks like it is set up to use swap, not auto/block:

@font-face {
    font-display: swap;
    font-family: Inter;
    font-style: normal;
    font-weight: 300 600;
    src: url(https://addons-amo.cdn.mozilla.net/static/1b326f3663dc37ac5a83cf3f9fe30934.woff2) format("woff2");
    unicode-range: U+20-7e,U+a0-148,U+14a-1c3,U+1c5-31e,U+321-362,U+370-377,U+37a-37f,U+384-38a,U+38c,U+38e-3a1,U+3a3-3e1,U+3f0-49d,U+4a0-4ff,U+52f,U+1d43,U+1d47-1d49,U+1d4d,U+1d4f-1d50,U+1d52,U+1d56-1d58,U+1d5b,U+1d62-1d65,U+1d9c,U+1da0,U+1dbb,U+1dbf,U+1e00-1f15,U+1f18-1f1d,U+1f20-1f45,U+1f48-1f4d,U+1f50-1f57,U+1f59,U+1f5b,U+1f5d,U+1f5f-1f7d,U+1f80-1fb4,U+1fb6-1fc4,U+1fc6-1fd3,U+1fd6-1fdb,U+1fdd-1fef,U+1ff2-1ff4,U+1ff6-1ffe,U+2000-200b,U+2010-2027,U+202f-205f,U+2080,U+20a0-20bf,U+2100-2101,U+2103,U+2105-2106,U+2109,U+2113,U+2116-2117,U+211e-2123,U+2126,U+212a-212b,U+212e,U+2132,U+213b,U+214d,U+214f,U+2153,U+215a-215f,U+2190-2193,U+2202,U+2205-2206,U+220f,U+2211-2212,U+221a,U+221e,U+222b,U+2248,U+2260,U+2264-2265,U+2303,U+2305,U+2318,U+2325-2327,U+232b,U+2380,U+2387,U+238b,U+23ce-23cf,U+2423,U+2600,U+2605-2606,U+263c,U+2661,U+2665,U+26a0,U+2713,U+2717,U+27ef,U+27f5-27fa,U+2b06,U+2b12-2b13,U+2b1c,U+2c7c,U+2c7f,U+2dff,U+2e18,U+a69f,U+a7ff,U+a92e,U+e000,U+e002-e080,U+e093-e096,U+e0a5-e0e6,U+e0f3-e11c,U+e11e-e164,U+ee01,U+f12f-f149,U+f16a-f16b,U+f6c3,U+f850,U+f852,U+fe20-fe2d,U+feff
}

@font-face {
    font-family: Inter;
    font-style: normal;
    font-weight: 300 600;
    src: url(https://addons-amo.cdn.mozilla.net/static/Inter-roman-subset-en_de_fr_ru_es_pt_pl_it.var.9dec95cbd8ab85dca039a58573254d87.woff2) format("woff2");
    unicode-range: U+20-7e,U+a0-a1,U+a3,U+a7,U+a9,U+ab,U+ae,U+b0-b5,U+bb,U+bf-c4,U+c6-cf,U+d1-d7,U+d9-dc,U+df-e4,U+e6-ef,U+f1-f7,U+f9-fd,U+ff,U+104-107,U+10d,U+118-119,U+11b,U+141-144,U+152-153,U+159-15b,U+161,U+178-17c,U+17e,U+401,U+410-44f,U+451,U+2010-2015,U+2018-201a,U+201c-201e,U+2024,U+2026,U+202f,U+2032-2033,U+2080,U+20ac,U+2122,U+2192,U+2605,U+2665
}
2 Likes

That said, I’m just guessing. Run 2 clearly displays the text at 2.5s but “something” causes it to re-paint that same paragraph at a slightly larger size again at 3.8s.

1 Like

The full font is a red herring on that page. The same kind of racy LCP happens on other pages that don’t request the full font: WebPageTest - Running web page performance and optimization tests...

1 Like

WebPageTest - Visual Comparison is a great example. 2.7s: paint without text, 2.8s: paint with text and LCP candidate, 3.8s: no obvious visual change but the final LCP.

2 Likes

The full font is barely used on that page. In any case the subset is definitely enough for the first paragraph.

1 Like

I don’t know at this point. It feels a lot like a Chrome bug of some kind. Do you know if the script is doing something like rehydrating a SSR page? There is a callback for componentDidLoad os something like that while the script was running.

This feels a LOT like a LCP issue I saw recently with AMP where it was racy but the AMP page would load an image, then the layout would shift slightly, moving more of the image into the viewport and then the AMP component would rehydrate the amp-image element, replacing the image in-place with the same DOM node which would trigger a new paint and since slightly more was in the viewport, the new paint would be “larger”.

Looking at the raw video from run #5 in this test and scrubbing the frames, it looks like the text paints with a slightly smaller font (fallback?) and then almost immediately applies the webfont which makes the text area larger but the LCP doesn’t register until quite a bit later after the JS has run.

It feels a lot like what is happening is a similar rehydration problem (just a guess):

  • SSR initially renders with the fallback font, triggering the first LCP candidate
  • Webfont loads, causing the text area to be slightly larger but not triggering a new candidate since it’s not the initial paint for the content (just a layout shift)
  • JS runs and rehydrates the page, causing “new” (identical) elements to render exactly where they were before. Since the area is bigger now and it’s the “initial” paint of this new text node, it counts as a new LCP candidate.

It’s racy because if the initial paint doesn’t use the fallback font then the initial paint will be with the webfont and will be the correct size so the rehydrated paint won’t be larger and won’t count as a new candidate.

For AMP, the solution was to not re-attach the same DOM node when rehydrating and just leave everything in place if it was SSR’d.

1 Like

Yeah, looking at the raw JSON for the LCP candidates, the DOM node ID is different for the 2 text paint candidates (for the same content).

This looks like rehydration issue where the initial SSR painted with the fallback font and the rehydrated version painted with the webfont (which was larger), making the LCP report later.

1 Like

The question is, why is it using a fallback font ? In all test runs I’ve seen, the subset font was downloaded quite early (which makes sense since it’s preloaded), so I’m surprised the browser would choose to use a fallback font.

(edit: as I mentioned above, the subset font should be enough for the LCP text on most of our pages, including the ones linked, and does not have font-display: swap)

1 Like

It used the subset font but it looks like it took long enough to load that Chrome rendered initially with the system font and quickly re-painted with the subset webfont. The extended version of the webfont loaded much later and isn’t involved at all (looks like the time for “auto” isn’t all that long, at least in this case).

2 Likes

I don’t understand the Chrome font code well enough to be authoritative but it looks like the “blocking” timeout in auto is adaptive based on the connection type and there is an intervention to fast-fallback to the system font on a “slow” connection.

It looks like if you change it explicitly to “block” then the intervention won’t kick in and it would have the behavior you expect.

2 Likes

How fascinating! I’ve learned that LCP is based on the first contentful paint of an element. I’ve learned to look at largestPaints and domNodeId in the raw JSON.

The intervention suggests it is only for 3G or slower networks. The tests are configured to simulate 4G but maybe Chrome’s heuristics classify this as 3G. Is there any way to confirm that by looking at the devtools warning in WPT?

Developer tools shows a warning message when the intervention is triggered for each font.

1 Like

Yeah, the effective connection type stuff is based on observed latency so it can be hit-or-miss. We exposed the final LCP candidate details on the web vitals page - looks like we may also have to bubble-up the candidate events out of the json. We’re also considering capturing the calculated font-family for any text LCP candidates.

3 Likes

Here is the crbug tracking the issue on the Chrome side: 1213220 - chromium - An open-source project to help move the web forward. - Monorail

3 Likes

I forgot to reply here, thanks for all your answers and detective work! I’ve set our preloaded subset font to be explicitly blocking, and that seems to have done the trick as you suspected.

2 Likes

Hello,

I hope you don’t mind me adding a few comments. I am not so sure LCP is based on the first contentful paint, and I’ve noticed such extreme fluctuations about it, that I am a bit concerned that LCP is considered a “core web vital”.

Below, you will see a comparison of four tests for the same page. The first two and the remaining two are less than 24 hours apart. There is a small change for the two last ones (an image that appears halfway down the page, was reduced a bit in size - but this doesn’t have much effect since it is loaded from Cloudinary, and it only affects the complete loading time and page size).

Now, the LCP in each of the four instances is wildly different:
1st test: LCP appears in the end (the very last thumbnail) ignoring completely any images loaded in the viewport.
2nd test: LCP appears as early as the 4th thumbnail with the appearance of just one image.
3rd test: LCP appears again in the end, although the first paint shows up in almost the same time as the 2nd test (only 0.1 sec difference)
4rth test: LCP shows somewhere in the middle, with the appearance of two main viewport images - this time behaving as you would expect it.

Can this metric really be considered reliable?

1 Like

LCP isn’t FCP - they are completely different metrics. LCP is when the largest piece of visible content paints (and in the case of an image, when the image has also fully loaded). In the 4 tests above, it reliably fires every time when the sundial/clock/whatever paints which is what is determined to be the largest piece of content that painted.

The overall page background is ignored intentionally by Chrome’s LCP algorithms.

2 Likes

It should work like this, but in practise I’ve been getting (especially lately) all sorts of crazy results with LCP.

e.g

Often the largest image is ignored, at other tests I’ve seen LCP ignoring every image in the viewport and firing after a main font face was loaded… it has become very random.
In other tests LCP actually did fire for a background graphic (which is loaded as “background-image” in css), again ignoring the main image.

I don’t know if there have been recent changes in Chrome code, but something is no longer working alright lately.

1 Like