completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

Remove render delay

The LCP image has arrived but nothing shows. Thirteen versions of one shop page loaded in Chrome over HTTP/2 on a Slow 4G link: Bootstrap from a CDN and self-hosted, critical CSS, a third-party script sync and async, client and server rendering with React, an anti-flicker snippet with its A/B server up and down, and a heading waiting for its web font.

By the end, you can
  • 01
    Find what holds the paint backCompare when the LCP photo arrived with when a stylesheet, script, bundle or font did, and spot the one it waited for.
  • 02
    Take each blocker out of the wayInline the critical CSS, give head scripts async, render on the server, and drop the anti-flicker snippet.
  • 03
    Handle a heading as the LCPSay why a web font's wait counts as render delay, and what preload and font-display: swap each change.

Here, but not shown

Asha opens the Leaf and Kettle tea shop's spring sale page again. Ravi looks after the shop's website. He has done everything Load the LCP resource early asked. The mint tea photo is right in the HTML. It's marked fetchpriority="high". So the browser asks for it along with the very first files. On a slow phone network, the whole photo reaches Asha's phone in about three quarters of a second.

Then Ravi adds one line to the page's head. It loads a script from another company's server. The photo still arrives on time. But Asha sees it almost half a second later than before. Here are the two versions side by side:

Arrived, then paintedPop out in the code editor (opens in a new tab)JavaScript
// Two versions of the sale page, from the run with the median LCP, in ms.const healthyPage = { arrived: 757.5, painted: 764 };const scriptInHead = { arrived: 773.9, painted: 1204 }; console.log(`Healthy page: painted ${Math.round(healthyPage.painted - healthyPage.arrived)} ms after the photo arrived.`);console.log(`Script in the head: painted ${Math.round(scriptInHead.painted - scriptInHead.arrived)} ms after the photo arrived.`);
  • Line 1 is a comment. Times are in milliseconds (ms), from the moment Asha started loading the page. Each version was loaded 5 times. This is the run whose LCP was the median, the middle one.
  • Line 2 is the healthy page. arrived is when the photo's last byte came in. painted is when the photo appeared on the screen. That moment is the page's LCP, its Largest Contentful Paint.
  • Line 3 is the same page with the script in its head.
  • Lines 5 and 6 subtract one time from the other. They print Healthy page: painted 7 ms after the photo arrived. and Script in the head: painted 430 ms after the photo arrived.

Here is what Asha's screen showed. Each version is shown at the same three moments:

Asha's screen on two versions of the sale page, at the same three moments, from each page's run with the median LCP. At 774 ms, the photo is in on both phones: the healthy page shows the line of text and the photo; the page with the script in its head shows a blank screen, though the photo is in. At 1,176 ms, the script arrives: the healthy page shows the line of text and the photo; the page with the script in its head shows a blank screen, though the photo is in. At 1,204 ms, the script page paints the photo: the healthy page shows the line of text and the photo; the page with the script in its head shows the line of text and the photo.774 ms1,176 ms1,204 msA healthypageScript inthe headThe line of text and the mint tea photoblankphoto in,not shownThe line of text and the mint tea photoblankphoto in,not shownThe line of text and the mint tea photoThe line of text and the mint tea photothe photo is inon both phonesthe scriptarrivesthe script pagepaints the photo
Asha's screen on both pages at the same moments. The photos are real frames Chrome captured while loading each page. At 774 ms, the photo was in on both phones, but only the healthy page showed it. The other stayed blank until 1,204 ms.

At 774 ms, the photo was in on both phones. Only the healthy page showed it.

So for 430 ms, the whole photo sat on Asha's phone. And she couldn't see it. What was the browser waiting for? Not the photo. The photo was there.

The four parts of LCP gave this wait a name. It's the last of the four parts: element render delay. It runs from the moment the LCP resource's last byte arrives to the moment the element is painted. The LCP resource is the file the LCP element needs, here the photo. In that lesson's pizza story, render delay was the drive and the knock at your door.

Real-life analogyA plate waiting on the pass

In a café kitchen, a finished plate goes on the pass: the shelf between the kitchen and the tables. The food is cooked. Nothing more needs doing to it. But until someone carries it to the table, the customer is still waiting. Every minute it sits there, the food is ready and the customer is still hungry.

In real life: Cooking the meal
In JavaScript: Downloading the photo
In real life: The finished plate on the pass
In JavaScript: The photo, fully downloaded, not yet on the screen
In real life: Carrying it to the table
In JavaScript: Painting the photo
In real life: The time the plate sits on the pass
In JavaScript: Element render delay

So what keeps a plate on the pass? web.dev is the Chrome team's site for web developers. It lists four reasons a finished LCP resource can't be painted straight away:

  1. The whole page can't render yet. That's because of "stylesheets or synchronous scripts in the <head> that are still loading".
  2. The element isn't on the page yet. It's "waiting for some JavaScript code to load" that adds it.
  3. The element is "being hidden by some other code, such as an A/B testing library".
  4. The main thread is busy with long tasks. The main thread is the one thread that runs the page's scripts and paints it.

