Dev.to WebDev 🛠 Dev 👁 0 📖 8 min read

# Proving your React performance wins are real — Core Web Vitals in production and CI regression gates

The performance patterns post covers what to optimize. This one covers verifying those wins survived the real world: collecting INP, LCP, and CLS from actual users, interpreting p75 instead of median, and blocking regres

The performance patterns post covers what to optimize. This one covers verifying those wins survived the real world: collecting INP, LCP, and CLS from actual users, interpreting p75 instead of median, and blocking regressions in CI before they reach production.

I added React.memo around a heavy list component, ran the profiler, and watched the render time drop from 42ms to 8ms. Shipped it. Two weeks later a stakeholder asked why the page still felt sluggish on mobile. The profiler had been running on my M2 MacBook with no network throttling and a warm cache. Real users were on mid-range Android phones on 4G in areas with variable signal.

The optimization was real. The measurement was wrong. This post is about closing that gap: collecting Web Vitals from actual users, reading the numbers that matter (p75, not median), and catching regressions in CI before they ship.

Why the React Profiler and Lighthouse in DevTools aren't enough

The React Profiler measures JavaScript execution time on your machine. It's the right tool for finding which components are re-rendering too often and why. It tells you nothing about what a real user experiences: network conditions, device CPU throttling, paint time, layout shifts from images loading, or interaction delays caused by third-party scripts.

Lighthouse in DevTools is synthetic — it simulates a slow device, but it's still running on your machine, and it measures a single cold load. Your p75 user has a browser with 30 tabs open, a CPU that's already warm from background tasks, and a network that drops packets mid-flight. The only way to know their experience is to measure it.

The three metrics that actually matter right now

Core Web Vitals are Google's production health signals. The three that matter for interactive React apps:

INP (Interaction to Next Paint) — replaces FID as of March 2024. Measures the worst interaction delay across the entire page visit: click, tap, keypress to first pixel change. Under 200ms is good. This is the one your React optimizations directly affect. Heavy re-renders, synchronous event handlers, and non-concurrent renders all show up here.

LCP (Largest Contentful Paint) — time until the largest visible element (hero image, above-fold heading) is painted. Under 2.5s is good. React-specific causes: blocking JS that delays the first render, large images without preload hints, lazy-loaded components above the fold.

CLS (Cumulative Layout Shift) — sum of unexpected layout shifts during the page lifetime. Under 0.1 is good. React-specific causes: content rendered after hydration that shifts existing elements, images without explicit dimensions, dynamic banners inserted above content.

Collecting Web Vitals from real users

The web-vitals library is the official implementation from the Chrome team. It reports the same values Chrome sends to Google Search Console. Three lines to collect all three metrics:

// lib/vitals.ts
import { onINP, onLCP, onCLS, type Metric } from 'web-vitals'

function sendToAnalytics(metric: Metric) {
  // Replace with your endpoint — see options below
  navigator.sendBeacon('/api/vitals', JSON.stringify({
    name: metric.name,     // 'INP' | 'LCP' | 'CLS'
    value: metric.value,   // ms for INP/LCP, unitless score for CLS
    rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
    id: metric.id,         // stable ID for deduplication across page loads
    navigationType: metric.navigationType, // 'navigate' | 'reload' | 'back-forward'
    url: window.location.href,
    // Add whatever segmentation you need
    deviceMemory: (navigator as any).deviceMemory,
    connection: (navigator as any).connection?.effectiveType,
  }))
}

export function initVitals() {
  onINP(sendToAnalytics)
  onLCP(sendToAnalytics)
  onCLS(sendToAnalytics)
}
// app/layout.tsx (Next.js) or your root component
import { useEffect } from 'react'
import { initVitals } from '@/lib/vitals'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    initVitals()  // runs once per page load, reports on unload via sendBeacon
  }, [])

  return <html><body>{children}</body></html>
}

sendBeacon fires reliably on page unload — it doesn't block navigation and completes even when the page closes. Don't use fetch here; it gets cancelled when the user navigates away.

Where to send the data — three realistic options

Vercel Speed Insights is the path of least resistance if you're on Vercel. One package install, automatic collection, pre-built dashboard. It costs nothing on the hobby plan for low-traffic sites.

// Vercel Speed Insights — zero config option
// npm install @vercel/speed-insights
import { SpeedInsights } from '@vercel/speed-insights/next'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        {children}
        <SpeedInsights />  {/* that's it */}
      </body>
    </html>
  )
}

Google Analytics 4 if you're already using it. Send as a custom event — GA4 stores the raw values and you can build custom reports with percentile breakdowns.

// lib/vitals.ts — GA4 variant
import { onINP, onLCP, onCLS, type Metric } from 'web-vitals'

function sendToGA4(metric: Metric) {
  if (typeof window.gtag !== 'function') return
  window.gtag('event', metric.name, {
    value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
    metric_id: metric.id,
    metric_value: metric.value,
    metric_rating: metric.rating,
    non_interaction: true,  // don't count as a user interaction for bounce rate
  })
}

export function initVitals() {
  onINP(sendToGA4)
  onLCP(sendToGA4)
  onCLS(sendToGA4)
}

Your own endpoint — the right choice when you want to join vitals data with server-side context (user segment, A/B test variant, backend latency). A Route Handler that writes to Postgres or your analytics warehouse:

