Code of the Day
AdvancedWeb Performance & Delivery

Lab — Improve Core Web Vitals

Use Lighthouse and DevTools to identify Core Web Vitals failures on a sample page, then apply targeted fixes to bring all three metrics into the Good range.

Lab · optionalWeb FoundationsAdvanced30 min
Recommended first
By the end of this lesson you will be able to:
  • Run a Lighthouse audit and read LCP, INP, and CLS scores
  • Apply image format and fetchpriority fixes to improve LCP
  • Add width/height attributes and font-display to eliminate CLS
  • Move render-blocking scripts to defer to improve FCP

This lab is built around a deliberately broken HTML page. It has four performance problems — one for each Core Web Vital, plus FCP. Your job is to find each problem using Chrome DevTools, fix it, and verify that the score improves.

You will not run code in the browser here (the lab is hands-on in your own DevTools), but the HTML snippets below are copy-ready. For each part, apply the fix to the broken snippet and confirm that the metric improves in Lighthouse.

Open Chrome, navigate to any page you control locally (e.g. localhost:3000), open DevTools with F12, go to the Lighthouse tab, and run an audit with the "Performance" category selected. You will see LCP, INP, and CLS reported under "Metrics".

The starting page

Here is the complete HTML of the broken starting point. Read through it and identify as many performance problems as you can before reading Part 1:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Mountain Photography</title>

  <!-- Synchronous analytics script — blocks HTML parsing -->
  <script src="https://example-analytics.com/tracker.js"></script>

  <!-- Google Fonts without preconnect or font-display -->
  <link href="https://fonts.googleapis.com/css2?family=Playfair+Display&display=auto"
        rel="stylesheet">

  <style>
    body { font-family: 'Playfair Display', serif; margin: 0; }
    .hero img { width: 100%; }
  </style>
</head>
<body>

  <!-- Hero image: large uncompressed JPEG, no dimensions, default priority -->
  <section class="hero">
    <img src="hero.jpg" alt="A mountain at sunrise">
  </section>

  <!-- Below-fold images: no lazy loading, no dimensions -->
  <section class="gallery">
    <img src="gallery-1.jpg" alt="Valley mist">
    <img src="gallery-2.jpg" alt="Summit snow">
    <img src="gallery-3.jpg" alt="Alpine lake">
  </section>

</body>
</html>

Take a moment to count the problems. There are four distinct issues.

Part 1 — Run the baseline audit

Before fixing anything, record the starting scores. Open the page in Chrome, open DevTools (F12), go to the Lighthouse tab, and click "Analyze page load." Write down or screenshot:

  • LCP value (seconds)
  • CLS score (decimal)
  • FCP value (seconds, shown under Diagnostics)
  • Total Blocking Time (a proxy for INP in lab conditions)

These are your before numbers. Every fix you make should move at least one of them.

Part 2 — Fix LCP

The hero image is the largest element on the page. It has three problems: it is JPEG format (larger than necessary), it has no fetchpriority hint, and there is no preload in <head> to start the download early.

Apply this fix:

<head>
  <!-- Preload the hero image at high priority -->
  <link
    rel="preload"
    as="image"
    href="hero.webp"
    type="image/webp"
    fetchpriority="high"
  >
</head>

<!-- In the body: use picture for format selection, set explicit dimensions -->
<section class="hero">
  <picture>
    <source srcset="hero.webp" type="image/webp">
    <img
      src="hero.jpg"
      alt="A mountain at sunrise"
      width="1920"
      height="1080"
      fetchpriority="high"
    >
  </picture>
</section>

What changed and why it helps:

  • <link rel="preload">: the browser discovers the hero image while parsing <head>, not when it reaches the <img> in <body>. This moves the fetch earlier in the timeline by the amount of time it takes to parse the HTML above the <section>.
  • fetchpriority="high": tells the browser to download this image before competing resources at the same priority level. Particularly effective when other images or fonts are also downloading.
  • WebP format: a properly compressed WebP should be 25–40% smaller than the equivalent JPEG. Fewer bytes means faster download means earlier LCP.

Re-run Lighthouse. LCP should improve.

Part 3 — Fix CLS

The page has two sources of :

  1. All four images lack width and height — the browser does not know how much space to reserve before they load, so surrounding content jumps.
  2. The font-display: auto on the Google Fonts import means the browser may use a FOIT window and then swap the font, shifting text metrics.

Apply these fixes:

<!-- Add width and height to all images -->
<section class="gallery">
  <img src="gallery-1.jpg" alt="Valley mist"   width="800" height="600" loading="lazy">
  <img src="gallery-2.jpg" alt="Summit snow"   width="800" height="600" loading="lazy">
  <img src="gallery-3.jpg" alt="Alpine lake"   width="800" height="600" loading="lazy">