The next sections take them in turn. The last one adds a fifth case: a heading waiting for its web font.

To see each one happen, the course built thirteen versions of the sale page. They all share:

  • The same 21,617-byte photo, in the HTML, marked fetchpriority="high".
  • HTTP/2, with text files compressed.
  • A slow 4G link: a 150 ms round trip (a message to the server and back), and 1.6 Mbps down.
  • A new Chrome, with a new profile, for every load.

On the healthy page and four others, the photo finished at about the same time. Yet LCP ran from 764 ms to 1,980 ms. The others waited until one more file had arrived: a stylesheet, a script, a bundle or an A/B script. Why it matters: when render delay is big, a faster photo only waits longer. Ravi found that in The four parts of LCP. Find the other file.

The main idea

Element render delay is the wait between the LCP resource arriving and the element appearing. To remove it, find what else the paint is waiting for, and take it out of the way. Once the photo is in, nothing should stand between it and the screen.

All thirteen pages are in this playground. Pick one, and compare when its photo arrived with when it was painted:

Playground: thirteen pages, one paint
What arrived when
  1. FCP684 ms
  2. The photo arrived758 ms
  3. Time to first byte482 ms
  4. Resource load delay9 ms
  5. Resource load duration267 ms
  6. Element render delay7 ms
  7. LCP764 ms
Try it yourself
A healthy page: LCP 764 ms

From the run whose LCP was the median of 5. The marked row is element render delay: the time from the photo's last byte to its paint.

Recorded in Chrome 156 on Apple M1 Pro, over HTTP/2 in an 800 by 600 window, on a link with a 150 ms round trip and 1.6 Mbps down.

Render-blocking stylesheets and scripts

Start with the first reason. Render-blocking and parser-blocking resources showed that a stylesheet in the page's head is render-blocking. The browser paints nothing until it has arrived. That lesson's shop owner kept the doors shut until the window display was done.

Usually that's what you want. Nobody wants to see the page without its styles. The trouble starts when the stylesheet is slower than the photo. web.dev: "if the style sheet is so large that it takes significantly longer to load than the LCP resource, then it will prevent the LCP element from rendering".

A stylesheet from another server

The course added a big stylesheet to the sale page. It's Bootstrap 5.3.3, a CSS framework full of ready-made styles. It's 232,803 bytes, or 22,709 once compressed. On the first page, it came from a public CDN. That's a server that hosts libraries for any site to link to. That server is another origin, a different host name from the shop's.

The photo, then the stylesheetPop out in the code editor (opens in a new tab)JavaScript
// Bootstrap from a CDN, from the run with the median LCP, in ms.const photo = 778.7;const stylesheet = 1086.6;const lcp = 1116; console.log(`The photo arrived at ${Math.round(photo)} ms.`);console.log(`The stylesheet arrived at ${Math.round(stylesheet)} ms.`);console.log(`The photo was painted at ${lcp} ms.`);
  • Lines 2 to 4 hold three recorded times: when the photo arrived, when the stylesheet arrived, and LCP.
  • Lines 6 to 8 print The photo arrived at 779 ms., The stylesheet arrived at 1087 ms. and The photo was painted at 1116 ms.

The photo was ready first, then waited for the stylesheet. Its render delay was 337 ms.

Why was the stylesheet so late? Chrome asked for both files at the same moment. But the stylesheet lived on another origin. So Chrome first had to open a new connection to it. That takes one round trip for TCP and one for TLS: 300 ms on this link. Connections in practice showed that cost.

Real-life analogyA parcel held next door

A parcel for you arrives while you're at work. So the courier leaves it with your neighbour. She's careful. She only hands it over when you show her the delivery card. The card is coming separately, by post, and it's late. Your parcel sits one door away until the card comes.

In real life: The parcel at your neighbour's house
In JavaScript: The photo, fully downloaded
In real life: The delivery card, still in the post
In JavaScript: A render-blocking stylesheet or script, still loading
In real life: Showing the card and getting the parcel
In JavaScript: The stylesheet arrives, and the photo is painted
In real life: A card sent by a slower post
In JavaScript: A file on another origin, which needs its own connection

The same stylesheet from the shop's own server

So the course moved the same file onto the shop's own server. No new connection this time:

The stylesheet first, then the photoPop out in the code editor (opens in a new tab)JavaScript
// Bootstrap from the shop's own server, from the run with the median LCP, in ms.const stylesheet = 759.1;const photo = 868.2;const lcp = 876; console.log(`The stylesheet arrived at ${Math.round(stylesheet)} ms.`);console.log(`The photo arrived at ${Math.round(photo)} ms, and was painted at ${lcp} ms.`);
  • Lines 2 to 4 hold this page's recorded times. This time, the stylesheet comes first.
  • Line 6 prints The stylesheet arrived at 759 ms.
  • Line 7 prints The photo arrived at 868 ms, and was painted at 876 ms.

