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.
- 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 CLS:
- All four images lack
widthandheight— the browser does not know how much space to reserve before they load, so surrounding content jumps. - The
font-display: autoon 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
widthandheightattributes? - Do gallery and below-fold images have
loading="lazy"? - Does every
@font-faceor Google Fonts URL includefont-display: swaporfont-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>eitherdeferred 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.
Asset Optimization
Minification, bundling, caching, and compression reduce the bytes a browser downloads — most of this is handled by build tools, but understanding what they do lets you configure them deliberately.
The DOM Tree
The DOM is the browser's live, in-memory representation of an HTML document — a tree of nodes that JavaScript can read and modify.