open atlas
↑ Back to track
React, zero to senior RCT · 14 · 03

Performance regressions: the slow creep nobody ships on purpose

Bundle creep, hydration growth, and memo decay degrade p75 without one bad release. Defense in layers: hard-fail CI byte budgets, synthetic checks on a fixed device, RUM percentiles per release. Bisect with release diffs plus profile diffs; give every route an owned budget.

RCT Senior ◷ 17 min
Level
FoundationsJuniorMiddleSenior

Nobody shipped a slow release. The app got slow anyway. When the team finally looked, p75 INP on the main grid had gone from 140 ms to 480 ms over two quarters — and no single deploy moved it more than 6%. The archaeology found three culprits, none of which looked wrong in review. One: the route bundle had grown 180 kB minified across fourteen sprints — about 13 kB per sprint, including a one-line PR that changed import debounce from 'lodash/debounce' to import { debounce } from 'lodash' and silently pulled the whole library in. Two: a server-rendered table had become a client component for one hover tooltip, dragging its markdown renderer and date library into the hydration bundle. Three: a refactor had added an inline style object to a memoized 3,000-row list, and the subtree had been re-rendering on every keystroke for five months — the Profiler later showed the before/after as 4 ms versus 41 ms per commit. Three mechanisms, one failure class: regressions that creep instead of crash, in increments too small for any human reviewer to feel.

The slow-creep failure class

A crash pages someone; a 12 kB bundle increase pages no one. The defining property of performance regressions is that they arrive in increments below human perception: each sprint adds a dependency here, a client boundary there, an inline prop somewhere — every diff defensible, the cumulative sum a doubled p75. Review cannot catch this class, because no reviewer holds the running total in their head, and “this PR adds 1.8% to the bundle” is invisible without tooling that prints exactly that sentence. Typical unguarded creep rates, observed across teams: 5–15 kB minified per sprint of bundle growth, and p75 INP degrading from “good” (under 200 ms) to “poor” (over 500 ms) territory over two to three quarters with no single attributable release. The defense is not heroics; it is machinery that converts invisible increments into visible failures — budgets that fail builds, synthetics that diff releases, percentiles that diff rings.

Bundle creep and hydration growth

The lodash import is the canonical creep mechanism: CommonJS lodash is not tree-shakeable, so import { debounce } from 'lodash' ships the entire library — roughly 70 kB minified, ~25 kB gzipped — where lodash/debounce ships a few kB. The class generalizes: a charting library imported for one sparkline, a date library where Intl would do, a polyfill set for browsers you dropped. The countermeasure is a hard-fail CI byte budget per route entry (size-limit or equivalent): the build fails when a route crosses its budget, with the number in the PR check. Warn-only budgets die of alert fatigue within a quarter — every team that has run them has the same story: warnings accumulate, get normalized, get ignored; the regression ships anyway. The budget must fail the build or it is decoration.

Hydration growth is the React-specific variant. In an RSC app, marking one component 'use client' pulls it and its entire import graph into the client bundle — the cascade. The Hook’s table needed one hover tooltip; the boundary flip brought a markdown renderer and a date library, +90 kB of hydration work, all to track a boolean. The fix pattern is boundary isolation: keep the table on the server and extract a leaf HoverHint client component of twenty lines. Detection is the same byte budget, applied to the client bundle per route — the cascade shows up as a step change the build refuses.

Quiz

A one-line PR changes import debounce from 'lodash/debounce' to import { debounce } from 'lodash' and the route bundle grows by about 70 kB minified. Why?

Memo decay: the silent re-render regression

Memoization is a contract over reference identity, and refactors break it without breaking anything visible. When you add a prop to a memoized component, ask yourself: is this a new object or function created inline? If yes, every render creates a new reference and the memo comparison always fails. The decay pattern: a memoized list works for months; a refactor adds style={{ paddingLeft: depth * 8 }} or an inline onSelect at the call site; every prop comparison now fails; the subtree re-renders on every keystroke. No test fails — the output is identical. No error fires — re-rendering is legal. The only witnesses are the Profiler and, eventually, the INP percentiles. The evidence discipline is before/after Profiler data: the Hook’s table showed 4 ms commits with working memo, 41 ms with decayed memo, on every keystroke of every user for five months. Lint cannot fully catch this (it cannot know which subtrees are expensive), so the defense is layered: the React DevTools Profiler in code review for changes touching hot paths (“show me the commit profile for this refactor”), plus RUM INP attribution flagging the route afterward. Memo decay is also the strongest argument for the React Compiler: auto-memoization that survives refactors removes the human from the contract.