</section>
/* In your stylesheet: use font-display: swap to prevent FOIT */
@font-face {
  font-family: 'Playfair Display';
  src: url('/fonts/playfair-display.woff2') format('woff2');
  font-display: swap;
}

Or, if continuing to use Google Fonts, append &display=swap to the URL:

<link
  href="https://fonts.googleapis.com/css2?family=Playfair+Display&display=swap"
  rel="stylesheet"
>

Notice that gallery images also got loading="lazy" — they are below the fold and should not compete with the hero for bandwidth on initial load.

Re-run Lighthouse. CLS should be at or near 0.

Setting width and height does not mean the images will be rendered at exactly those pixel dimensions. The CSS rule img { width: 100%; height: auto; } still makes them fluid. The browser uses the declared dimensions only to compute the aspect-ratio and reserve space in the layout — the CSS controls the actual rendered size.

Part 4 — Fix FCP

The <script src="...tracker.js"> tag in <head> is synchronous. It blocks the HTML parser until tracker.js has been downloaded and executed — everything below it in the HTML, including the CSS and images, cannot start loading until the script finishes.

The fix is defer, which tells the browser to download the script in parallel with HTML parsing and execute it only after the document is fully parsed:

<!-- Before: blocks HTML parsing -->
<script src="https://example-analytics.com/tracker.js"></script>

<!-- After: parallel download, executes after parse -->
<script src="https://example-analytics.com/tracker.js" defer></script>

Additionally, add <link rel="preconnect"> to warm up the connection to the font CDN before the CSS requests the font file:

<head>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <!-- font link comes after the preconnects -->
  <link
    href="https://fonts.googleapis.com/css2?family=Playfair+Display&display=swap"
    rel="stylesheet"
  >
</head>

Re-run Lighthouse. FCP and Total Blocking Time should both improve.

Part 5 — The fixed page and delta

After all four fixes, the page should look like this:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Mountain Photography</title>

  <!-- Preconnect to font CDN before CSS requests it -->
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

  <!-- font-display: swap prevents FOIT -->
  <link
    href="https://fonts.googleapis.com/css2?family=Playfair+Display&display=swap"
    rel="stylesheet"
  >

  <!-- Preload the LCP image at high priority -->
  <link
    rel="preload"
    as="image"
    href="hero.webp"
    type="image/webp"
    fetchpriority="high"
  >

  <!-- Analytics deferred — no longer blocks parsing -->
  <script src="https://example-analytics.com/tracker.js" defer></script>

  <style>
    body { font-family: 'Playfair Display', serif; margin: 0; }
    .hero img { width: 100%; height: auto; }
    .gallery img { width: 100%; height: auto; }
  </style>
</head>
<body>

  <!-- Hero: WebP with fallback, explicit dimensions, high priority -->
  <section class="hero">
    <picture>
      <source srcset="hero.webp" type="image/webp">
      <img
        src="hero.jpg"
        alt="A mountain at sunrise"
        width="1920"
        height="1080"
        fetchpriority="high"
      >
    </picture>
  </section>

  <!-- Gallery: explicit dimensions, lazy loaded -->
  <section class="gallery">
    <img src="gallery-1.jpg" alt="Valley mist"   width="800" height="600" loading="lazy">
    <img src="gallery-2.jpg" alt="Summit snow"   width="800" height="600" loading="lazy">
    <img src="gallery-3.jpg" alt="Alpine lake"   width="800" height="600" loading="lazy">
  </section>

</body>
</html>

Run the final Lighthouse audit and compare your before and after numbers. You should see meaningful improvements across LCP, FCP, and CLS. Total Blocking Time — the lab proxy for INP — should also drop now that the analytics script no longer occupies the main thread during the critical path.

Self-review checklist

Before considering a page "performance-reviewed," work through this checklist:

  • The LCP element is identified — is it a <img>, a CSS background, or a text block?
  • If it is an image: does it have fetchpriority="high" and a <link rel="preload"> in <head>? Is it in a modern format (WebP or AVIF)?
  • Do all images — including the LCP image — have width and height attributes?
  • Do gallery and below-fold images have loading="lazy"?
  • Does every @font-face or Google Fonts URL include font-display: swap or font-display: optional?
  • Are there <link rel="preconnect"> hints for any third-party origins used by fonts, analytics, or CDNs?
  • Are synchronous <script> tags in <head> either deferred or moved to the end of <body>?
  • Have you run Lighthouse both before and after changes and noted the delta?

PageSpeed Insights at pagespeed.web.dev gives you both lab scores and real-user field data (when available) for any public URL. For a production site, always check field data — your DevTools Lighthouse run on a fast laptop is an optimistic scenario.

Finished reading? Mark it complete to track your progress.

On this page