The paint came 8 ms after the photo arrived. So render delay is gone. But LCP was 876 ms, against 764 on the healthy page. This time, the photo itself arrived later.

Both files now shared the shop's one connection. The course's server sends the most urgent file first. Chrome marks a stylesheet in the head as the most urgent of all. You saw this in How browsers prioritize requests. So Bootstrap's bytes went first. The photo's download took 379 ms instead of the healthy page's 267.

As in The four parts of LCP, the fix only moved time from one part into another. Why it matters: a big render-blocking file costs time wherever it comes from. It has to stop blocking.

Critical CSS inline, the rest later

web.dev gives two ways out: put the stylesheet straight into the HTML, or make it smaller. It suggests the first only for small stylesheets. So the course split the CSS in two, as The critical rendering path showed. Here are the two parts:

  • The critical CSS is the few rules the first screen needs. Here, that's the page's own two rules. They go straight into the HTML.
  • Everything else, here Bootstrap. It loads without blocking, and applies once it arrives.
The head with critical CSS inlineHTML
<style>body { font: 18px/1.4 system-ui, sans-serif; margin: 0; padding: 16px; }.hero { display: block; width: 640px; height: 427px; }</style><link rel="preload" href="https://cdn.test/bootstrap.min.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
  • Lines 1 to 4 are the page's own CSS, inside a <style> tag. It arrives with the HTML. So there's no file to wait for.
  • Line 5 asks for Bootstrap with rel="preload". web.dev: this "requests the style sheet asynchronously". The browser downloads it, but nothing waits for it.
  • Line 5 also has an onload attribute. When the file has loaded, it changes rel to stylesheet, and the styles apply. Setting onload to null first stops some browsers from running it twice.

That's web.dev's pattern from Defer non-critical CSS, with Bootstrap's address. FCP, first contentful paint, is when the first text or image appears. Here is what the pattern changed:

Blocking, then not blockingPop out in the code editor (opens in a new tab)JavaScript
// Medians of 5 runs, in ms.const fromCdn = { firstPaint: 1116, lcp: 1116 };const inlined = { firstPaint: 516, lcp: 768 }; console.log(`Bootstrap from a CDN: first paint at ${fromCdn.firstPaint} ms, LCP at ${fromCdn.lcp} ms.`);console.log(`Critical CSS inline: first paint at ${inlined.firstPaint} ms, LCP at ${inlined.lcp} ms.`);
  • Lines 2 and 3 hold each page's median FCP and median LCP. The code calls FCP firstPaint.
  • Line 5 prints Bootstrap from a CDN: first paint at 1116 ms, LCP at 1116 ms.
  • Line 6 prints Critical CSS inline: first paint at 516 ms, LCP at 768 ms.

With the CSS inline, LCP was 768 ms, against 764 on the healthy page. The first words came even sooner than the healthy page's 684 ms. That's because its small stylesheet now came inside the HTML. Why it matters: inline what the first screen needs, and nothing big stands in front of the paint. Exercise 5 asks why inlined CSS should stay small.

A script in the head

Scripts can block too. The parser is the part of the browser that reads the HTML. Take a plain <script src> in the head, with no async or defer. It stops the parser until it has downloaded and run. In the head, it holds back the paint as well. web.dev: "It is almost never necessary to add synchronous scripts (scripts without the async or defer attributes) to the <head> of your pages".

Back to Ravi's page from the start. Its script is a third-party script, one from another company's server. It's lodash 4.17.21, a JavaScript library of 73,015 bytes, or 23,089 compressed.

The photo waits for the scriptPop out in the code editor (opens in a new tab)JavaScript
// A third-party script in the head, from the run with the median LCP, in ms.const photo = 773.9;const script = 1176;const lcp = 1204; console.log(`The photo arrived at ${Math.round(photo)} ms.`);console.log(`The script arrived at ${script} ms.`);console.log(`The photo was painted at ${lcp} ms.`);
  • Lines 2 to 4 hold this page's recorded times.
  • Lines 6 to 8 print The photo arrived at 774 ms., The script arrived at 1176 ms. and The photo was painted at 1204 ms.

It's the CDN stylesheet again. The script needed its own new connection, and came about 400 ms after the photo. The paint waited for it. That's where Asha's 430 ms went.

Now add one word, async: <script src="https://tags.test/lodash.min.js" async>. It tells the browser to download the script without stopping, and run it whenever it arrives. Render-blocking and parser-blocking resources compared it to a note for the cook: "do this whenever you're free".

