← cd ..

Performance is a team KPI, not a hero project

November 2, 2026·7 min read#performance#frontend#leadership

Every performance win I've shipped has had the same shape: a quarter of focused work, a graph that goes down and to the right, a celebratory thread — and then, a year later, a page that's slow again.

The numbers were real. I've watched a platform's bounce rate fall from 75% to 28% on the back of one of those pushes. What wasn't real was the assumption underneath it: that performance is a project with an end date.

It isn't. Performance is a property of your codebase that decays by default, because every force acting on it pushes the same direction. Marketing needs a tag. Product needs a carousel. Someone pulls in a date library to format one timestamp. Nobody wakes up intending to make the site slower — they each make it 40ms slower, and forty milliseconds twenty times is a second.

Why the win always erodes

If you go looking for what undid a performance push, you rarely find a villain. You find a dozen reasonable decisions:

  • A third-party script added through a tag manager, so it never passed through a pull request at all.
  • import { format } from 'some-enormous-lib' — a barrel import that quietly defeats tree-shaking and drags the whole module graph into the bundle.
  • A hero image shipped at 2400px as a PNG because it looked crisp on the designer's monitor.
  • A webfont added without font-display, so text is invisible while it loads.
  • A component marked "use client" because it needed one event handler, pulling its entire subtree to the browser.

None of these arrive in review labelled "performance regression." They arrive as a one-line diff with a green CI run. That's the actual problem: your pipeline enforces types, tests, and lint, and says nothing at all about the thing your users physically feel.

Measure what users feel, not what's easy to measure

The first fix is to stop treating Lighthouse as the scoreboard. Lighthouse is a lab tool: one machine, one throttled network profile, one run. It's genuinely good for diagnosis — it'll tell you which image is blocking your paint — and genuinely bad for tracking, because the number moves when the CI runner has a noisy neighbour.

What you track is field data: real sessions, real devices, real networks. The three Core Web Vitals and their "good" thresholds:

Metric Measures Good
LCP when the main content appears ≤ 2.5s
INP responsiveness to interactions ≤ 200ms
CLS unexpected layout shift ≤ 0.1

And you track them at the 75th percentile, not the average. An average is a story about nobody; p75 is a promise that three out of four visits were at least this good. It's also what Google assesses you on, which is a convenient alignment of incentives.

Collecting it is about fifteen lines:

import { onLCP, onINP, onCLS } from "web-vitals";
 
function report(metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating, // "good" | "needs-improvement" | "poor"
    path: location.pathname,
  });
  navigator.sendBeacon("/api/vitals", body);
}
 
onLCP(report);
onINP(report);
onCLS(report);

Send it somewhere you can slice by route. "The site is slow" is unactionable. "p75 INP on the product page regressed from 180ms to 340ms on Tuesday" is a ticket with an owner.

Put the budget where the build is

Monitoring tells you that you regressed. A budget stops you regressing. The difference matters, because by the time a dashboard turns red the code is already in production and the person who wrote it has moved on to something else.

So the budget goes in CI, per route, and it fails the build:

{
  "size-limit": [
    { "path": ".next/static/chunks/app/product/**/*.js", "limit": "130 kB" },
    { "path": ".next/static/chunks/app/checkout/**/*.js", "limit": "95 kB" }
  ]
}

The exact numbers barely matter — set them slightly above where you are today and ratchet down. What matters is where the conversation happens. With a budget, adding a 60kB dependency to checkout becomes a discussion in the pull request, with the author, while the alternative is still cheap. Without one, it becomes an archaeology project next quarter.

Budgets also do something subtler: they make cost visible to people who don't think in kilobytes. "This library will put us 40kB over budget on checkout" is a sentence a product manager can reason about.

The techniques that actually move p75

In my experience the ordering is pretty consistent:

Third-party scripts first. This is almost always the biggest and least glamorous win, because it's someone else's JavaScript competing with yours for the main thread. Audit what's loading, delete what nobody can name an owner for, and defer the rest:

import Script from "next/script";
 
// Analytics doesn't need to block anything.
<Script src="https://example.com/analytics.js" strategy="afterInteractive" />
 
// Chat widget can wait for idle.
<Script src="https://example.com/chat.js" strategy="lazyOnload" />

Then ship less JavaScript. Route-level code splitting is mostly free in a modern framework; the work is in finding the heavy things that render below the fold or behind an interaction:

import dynamic from "next/dynamic";
 
const ReviewsPanel = dynamic(() => import("./ReviewsPanel"), {
  loading: () => <ReviewsSkeleton />,
});

Then images and fonts, which dominate LCP far more often than JavaScript does. Correctly sized, modern formats, explicit width/height to reserve space (CLS is usually an image or an ad slot with no reserved box), font-display: swap, and preload only the one font that's actually in your above-the-fold text.

Then rendering strategy. Static or incrementally regenerated where the content allows it, server-rendered where it doesn't, client-rendered only for what genuinely needs interactivity. Pushing work to build time is the cheapest optimisation there is, because it happens once instead of once per visitor.

Then caching. Long max-age with content hashing on static assets, sensible CDN rules, stale-while-revalidate on the things that can tolerate being a few seconds old.

The part that isn't code

Here's what I got wrong for a long time: I treated this as a technical problem and solved it technically, which is exactly how you end up repeating the push every eighteen months.

A performance champion is a single point of failure. When one person owns speed, you get great results while they have bandwidth and silent decay the moment they're pulled onto a launch. The work is making it structural:

  • Give the metric an owner per surface, not per person — the team that owns checkout owns checkout's LCP, the same way they own its error rate. Regression alerts route to that team, not to a central performance group.
  • Put it in the OKRs with a number, so it survives prioritisation. "Keep p75 INP under 200ms on the top five routes" competes for time honestly. "Be fast" does not.
  • Review it on a cadence — the same fifteen minutes you spend on uptime and error rates. If it's never discussed, it's not a KPI, it's a preference.
  • Spread the knowledge deliberately. Sessions on profiling, reading a flame graph, and bundle analysis, so the person who catches the regression is whoever happens to be in that file — not whoever happens to be me.

That last one is the real multiplier. The goal isn't a team that can execute a performance project. It's a team where a 60kB dependency gets questioned in review as a reflex, by whoever picks up the PR.

Speed isn't a sprint you win once. It's a budget you defend, every release, with everybody holding a copy.