Three detection layers and the bisect discipline

No single layer catches everything; each has a blind spot the next covers. CI budgets see bytes pre-merge — instant, per-PR, but blind to runtime cost: a memo decay ships zero new bytes. Synthetic checks (Lighthouse CI or equivalent on a fixed device profile, per PR or hourly against production) see runtime cost on one device with fixture data — they catch a hydration step change but miss what only appears at real data scale (3,000 real rows versus the fixture’s 20) or on real low-end hardware. RUM percentiles per release ring see the truth — every device, every dataset — but lag by hours and require traffic; they are the layer that catches what both gates missed, and the layer that proves the other two are calibrated. When RUM flags a regression the gates missed, the bisect discipline is two diffs: the release diff (which commits shipped in the regressed ring — small if you deploy daily, which is itself an argument for deploy frequency) and the profile diff (synthetic trace or sampled field profile of the regressed route, before versus after, to name the component or task that got slower). With release rings and daily deploys the suspect set is usually under a dozen commits; without them it is the three-week hunt.

Why this works

Why do warn-only budgets reliably fail while hard-fail budgets hold? Because a warning prices the regression at zero: the engineer who ships it pays nothing today, and the cost lands on whoever investigates the percentiles next quarter. A hard failure reprices it: the cost lands now, on the person with the most context, who can choose the cheap fix (the per-method import) while the diff is one line. Budget exceptions still happen — a legitimate feature sometimes needs the bytes — but they go through a visible bump in the config file with a reviewer sign-off, which converts silent creep into a recorded decision. The mechanism is the same as type errors: make the future cost present.

The budget contract: numbers and an owner

A perf budget that works is a per-route contract with three numbers and a name: JS bytes (e.g., 200 kB minified for the entry), LCP target (2.5 s at p75), INP target (200 ms at p75) — and an owning team. The owner clause is what makes it a contract instead of a dashboard: when the route breaches, the owning team gets the ticket, not a central “perf team” that owns everything and therefore nothing. Two operating rules keep it honest. Ratchet: when a route beats its budget comfortably, the budget tightens to lock in the gain — otherwise headroom gets silently spent. Exception protocol: raising a budget requires a recorded sign-off in the config, so every increase has a name, a date, and a reason attached. Teams running this report the same shape of outcome: creep stops being a quarterly archaeology project and becomes a per-PR conversation that takes five minutes while context is fresh.

Quiz

CI byte budgets are green and the per-PR Lighthouse run on a fixture dataset is green, yet RUM p75 INP for the grid route jumps 60% in the new release ring. What class of regression slips both gates, and what is the bisect move?

Recall before you leave
  1. 01
    Name the three creep mechanisms from this lesson with their detection and fix.
  2. 02
    Lay out the three detection layers, the blind spot of each, and the bisect discipline when the last layer fires.
Recap

Performance regressions are a failure class defined by imperceptibility: no single diff moves a metric enough for a human to object, and two quarters later p75 INP has tripled with no attributable release. The three React-flavored mechanisms: bundle creep, where unshakeable import forms — the CommonJS lodash named import being the canon — add tens of kilobytes in one-line PRs at typical unguarded rates of 5–15 kB per sprint; hydration growth, where one client boundary flip pulls an entire import graph into the client bundle to track one boolean, fixed by isolating a tiny client leaf; and memo decay, where a refactor introduces an inline prop, the reference-equality contract breaks, and a memoized subtree re-renders on every keystroke for months — zero bytes, zero failing tests, witnessed only by Profiler before/after data and INP percentiles. The defense is three layers with complementary blind spots: hard-fail CI byte budgets (warn-only budgets reliably die of normalization — a warning prices the regression at zero), synthetic checks on a fixed device that see runtime cost but at fixture scale, and RUM p75 per release ring, which sees real scale and real hardware but lags and needs traffic. When the truth layer fires, the bisect discipline is the release diff plus the profile diff — frequent deploys keep the suspect set small. And the budget is a contract, not a dashboard: per-route bytes, LCP, and INP targets with an owning team, a ratchet that locks in gains, and recorded exceptions — converting silent creep into five-minute conversations held while the context is still one line long. Now when you see a PR that looks harmless — a one-line import change, an inline style on a list row, a new client boundary — ask which layer would catch it if it were a regression, and whether that layer is actually in place.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.