The same script, with asyncPop out in the code editor (opens in a new tab)JavaScript
// The same script with async, from the run with the median LCP, in ms.const photo = 759;const script = 1181.6;const lcp = 768; console.log(`The photo arrived at ${photo} ms.`);console.log(`The photo was painted at ${lcp} ms.`);console.log(`The script arrived at ${Math.round(script)} ms.`);
  • Lines 6 and 7 print The photo arrived at 759 ms. and The photo was painted at 768 ms.
  • Line 8 prints The script arrived at 1182 ms.

The script arrived just as late. But this time nothing waited for it. The photo was painted 9 ms after it arrived. Why it matters: a third-party script in the head can almost always take async or defer. Then it can't hold the photo back while it loads.

Client-side rendering and hydration

The second reason is quieter. Sometimes the photo arrives, and there's no <img> on the page to put it in yet.

Sites built with a JavaScript framework like React can build the page in the browser. That's called client-side rendering. The HTML arrives with an empty box, and a script fills it in. React's docs: "When your HTML is empty, the user sees a blank page until the app's JavaScript code loads and runs".

The course built the sale page with React. One file, the bundle, holds React, React DOM and the shop's own code. It's 219,152 bytes minified and 58,352 compressed. Minified means shrunk by taking out spaces and shortening names. The page loads it with defer, so it runs once the HTML has been read. Here is the shop's part:

The shop's React codeJavaScript
const React = require("react");const { createRoot, hydrateRoot } = require("react-dom/client");const h = React.createElement;const page = h(React.Fragment, null,  h("p", { id: "intro" }, "Everything in the shop is 20% off until Sunday."),  h("img", { className: "hero", src: "/hero.jpg", alt: "Mint tea", width: 640, height: 427, fetchPriority: "high" }),);const root = document.getElementById("root");if (root.hasChildNodes()) hydrateRoot(root, page);else createRoot(root).render(page);
  • Lines 1 and 2 load React, and React DOM's two ways to start: createRoot and hydrateRoot.
  • Line 3 gives React.createElement a short name, h. Each call makes one element.
  • Lines 4 to 7 describe the page: the line of text about the sale, and the hero photo.
  • Line 8 finds the box on the page that React will fill.
  • Line 9 runs if the box already holds HTML. Line 10 runs if it's empty, and builds the page from nothing.

The course loaded the page both ways. On the client-rendered page, the box arrives empty: <div id="root"></div>. That page also preloads the photo, as the last lesson advised. So the photo isn't late:

The photo waits for the bundlePop out in the code editor (opens in a new tab)JavaScript
// Rendered on the client, from the run with the median LCP, in ms.const photo = 776.4;const bundle = 1050.2;const lcp = 1080; console.log(`The photo arrived at ${Math.round(photo)} ms.`);console.log(`The bundle arrived at ${Math.round(bundle)} ms.`);console.log(`The photo was painted at ${lcp} ms.`);
  • Lines 2 to 4 hold this page's recorded times.
  • Lines 6 to 8 print The photo arrived at 776 ms., The bundle arrived at 1050 ms. and The photo was painted at 1080 ms.

The photo was on time. But there was no <img> to show it in until the bundle had arrived and run. The paint came 30 ms after the bundle arrived: time to run it, build the page, and paint.

Real-life analogyTables that come flat-packed

The café has just had new tables delivered, still flat-packed in their boxes. Your meal is ready on the pass. But until someone opens a box and builds a table, there's nowhere to put your plate. A café whose tables arrive already built can serve you the moment the food is ready.

In real life: The meal, ready on the pass
In JavaScript: The photo, downloaded
In real life: A table still in its flat-pack box
In JavaScript: An empty root box, and the bundle that will fill it
In real life: Building the table
In JavaScript: The bundle arriving, and React building the <img>
In real life: A table delivered already built
In JavaScript: HTML rendered on the server

Rendered on the server, then hydrated

The fix is the café whose tables arrive built. With server-side rendering (SSR), the server runs the app's code and sends the finished HTML. On the course's server-rendered page, the box arrives full. The line of text and the <img> are already inside. The browser can paint the photo the moment it arrives:

The photo doesn't waitPop out in the code editor (opens in a new tab)JavaScript
// Rendered on the server, from the run with the median LCP, in ms.const photo = 774.2;const bundle = 1046.7;const lcp = 792; console.log(`The photo arrived at ${Math.round(photo)} ms.`);console.log(`The photo was painted at ${lcp} ms.`);console.log(`The bundle arrived at ${Math.round(bundle)} ms.`);
  • Lines 2 to 4 hold this page's recorded times.
  • Lines 6 to 8 print The photo arrived at 774 ms., The photo was painted at 792 ms. and The bundle arrived at 1047 ms.

This time, the photo was painted before the bundle had even arrived. web.dev lists this among SSR's advantages for LCP. It says: "Your page content won't require additional JavaScript requests to finish before it can render."

The bundle still runs. When it does, line 9 calls hydrateRoot. Hydration is React taking over HTML that's already on the page. The DOM is the page as the browser holds it in memory. React's docs say hydrateRoot will "attach to the HTML that exists inside the domNode, and take over managing the DOM inside it". It doesn't build the page again.

