---
title: "Website Optimization With Claude Code: What Lighthouse Found, and What It Missed | SportyPlus"
description: "How we used Claude Code, Lighthouse and headless Chrome to cut the sporty.plus homepage from 2.6 MB to 0.8 MB and reach 100 for accessibility, and the layout check that caught a bug Lighthouse could not see."
lang: "fr"
canonical: "https://www.sporty.plus/fr/blog/website-optimization-with-claude-code"
---

1.  [Accueil](https://www.sporty.plus/fr)
3.  [Blog](https://www.sporty.plus/fr/blog)
5.  Website Optimization With Claude Code: What Lighthouse Found, and What It Missed

![Website Optimization With Claude Code: What Lighthouse Found, and What It Missed](https://www.sporty.plus/_og/r/blog/website-optimization-with-claude-code.png)

# Website Optimization With Claude Code: What Lighthouse Found, and What It Missed

How we used Claude Code, Lighthouse and headless Chrome to cut the sporty.plus homepage from 2.6 MB to 0.8 MB and reach 100 for accessibility, and the layout check that caught a bug Lighthouse could not see.

**Website optimization with Claude Code** turned out to be less about the AI writing fixes and more about it measuring before and after every change. In one working session on the sporty.plus marketing site, Claude Code ran Lighthouse, traced the failures to the code, fixed them, and proved the fixes moved nothing they should not have. The homepage went from 2.6 MB to 0.8 MB on mobile, mobile accessibility from 93 to 100, and layout shift from 0.204 to 0.004.

The more useful part is what Lighthouse could not tell us. Our first image fix quietly broke the layout, every audit still passed, and only a second, home-made check caught it.

## The starting point

sporty.plus is a Nuxt 4 site served by Node behind nginx and Cloudflare. We started from one prompt: install the [Chrome DevTools MCP](https://github.com/ChromeDevTools/chrome-devtools-mcp) and run a Lighthouse test against the site.

The MCP server installs with one command:

`claude mcp add chrome-devtools --scope user -- npx -y chrome-devtools-mcp@latest`

It gives an agent a real Chrome to drive: navigation, input, device emulation, performance traces, network inspection and screenshots. One catch shaped the rest of the session: Claude Code loads MCP servers when a session starts, so a server added mid-session is not callable until the next one. So in this session Claude ran the same engine from the shell, `npx lighthouse`, and drove headless Chrome through puppeteer for the checks Lighthouse does not do. The MCP is what the next session gets for the same job, interactively.

Lighthouse 13.5 against the production homepage:

Mobile

Desktop

Performance

61

85

Accessibility

93

89

SEO

92

92

LCP / CLS

11.2 s / 0.204

2.1 s / 0.128

Page weight

2.6 MB

2.7 MB

Images were 2.1 MB of that 2.6 MB, served as full-size PNG and JPEG with no `srcset`, no dimensions and no lazy loading. A 1,040-pixel logo was being shown in an 80-pixel circle.

The 11.2-second LCP needed a second look. That figure is Lighthouse's _simulated_ load on a slow phone; the same run's _observed_ LCP, unthrottled, was 1.6 seconds. The element was plain heading text, and after the image work below the same simulated figure fell to 5.0 seconds, which suggests the images were crowding it out. A simulated metric is a model's estimate, so read the observed one beside it before you chase it.

## What we did

### Images: a dependency nobody had switched on

`@nuxt/image` was already in `package.json`, but had never been added to Nuxt's `modules`. Registering it gave us IPX, which resizes and re-encodes images on request. We pointed each homepage image at the width it actually renders at and asked for WebP. The 460 KB competition mockup came back at 93 KB; that 1,040-pixel logo came back at 2 KB.

Two cache details followed. IPX defaults to a 60-second `Cache-Control`, and our plain `public/` files had none at all, so Cloudflare was filling the gap with its 4-hour default. We set IPX to 30 days:

`image: {   ipx: {    fs: {      // IPX defaults to 60s. The URLs are not content-hashed, so a file      // replaced under the same name can be stale for up to this long —      // give it a new name instead.      maxAge: 60 * 60 * 24 * 30    }  } }`

After deploy, the first request for an image showed `cf-cache-status: MISS` and every later one `HIT`, so the server encodes each variant roughly once per Cloudflare location.

### Accessibility: mostly naming things

The accessibility failures were small and specific:

-   **Icon-only links had no accessible name.** A row of module icons showed its labels in a hover tooltip that is `display: none` until hover, so a screen reader heard nothing. Each link got an `aria-label`.
-   **Eight links just said "Learn more".** Each got its destination as visually hidden text. The site has 18 languages and no translation service, so the hidden text reuses headings that are already translated rather than adding new strings.
-   **White text on brand blue measured 2.6:1.** Our design system already defines a darker `brand-ink` blue for fills behind white text, so the fix was using the token it prescribes.
-   **Language buttons were 28 × 20 pixels**, under the 24-pixel minimum touch target.

## The bug Lighthouse could not see

After the image change, Lighthouse looked good. Then Claude compared the new build against production by measuring every image's layout box in headless Chrome, and most of the homepage screenshots measured 0 × 0 pixels. The page was up to 3,600 pixels shorter.

The cause was one old rule in our stylesheet:

`img {     width: 100%;    height: auto; }`

A percentage width resolves against the parent. When the parent is absolutely positioned, a flex item or `w-auto`, it sizes itself _to the image_, and before a lazy image has loaded there is nothing to size to: the image collapses to zero. Production had never shown it because every image loaded eagerly. Our `width` and `height` attributes did not help either, because the CSS width overrides them.

Lighthouse missed it for an honest reason: those images now passed the image audits, so they simply dropped off the report. An audit tells you about the things it checks, and none of them check that your layout is unchanged.

The fix was an explicit width class (`w-[320px]`) on each image whose parent sizes to it. One image is sized by `max-height` instead, where a fixed width would squash it, so it stays eager.

## Scaling it to every page, and how we checked it

The homepage fix was hand-written. The rest of the site had about 120 more image tags, many fed from data arrays, so the second round was built to be mechanical:

-   **A build-time module** reads the natural size of every raster file in `public/`, so no one writes image dimensions by hand.
-   **A `<SiteImg src :width>` wrapper** turns a rendered width into WebP, height and a 2x candidate. One gotcha: IPX will not enlarge an image, so the 2x candidate of a 500-pixel file shown at 350 pixels is still 500 pixels wide. With density descriptors (`2x`) the browser divides that by two and lays the image out at 250 pixels on a retina screen. Pinning `sizes` to the rendered width keeps the layout width fixed whatever file comes back:
    
    ``const width = Math.min(Number(props.width) || nw, nw) return {   width,  height: Math.round(width * nh / nw),  sizes: `${width}px`,  densities: nw > width ? 'x1 x2' : 'x1' }``
    
-   **Widths came from measurement, not guesswork.** A crawler loaded all 71 English pages at seven widths: the top edge of each of our breakpoint bands (412, 639, 767, 1023, 1150 and 1279 pixels) plus 1920. It recorded every image's `offsetWidth` and `offsetHeight`, which ignore animation transforms, so a mid-animation frame cannot fake a difference.
-   **The same crawl verified the result**, run twice against the new build: once with lazy images left unloaded (the state that collapses) and once scrolled to load everything. We compared both with production, image box by image box, plus each page's height.

Running the production crawl twice first told us how noisy the measurement was: 3 of 497 page-and-width combinations disagreed with themselves. One was a page with a third-party iframe; the other two were pages caught measuring exactly one viewport tall. Background tabs throttle `requestAnimationFrame`, our heroes are revealed by GSAP animations, and in a background tab those never ran. Launching Chrome with `--disable-background-timer-throttling`, `--disable-renderer-backgrounding` and `--disable-backgrounding-occluded-windows`, and waiting past the pages' own 4-second animation fallback, made the crawl repeatable.

The final comparison matched production on every page except three that are not about images: one with a third-party iframe, one with a rotating testimonial, and one blog post whose scheduled link depended on our local content cache. Along the way it turned up one long-standing bug: 17 module pages drew an `<img src="">` beside every feature icon.

One false lead is worth recording. A module page measured CLS 0.036 locally against 0 in production. Instead of guessing, Claude built the unchanged `master` branch locally and measured it: 0.036 to 0.038, so the shift was not ours. After the deploy, production showed it too, on one run of two (0 and 0.035): a timing effect, which Lighthouse attributed to an icon and a web font arriving late. It is still under the 0.1 threshold, and now on the list as its own fix.

## Results

Homepage, production, one Lighthouse 13.5 run before and one after:

Mobile before

Mobile after

Desktop before

Desktop after

Performance

61

**73**

85

**91**

Accessibility

93

**100**

89

**96**

SEO

92

**100**

92

**100**

LCP

11.2 s

**5.0 s**

2.1 s

**1.4 s**

CLS

0.204

**0.004**

0.128

0.128

Page weight

2,629 KB

**805 KB**

2,696 KB

**923 KB**

Single runs are noisy: mobile Speed Index read worse (3.3 s against 4.7 s), which we would re-run before trusting. The desktop accessibility score is 96 rather than 100 because Lighthouse caught two hero links overlapping mid-animation.

For the rest of the site, image transfer per page, production before and after, from Lighthouse (two runs after, which agreed within a kilobyte):

Page

Before

After

A sport landing page

1,916 KB

387 KB

A module page

1,015 KB

301 KB

A case study

1,037 KB

309 KB

A blog post

1,530 KB

909 KB

The blog post saves least because its hero is a generated social card, which IPX cannot read.

## What you can reuse

-   **Measure layout, not pixels.** Recording every element's `offsetWidth`/`offsetHeight` at the edge of each breakpoint band is a regression check that ignores animations and needs no screenshot baseline. Run it with lazy content unloaded _and_ loaded.
-   **Measure your noise before your change.** Crawl production twice. Anything that disagrees with itself is the crawler, not your code.
-   **Grep your CSS for `img { width: 100% }`** before adding `loading="lazy"`. If the image's parent sizes to its content, give the image an explicit width or keep it eager.
-   **Pin `sizes` when an image service won't enlarge**, or your 2x candidate can shrink the layout on retina screens.
-   **Read observed LCP beside simulated LCP**, and fix what is competing for bandwidth before the element itself.
-   **Check your image service's cache defaults.** Ours were 60 seconds and 4 hours.
-   **Turn off background throttling** when you crawl animated pages in parallel tabs.
-   **Fix contrast with your design tokens**, not ad-hoc colours, so the fix holds next time.

The agent part was useful because it closed the loop on every step: measure, change, measure again, and stop to explain when the numbers disagreed with the story. Every push and merge still went through a person.

## Related reading

This site also answers AI agents directly: every page is available as markdown, and the [developer page](https://www.sporty.plus/developers) lists the endpoints and how they are versioned. If you run a sports club rather than a website, the rest of the [SportyPlus blog](https://www.sporty.plus/blog) is for you.
