Writing

Vercel Speed Insights score dropped after Lighthouse runs: filter lab traffic with beforeSend

By Mohamed Gado · 13 Nov 2026 · 4 min read

On this page6 sections
  • Why did my Speed Insights score drop after running Lighthouse?
  • How do I tell a real regression from lab noise?
  • How do I exclude Lighthouse from Speed Insights?
  • Where do I mount it in the App Router?
  • Did the filter fix the score?
  • What I would do next time

One route on my Next.js site showed a Real Experience Score of 77 in Vercel Speed Insights, "Needs Improvement", while every other route showed 100. The page was fine. PageSpeed Insights scored it 99 on desktop. The cause was my own testing: a Lighthouse sweep that ran 44 throttled audits from my machine, which Speed Insights counted as real visits. The fix was a beforeSend function on <SpeedInsights> that drops events from Lighthouse, headless browsers and anything with navigator.webdriver set.

Why did my Speed Insights score drop after running Lighthouse?#

Speed Insights collects Core Web Vitals from a script in the browser. It cannot tell a visitor from an audit, so a Lighthouse run in Chrome counts as a visit, and so does a PageSpeed Insights run. Lab runs are slow on purpose. Lighthouse throttles the CPU and the network to imitate a mid-range phone, so every one of them reports poor numbers. One of my runs also hit an 8 second cold-start task.

Speed Insights groups scores by route. A route with 193 events barely moves when 44 bad ones arrive. A route with 7 events does. That is what happened: /writing/[slug], my newest route, had 7 events on desktop, most of them mine.

How do I tell a real regression from lab noise?#

I almost started optimizing the page. Three checks showed it was not needed before I touched any code:

  1. Click the route row in Speed Insights to open its daily chart. The drop was on one day only, and the score was above 90 the day before.
  2. Compare that date with your own testing. A Lighthouse sweep ran that day.
  3. Run the page through the PageSpeed Insights web UI. Desktop gave 99, with FCP 0.3 s, LCP 0.6 s, CLS 0.006 and TBT 100 ms.

The live page agreed. Layout shifts totalled 0.002, clicks on the copy button and the outline took 48 ms, and the page was served from the edge cache with a TTFB of about 0.25 s.

A route score built on fewer than about 30 events is a lead. Treat it that way until the daily chart and your own run history have been checked against it.

How do I exclude Lighthouse from Speed Insights?#

<SpeedInsights> from @vercel/speed-insights accepts a beforeSend prop. It receives each event before it is sent, and returning null drops it. The same prop exists on <Analytics> from @vercel/analytics.

The filter reads the user agent. Lighthouse and PageSpeed Insights add Chrome-Lighthouse to it. Puppeteer and Playwright usually run HeadlessChrome or set navigator.webdriver. WebPageTest and GTmetrix identify themselves too, as PTST and GTmetrix.

// lib/analytics.ts
const SYNTHETIC_UA = /Chrome-Lighthouse|HeadlessChrome|PTST|GTmetrix/i
 
export function isSyntheticAgent(ua: string, webdriver: boolean): boolean {
  return webdriver || SYNTHETIC_UA.test(ua)
}
 
// One gate for both beacons: Web Analytics pageviews and Speed Insights vitals.
export function dropOwnVisits<T>(event: T): T | null {
  try {
    if (isSyntheticAgent(navigator.userAgent, navigator.webdriver === true)) {
      return null
    }
    return isOptedOut(window.location.search, window.localStorage)
      ? null
      : event
  } catch {
    // Storage blocked (private mode, disabled cookies): count the visit.
    return event
  }
}

isOptedOut is the older half of the same gate. It reads a ?va-ignore=1 query flag, stores it in localStorage, and keeps my own browser out of the counts. The catch matters: if storage is blocked, the visit is counted, because losing a real visitor is worse than keeping a stray one.

Where do I mount it in the App Router?#

beforeSend is a function, and a Server Component cannot pass a function to a Client Component. The root layout is a Server Component, so the beacons live in a small client wrapper:

// components/site-analytics.tsx
'use client'
 
import { Analytics } from '@vercel/analytics/next'
import { SpeedInsights } from '@vercel/speed-insights/next'
 
import { dropOwnVisits } from '@/lib/analytics'
 
export function SiteAnalytics() {
  return (
    <>
      <Analytics beforeSend={dropOwnVisits} />
      <SpeedInsights beforeSend={dropOwnVisits} />
    </>
  )
}

The layout renders <SiteAnalytics />. The /next entry points report the route template, such as /writing/[slug], instead of each URL, which is why scores group by route.

The component had already filtered <Analytics>, but <SpeedInsights> had no filter. Sharing one function means a new beacon cannot quietly skip it. When you add a third one, pass it dropOwnVisits.

Did the filter fix the score?#

The filter keeps future lab runs out. It does not delete the events already collected, so the old 77 stays in the chart until those events age out of the window you are viewing. Check the fix on a fresh run instead: open the page with Lighthouse and confirm that no new event arrives for that visit. A quick way: in DevTools, open Network conditions, set the user agent to a custom string containing Chrome-Lighthouse, reload, and filter the Network tab by speed-insights. No vitals request should go out. Clear the override and it comes back.

I pinned the filter with a test that feeds it a Lighthouse user agent, a headless one and a webdriver flag, and I watched it fail before the filter existed.

What I would do next time#

Use the PageSpeed Insights web page for single checks. I called its API in parallel from a script and used up the anonymous daily quota in one go. And before profiling any route with a low score, open its daily chart first.

The site this came from is described in the portfolio case study. A separate rendering bug from the same site is covered in Next.js soft 404: why notFound() returned 200 behind loading.tsx.

More writing

  • Next.js sitemap lastmod never changes: derive the date from git, not a sync timestamp
  • Clerk auth in Next.js: from authMiddleware to proxy.ts and auth.protect()
  • Lemon Squeezy subscription statuses: who should keep access, and the two bugs I shipped
See the projects these came from
  • Why did my Speed Insights score drop after running Lighthouse?
  • How do I tell a real regression from lab noise?
  • How do I exclude Lighthouse from Speed Insights?
  • Where do I mount it in the App Router?
  • Did the filter fix the score?
  • What I would do next time