So here, hydration came after the paint: the order you want. A page that hid its server HTML until hydration finished would wait for the bundle all over again. Why it matters: render the LCP element on the server, and hydrate it afterwards. SSR has a cost too. web.dev: "it requires additional server processing time, which can slow down your TTFB." TTFB is time to first byte.

When the main thread is busy

web.dev's fourth reason needs no render-blocking file and no missing element. JavaScript that does neither, its guide says, "can still delay LCP".

Here's why. web.dev: "All browsers today render images on the main thread". And the main thread does one task at a time. You saw this in The event loop and frames. A long task is one that runs for more than 50 ms. While it runs, nothing else on the main thread can, painting included.

So a photo can arrive in the middle of a long task. Then, in web.dev's words, "it may still have to wait until an unrelated script finishes executing before it can render." It's the plate on the pass again. The café's only waiter is stuck at another table, taking a long order.

The most common cause, web.dev says, is large JavaScript files. They are files "which need to be parsed and executed on the browser's main thread". To parse code is to read it and work out what it means. Why it matters: a script can delay LCP without blocking anything, just by running at the wrong moment.

Here are the three problems from this section and the last. Each is next to its fix:

Each page's LCP as its four parts, from the run with the median LCP. Bootstrap from a CDN: LCP 1,116 ms, render delay 337 ms. Critical CSS inline, Bootstrap later: LCP 768 ms, render delay 13 ms. A third-party script in the head: LCP 1,204 ms, render delay 430 ms. The same script, async: LCP 768 ms, render delay 9 ms. Rendered on the client: LCP 1,080 ms, render delay 304 ms. Rendered on the server: LCP 792 ms, render delay 18 ms.Bootstrap from a CDNLCP 1,116 ms, render delay 337 msCritical CSS inline, Bootstrap laterLCP 768 ms, render delay 13 msA third-party script in the headLCP 1,204 ms, render delay 430 msThe same script, asyncLCP 768 ms, render delay 9 msRendered on the clientLCP 1,080 ms, render delay 304 msRendered on the serverLCP 792 ms, render delay 18 ms
Each bar is one page's LCP: TTFB, load delay, load duration and render delay, in that order. Shaded: render delay. Each pair is a problem, then its fix.

In each pair, the shaded part, render delay, almost disappears. LCP falls back to within 30 ms of the healthy page's 764.

Anti-flicker snippets from A/B testing tools

The third reason is code that hides the page on purpose. Ravi's page in The four parts of LCP did this. There, a lighter photo saved him nothing.

An A/B test shows some visitors one version of a page and others another. It shows which works better. Some testing tools switch the version in the browser, with a script. Until it runs, a visitor would see the original page flicker into the other one.

Chrome's guide on A/B testing says tools have avoided the flicker with "anti-flicker snippets". They "hide the entire page with opacity: 0 until the experiment script finishes or an arbitrary timeout (typically 4 seconds) elapses." Arbitrary means picked for no real reason. The course wrote one with a 3-second timeout:

An anti-flicker snippetHTML
<style>.ab-hide { opacity: 0 !important; }</style><script>  document.documentElement.classList.add("ab-hide");  const show = () => document.documentElement.classList.remove("ab-hide");  setTimeout(show, 3000);  window.abReady = show;</script><script src="https://ab.test/ab.js" async></script>
  • Line 1 is a CSS rule. Anything with the class ab-hide gets opacity: 0: fully see-through. How LCP works showed that an element at opacity 0 doesn't count for LCP.
  • Line 3 adds that class to the <html> element, hiding the whole page. Line 4 makes a function, show, that takes the class away again.
  • Line 5 runs show after 3,000 ms, whatever happens. Line 6 hands it to the testing script as window.abReady, to call once it has run.
  • Line 8 loads the testing script from the testing company's server, with async.
Real-life analogyA curtain that waits for the stage manager

At a school play, the curtain stays closed until the stage manager says go. The actors are in place and the set is ready, but the audience sees none of it. If the stage manager is late, everyone waits. The teacher has one rule: if there's no call by seven o'clock, open the curtain anyway.

In real life: The actors, in place behind the curtain
In JavaScript: The page and its photo, loaded but hidden
In real life: The closed curtain
In JavaScript: opacity: 0 on the whole page
In real life: The stage manager's "go"
In JavaScript: The testing script calling abReady()
In real life: "Open at seven, whatever happens"
In JavaScript: The snippet's timeout

The course's stand-in testing script is just one line: window.abReady();. But its server takes one second to answer:

