Spring til indhold
Website Optimization With Claude Code: What Lighthouse Found, and What It Missed

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 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:

MobileDesktop
Performance6185
Accessibility9389
SEO9292
LCP / CLS11.2 s / 0.2042.1 s / 0.128
Page weight2.6 MB2.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 beforeMobile afterDesktop beforeDesktop after
Performance61738591
Accessibility931008996
SEO9210092100
LCP11.2 s5.0 s2.1 s1.4 s
CLS0.2040.0040.1280.128
Page weight2,629 KB805 KB2,696 KB923 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):

PageBeforeAfter
A sport landing page1,916 KB387 KB
A module page1,015 KB301 KB
A case study1,037 KB309 KB
A blog post1,530 KB909 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.

This site also answers AI agents directly: every page is available as markdown, and the developer page lists the endpoints and how they are versioned. If you run a sports club rather than a website, the rest of the SportyPlus blog is for you.