// app/api/vitals/route.ts
export async function POST(req: Request) {
  const metric = await req.json()
  // Insert into your analytics table — keep it fire-and-forget
  await db.vitals.insert({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    url: new URL(metric.url).pathname,  // strip query params before storing
    device_memory: metric.deviceMemory,
    connection_type: metric.connection,
    recorded_at: new Date(),
  }).catch(() => {})  // never let analytics writes fail the request

  return new Response(null, { status: 204 })
}

Read p75, not the average

Google's threshold for Core Web Vitals is met when your p75 is in the 'good' range — the 75th percentile user, not the average. The average is distorted by outliers in both directions: your localhost test runs and a user on 2G in a tunnel. p75 is the user who has a slightly-worse-than-typical experience. If p75 is good, 75% of your users are in the 'good' band.

-- If you're storing vitals in Postgres:
SELECT
  name,
  percentile_cont(0.75) WITHIN GROUP (ORDER BY value) AS p75,
  percentile_cont(0.50) WITHIN GROUP (ORDER BY value) AS p50,
  COUNT(*) FILTER (WHERE rating = 'good')::float / COUNT(*) AS good_rate,
  COUNT(*) AS sample_count
FROM vitals
WHERE
  recorded_at > NOW() - INTERVAL '7 days'
  AND url = '/dashboard'  -- check per-page, not sitewide
GROUP BY name
ORDER BY name;

Check per page, not sitewide. A landing page and a data-heavy dashboard have completely different performance profiles. Aggregating them hides where the real problems are.

Catching regressions in CI with Lighthouse CI

RUM data tells you what happened in production. A CI performance gate tells you what's about to happen before you merge. The two complement each other: CI prevents new regressions, RUM catches degradations from real-world conditions CI can't simulate (third-party scripts loading slowly, CDN cache misses).

Lighthouse CI is the standard tool. It runs against your built app (not DevTools), stores results across commits so you can compare, and fails the build when scores drop below your budget.

# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [pull_request]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }

      - run: npm ci
      - run: npm run build

      # Start your production build locally for LHCI to test against
      - name: Start server
        run: npm run start &
        env:
          PORT: 3000

      - name: Run Lighthouse CI
        uses: treosh/lighthouse-ci-action@v11
        with:
          urls: |
            http://localhost:3000/
            http://localhost:3000/dashboard
          budgetPath: ./.lighthouserc.json
          uploadArtifacts: true
          temporaryPublicStorage: true  # posts a comment with the report URL
// .lighthouserc.json — performance budgets per URL
{
  "ci": {
    "assert": {
      "preset": "lighthouse:no-pwa",
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.8 }],
        "first-contentful-paint": ["warn",  { "maxNumericValue": 2000 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "total-blocking-time":      ["error", { "maxNumericValue": 300 }],
        "cumulative-layout-shift":  ["error", { "maxNumericValue": 0.1 }],
        "interactive":              ["warn",  { "maxNumericValue": 3800 }]
      }
    }
  }
}

Use error for regressions that must never ship (LCP, CLS, TBT) and warn for things you're tracking but not blocking on yet. Start with generous budgets and tighten them as your baseline improves — setting aggressive budgets on a codebase that's never been measured is how you get a CI gate that everyone learns to ignore.

One thing I got wrong: INP vs TBT in CI

Lighthouse CI reports Total Blocking Time, not INP. TBT is a lab metric that correlates with INP but isn't the same number. I spent two weeks fixing TBT in CI while INP in production barely moved, because the interactions that triggered the worst INP in prod (a specific filter dropdown that ran an expensive re-render) weren't part of my CI test flow.

The fix: write a Playwright script that actually performs the interactions your users perform, then measure INP from it. The web-vitals library has a standalone build you can inject into Playwright:

// tests/inp.spec.ts — measure INP for a real interaction in CI
import { test, expect } from '@playwright/test'

test('filter interaction INP is under 200ms', async ({ page }) => {
  await page.goto('/dashboard')

  // Inject web-vitals and set up INP collection before any interaction
  await page.addInitScript(() => {
    (window as any).__inp_values = []
  })

  // Import web-vitals from CDN in the page context
  await page.evaluate(() => {
    const script = document.createElement('script')
    script.type = 'module'
    script.textContent = `
      import { onINP } from 'https://unpkg.com/web-vitals@4/dist/web-vitals.attribution.js'
      onINP(m => { window.__inp_values.push(m.value) }, { reportAllChanges: true })
    `
    document.head.appendChild(script)
  })

  // Perform the interaction you want to measure
  await page.click('[data-testid="filter-dropdown"]')
  await page.click('[data-testid="filter-option-active"]')
  await page.waitForTimeout(500)  // give INP time to fire

  const inpValues: number[] = await page.evaluate(() => (window as any).__inp_values)
  expect(Math.max(...inpValues)).toBeLessThan(200)
})

Closing the loop

The workflow I settled on: Lighthouse CI in every PR for LCP/CLS/TBT budgets. Playwright INP tests for the five or six specific interactions that showed up worst in RUM. web-vitals sending to Vercel Speed Insights for the full production picture, checked weekly against per-page p75 breakdowns.

The React optimization patterns — collocated state, memo boundaries, virtualized lists — are worth doing. But without measurement they're guesses. The memoization that saved 34ms in the profiler might not move your p75 INP at all if the bottleneck is actually a third-party script loading on the same thread. Measure first, optimize second, measure again.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.