Shown when the testing script runsPop out in the code editor (opens in a new tab)JavaScript
// The A/B server answers after one second. From the run with the median LCP, in ms.const abScript = 1972.9;const lcp = 1980;const renderDelay = 1202.1; console.log(`The A/B script arrived at ${Math.round(abScript)} ms.`);console.log(`The page was shown, and the photo painted, at ${lcp} ms.`);console.log(`The photo had been ready and hidden for ${Math.round(renderDelay)} ms.`);
  • Lines 2 to 4 hold three recorded values. They are when the testing script arrived, LCP, and the photo's render delay.
  • Lines 6 to 8 print The A/B script arrived at 1973 ms., The page was shown, and the photo painted, at 1980 ms. and The photo had been ready and hidden for 1202 ms.

The testing server took a second to answer. It was on yet another origin, with its own new connection. Meanwhile, for 1,202 ms, the photo sat behind the curtain. Why it matters: with a snippet like this, LCP can come no sooner than the testing script, or the timeout.

When the testing server is down

What if the testing server doesn't answer at all? The course made it take ten seconds, far past the timeout:

Shown at the timeoutPop out in the code editor (opens in a new tab)JavaScript
// The A/B server is down. From the run with the median LCP, in ms.const timeout = 3000;const lcp = 3676; console.log(`The page was shown at ${lcp} ms.`);console.log(`That's ${lcp - timeout} ms later than the snippet's ${timeout} ms timeout.`);
  • Line 2 is the snippet's timeout. Line 3 is LCP, from the run with the median LCP.
  • Line 5 prints The page was shown at 3676 ms.
  • Line 6 prints That's 676 ms later than the snippet's 3000 ms timeout.

Why 676 ms late? The timer starts when line 5 of the snippet runs. That line is in an inline script. The HTML Standard has a rule for this. The parser holds a script back while the document "has a style sheet that is blocking scripts". The shop's own stylesheet sits above the snippet in the head. So the snippet waited for it.

The anti-flicker page with the A/B server down, from the run with the median LCP. The shop's stylesheet loaded from 485 ms to 667 ms. The photo loaded from 485 ms to 755 ms, then stayed hidden. The snippet's 3-second timer ran from 667 ms to 3,667 ms. LCP: 3,676 ms.The shop's stylesheetarrived at 667 msThe photoready, but hiddenThe snippet's timerLCP 3,676 ms0 ms2,000 ms4,000 ms
The A/B server down, from the run with the median LCP. The snippet's 3-second timer only started once the shop's stylesheet had arrived, at 667 ms. Shaded: the photo, ready long before.

The stylesheet arrived at 667 ms. Add 3,000 ms, and you get 3,667. The page appeared 9 ms after that. So the whole timeout comes on top of the page's own stylesheet.

Chrome's guide offers a better way for pages where speed matters. It says: "consider server-side A/B testing (where the server renders the correct variant directly)". A variant is one version of the page. Then the right HTML arrives from the start, and there's nothing to hide. If a test must run in the browser, the guide suggests blocking="render" on the testing script instead. You met it in Render-blocking and parser-blocking resources. The paint still waits for the script. But in the guide's words: "No opacity hacks, no arbitrary timeouts". Either way, keep that script "small and fast".

Text waiting for web fonts

So far the LCP element was a photo. Sometimes it's text, like a big heading, with no file of its own. The four parts of LCP covered this case. Both load parts are then 0. Everything after the HTML's first byte counts as render delay.

A web font is a font file the page downloads, like a handwriting font for a heading. How LCP works showed what happens while the font is still on its way. First comes the font's block period. During it, the text is drawn in an invisible font. And invisible text doesn't count for LCP. web.dev: "Neither are text nodes using web fonts during the font block period." So the heading must wait.

Real-life analogyA new sign under a sheet

A shop puts up its new sign before opening day. The sign is in place, over the door, the right size. But the owner wants it in gold lettering. The gold paint hasn't come yet. So the sign stays under a sheet. People walking past see a covered board, not the shop's name.

In real life: The sign, up and in place
In JavaScript: The heading, laid out on the page
In real life: The sheet over it
In JavaScript: The block period's invisible font
In real life: The gold paint, on its way
In JavaScript: The web font, still downloading
In real life: Taking the sheet off now, and painting it gold later
In JavaScript: font-display: swap

The course gave the sale's heading, "Spring tea sale", the course's Caveat handwriting font: a 74,932-byte file. It used font-display: block. That hides the text while the font downloads, for up to a few seconds:

The heading waits for its fontPop out in the code editor (opens in a new tab)JavaScript
// The heading with font-display: block, from the run with the median LCP, in ms.const fontAskedFor = 644;const fontArrived = 1189.3;const lcp = 1200; console.log(`Chrome asked for the font at ${fontAskedFor} ms.`);console.log(`The font arrived at ${Math.round(fontArrived)} ms.`);console.log(`The heading counted as LCP at ${lcp} ms.`);
  • Lines 2 to 4 hold this page's times. They are when Chrome asked for the font, when it arrived, and LCP.
  • Lines 6 to 8 print Chrome asked for the font at 644 ms., The font arrived at 1189 ms. and The heading counted as LCP at 1200 ms.

