Skip to main content
Back to Blog
5 min read

When Lighthouse CI dropped 20 points — 8 hours to find it

Performance score fell from 96 to 76 overnight. No code changes, no new dependencies. The cause was a combination of a Vercel edge function cold start and an image that wasn't lazy-loaded.

performancenext.js

Monday morning. Lighthouse CI fails on the staging deploy. Performance score: 76. Last week: 96. No one pushed code over the weekend. No dependency updates. No config changes.

I spent 8 hours tracking this down. Here's the debugging path.

Hour 1: The obvious checks

First thought: flaky test. Lighthouse scores vary by ±3–5 points between runs due to network and CPU conditions. A 20-point drop isn't variance — it's a regression.

I ran Lighthouse locally: 94. Close enough to the baseline. Not a regression in the code.

I ran Lighthouse on the deployed staging URL: 76. Consistent with CI.

The gap between local and deployed told me the issue was infrastructure, not code.

Hour 2: Waterfall analysis

I opened Chrome DevTools → Performance tab on the staging URL. The waterfall showed:

  1. HTML: 180ms (normal)
  2. Main JS bundle: 120ms (normal)
  3. Font file: 2,100ms (not normal — usually 80ms)
  4. Hero image: 350ms (normal)

The font file was loading from a different CDN than usual. I checked the <link> tag — it pointed to Google Fonts via fonts.googleapis.com. That hadn't changed. But the response time had.

I curl'd the font URL from the Vercel edge location:

curl -o /dev/null -w "%{time_total}" https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700

2.3 seconds. From my local machine: 45ms.

Hour 3: The font detour

Google Fonts was slow from the Vercel edge. Why? I checked Vercel's edge function logs. The staging deployment was in the iad1 (Virginia) region. Google Fonts serves from the nearest CDN node. But the Lighthouse CI runner was in a different region — and the edge function was making a cross-region request to Google Fonts.

Wait — fonts should be cached on the CDN. I checked the response headers:

cache-control: private, max-age=86400

private means the CDN can't cache it. Only the browser can. On a fresh Lighthouse run (empty cache), every font request goes to origin. And from the CI runner's location, origin was slow.

Fix: Self-host the font files. Download the WOFF2 files, put them in /public/fonts/, and reference them with @font-face in CSS.

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-display: swap;
  font-weight: 100 900;
}

After deploying self-hosted fonts: font load time dropped from 2,100ms to 40ms. Lighthouse score: 88. Better — but still not 96.

Hour 5: The image that wasn't lazy

8 points still missing. I ran Lighthouse with the "Treemap" view. The LCP element was the hero image — correct. But the LCP time was 2.8s, up from 1.2s.

I checked the hero <Image> component:

<Image src="/hero-bg.webp" alt="..." fill priority sizes="100vw" />

priority means loading="eager" and adds a preload hint. This was correct — the hero image should load eagerly. So why was LCP slow?

I checked the network tab. The hero image loaded fine. But a second large image below the fold was also loading eagerly:

// About section — below the fold
<Image
  src="/about-photo.webp"
  alt="..."
  width={600}
  height={800}
  priority // ← this was the problem
/>

Someone had added priority to the about section photo during a content update. This told the browser to preload two large images simultaneously. The hero image competed for bandwidth with the about photo, increasing LCP by ~1.5 seconds.

Fix: Remove priority from the below-fold image.

<Image
  src="/about-photo.webp"
  alt="..."
  width={600}
  height={800}
  // No priority — let the browser lazy-load it
/>

After deploying: LCP dropped to 1.3s. Lighthouse score: 95. Close enough to the 96 baseline.

Hour 7: Why CI caught it but local didn't

Local Lighthouse runs at full bandwidth and low latency. The font and image issues were hidden by fast local connections. The CI runner simulates a throttled mobile connection (4G), where the bandwidth competition between two priority images and a slow font actually mattered.

This is exactly why you run Lighthouse in CI, not just locally.

Hour 8: Preventing it from happening again

I added three guards:

  1. Lighthouse CI budget in .lighthouserc.js:
ci: {
  assert: {
    assertions: {
      'categories:performance': ['error', { minScore: 0.9 }],
      'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
    },
  },
},

This fails the CI check if performance drops below 90 or LCP exceeds 2.5s. No more silent regressions.

  1. ESLint rule for priority on images: A comment in the Image component documenting that only one image per page should have priority.

  2. Self-hosted fonts as a project rule: No more Google Fonts links. All fonts vendored into /public/fonts/.

The timeline

TimeActionScore
0hNoticed CI failure76
2hIdentified Google Fonts latency from edge76
4hSelf-hosted fonts88
6hRemoved extra priority from below-fold image95
8hAdded CI budgets + prevention rules95 (guarded)

20-point drop. Two root causes, both invisible locally. 8 hours to find, 10 minutes to fix. That's performance debugging.


Seeing performance regressions you can't explain? Let's talk — I run Lighthouse 98 on production sites and can audit your performance pipeline.