The heading counted 11 ms after its font came in. Its render delay was 720 ms: everything after the HTML's first byte.

Why did Chrome only ask for the font at 644 ms? That was right after the page's stylesheet had arrived. web.dev: "a font is downloaded only if it's referenced by styling that is used on the page." A preload tells the browser about a file before it would find it on its own. web.dev adds that without a preload, the browser "won't otherwise discover whether the font is needed until much later."

Preloading the font

For a font, a preload is one line in the head: <link rel="preload" href="/caveat.woff2" as="font" type="font/woff2" crossorigin>. The crossorigin is needed even on your own origin. CORS is how the browser asks a server whether a file may be used. MDN explains that fonts "have to be fetched using anonymous-mode CORS". The preload has to match.

The font, asked for at oncePop out in the code editor (opens in a new tab)JavaScript
// The same, with the font preloaded, from the run with the median LCP, in ms.const fontAskedFor = 485.1;const fontArrived = 1025.1;const lcp = 1032; console.log(`Chrome asked for the font at ${Math.round(fontAskedFor)} ms.`);console.log(`The font arrived at ${Math.round(fontArrived)} ms.`);console.log(`The heading counted as LCP at ${lcp} ms.`);
  • Lines 6 to 8 print Chrome asked for the font at 485 ms., The font arrived at 1025 ms. and The heading counted as LCP at 1032 ms.

Chrome asked for the font as soon as it read the head. And the heading counted 168 ms sooner.

It wasn't free, though. The line about the sale, the first words on the page, came later. In numbers, the median FCP went from 668 to 784 ms. web.dev warns that a font preload "comes at the cost of taking away browser resources from the loading of other resources." Here, the font's bytes shared the slow link with the stylesheet from the very start.

Showing the text at once

The other way is not to hide the text at all. font-display: swap draws the heading at once in a fallback font, one the device already has. The web font is swapped in when it arrives. Here are the times:

The heading shows at oncePop out in the code editor (opens in a new tab)JavaScript
// The heading with font-display: swap, from the run with the median LCP, in ms.const lcp = 672;const fontArrived = 1188; console.log(`The heading counted as LCP at ${lcp} ms, in a fallback font.`);console.log(`The font arrived at ${fontArrived} ms, and made no new LCP entry.`);
  • Line 5 prints The heading counted as LCP at 672 ms, in a fallback font.
  • Line 6 prints The font arrived at 1188 ms, and made no new LCP entry.

With swap, the heading counted 528 ms sooner than with block. When Caveat arrived, the heading changed font in front of Asha. But it made no new entry. Each element is reported once, at its first paint. The three downloads, on one timeline:

The heading's web font, from the run with the median LCP. font-display: block: font from 644 ms to 1,189 ms, LCP 1,200 ms. block, preloaded: font from 485 ms to 1,025 ms, LCP 1,032 ms. font-display: swap: font from 644 ms to 1,188 ms, LCP 672 ms.font-display: blockLCP 1,200 msblock, preloadedLCP 1,032 msfont-display: swapLCP 672 ms0 ms1,000 ms
Shaded: the font's download, from the request to its last byte. With block, the heading counted just after its font arrived. With swap, it counted long before, in a fallback font.

Why it matters: if your LCP is text, its font decides whether it waits. Swap lets it count at once. A preload shortens the wait, at a cost to everything else loading alongside.

Does it hold the paint back?

Here is every cause in one place. Read each row across: what's on the page, what the LCP element waits for, and the fix.

What holds the paint back, and the fix
What's on the pageWhat the LCP element waits forThe fix
A big stylesheet in the headThe stylesheet. From another origin, it waits for a new connection too.Inline the critical CSS, and load the rest with a preload.
A plain script in the headThe script, to arrive and run.Add async or defer to the script tag.
A page rendered on the clientThe bundle, to arrive and build the element.Render the page on the server, and hydrate after the paint.
A long task on the main threadThe task, to finish.Break the long task into smaller ones.
An anti-flicker snippetThe A/B script, or the snippet's timeout.Choose the version on the server.
A heading in a web font with font-display: blockThe font, while the text is invisible.Use font-display: swap, or preload the font.

Now sort each choice. Does it hold the LCP element's paint back once its resource has arrived?

Does it hold the paint back?
  • A render-blocking stylesheet from a CDN
  • A third-party <script> in the head, without async or defer
  • A hero rendered by React on the client
  • An anti-flicker snippet hiding the page
  • A big script running on the main thread just as the photo arrives
  • The same third-party script with async
  • Critical CSS inline, the rest loaded with a preload
  • Server-rendered HTML, hydrated by a deferred script
  • A heading in a web font with font-display: swap
Try it yourself
0 of 9 correct

Sort each choice by whether it holds the LCP element's paint back once its resource has arrived.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "Moving a stylesheet to your own server makes it free." No. Render delay fell to 8 ms, but LCP was still 876 ms against 764. The photo waited in its download instead.
  • "A script from another server always delays LCP." No. With async, the photo was painted at 768 ms, long before the script arrived.
  • "A big bundle always delays LCP." No. The server-rendered page painted at 792 ms, before its bundle arrived. Hydration came after.
  • "An anti-flicker snippet costs at most its timeout." No. With the A/B server down, the page appeared at 3,676 ms. The 3-second timer only started once the page's stylesheet had arrived.
  • "Preloading a font is free." No. The heading counted 168 ms sooner, but the first words came later: the median FCP went from 668 to 784 ms.

Practice exercises

Answer first, then use the hints.

Exercise 1 · Warm-upThe wait

This is the CDN page's example with one more line. What does line 9 print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// Bootstrap from a CDN, from the run with the median LCP, in ms.
const photo = 778.7;
const stylesheet = 1086.6;
const lcp = 1116;

console.log(`The photo arrived at ${Math.round(photo)} ms.`);
console.log(`The stylesheet arrived at ${Math.round(stylesheet)} ms.`);
console.log(`The photo was painted at ${lcp} ms.`);
console.log(`The photo waited ${Math.round(stylesheet - photo)} ms for the stylesheet.`);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upPainted after

    This is the client-rendered page's example with one more line. What does line 9 print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // Rendered on the client, from the run with the median LCP, in ms.
    const photo = 776.4;
    const bundle = 1050.2;
    const lcp = 1080;
    
    console.log(`The photo arrived at ${Math.round(photo)} ms.`);
    console.log(`The bundle arrived at ${Math.round(bundle)} ms.`);
    console.log(`The photo was painted at ${lcp} ms.`);
    console.log(`Painted ${Math.round(lcp - photo)} ms after the photo arrived.`);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeServer down

      With the A/B server down, how many milliseconds was element render delay?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeThe hidden photo

        On the anti-flicker page with the A/B server up, when did the photo arrive, in ms?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeInlining

          Why does web.dev recommend putting a stylesheet into the HTML only if it's small?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeBetter than SSR

            web.dev's guide names one option that it calls "generally a better choice for performance" than server-side rendering, when your setup allows it. What is it?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask when the LCP resource arrived, what else the paint waited for, and where that wait landed.

              Remove render delay quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does the last line of this self-hosted stylesheet example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // Bootstrap from the shop's own server, from the run with the median LCP, in ms.
                const stylesheet = 759.1;
                const photo = 868.2;
                const lcp = 876;
                
                console.log(`The stylesheet arrived at ${Math.round(stylesheet)} ms.`);
                console.log(`The photo arrived at ${Math.round(photo)} ms, and was painted at ${lcp} ms.`);

                Choose an answer to see the explanation.

              2. Question 2 of 7What does the second line of the server-down example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // The A/B server is down. From the run with the median LCP, in ms.
                const timeout = 3000;
                const lcp = 3676;
                
                console.log(`The page was shown at ${lcp} ms.`);
                console.log(`That's ${lcp - timeout} ms later than the snippet's ${timeout} ms timeout.`);

                Choose an answer to see the explanation.

              3. Question 3 of 7A render-blocking stylesheet from a CDN finished after the LCP photo. Which part of LCP grew?

                Choose an answer to see the explanation.

              4. Question 4 of 7The same stylesheet then came from the shop's own server. Render delay fell to a few milliseconds, but LCP was still later than the healthy page's. Why?

                Choose an answer to see the explanation.

              5. Question 5 of 7Why was the server-rendered page's photo painted before its React bundle arrived?

                Choose an answer to see the explanation.

              6. Question 6 of 7The photo has arrived, nothing render-blocking is still loading, and the <img> is in the HTML. Then a big script starts running on the main thread. Can it delay LCP?

                Choose an answer to see the explanation.

              7. Question 7 of 7The LCP element is a heading in a web font with font-display: block. Which part of LCP holds the wait for the font?

                Choose an answer to see the explanation.

              Key takeaways

              • Element render delay is the wait between the LCP resource arriving and the element being painted.
              • A render-blocking stylesheet or script that arrives after the photo holds it back. Inline the critical CSS, load the rest later, and give head scripts async or defer.
              • Moving a big stylesheet to your own server only moves the wait into the photo's download.
              • Render the LCP element on the server, and hydrate after the paint. A long task holds the paint back too.
              • Anti-flicker snippets hide a page that's ready, so choose the version on the server. For a heading, font-display: swap lets it count at once.

              Remember the one-liner.
              Once it has arrived, let nothing stand between it and the paint.

              Sources

              The facts on this page come from these standards and docs:

              Coming next: Image formats, also in Loading content fast: LCP. Once nothing holds the photo back, the next thing to shrink is its download. You'll compare JPEG, PNG, WebP, AVIF, JPEG XL and SVG, and learn how to choose a quality setting.

              CompleteFrontend Clear concepts. Working examples.