Performance Is a Feature (and It Has a Budget)
Nobody ships a slow app on purpose — it happens 40 kilobytes at a time. On bundle budgets, memoizing with evidence, and why measuring comes before optimizing.
Our checkout page once gained 180 kilobytes of JavaScript in a single quarter, and I can tell you exactly how: a date library for one "3 days ago" label, a chart package for a sparkline an A/B test later deleted, a drag-and-drop library for a feature that shipped behind a flag and never turned on, and a grab bag of "small" utilities. No single PR looked expensive. Every one of them passed review.
That's the thing about frontend performance: nobody ships a slow app on purpose. It happens 40 kilobytes at a time, each one individually defensible, none of them individually measured. Meanwhile, on the other side of the codebase, someone was carefully wrapping a 12-line component in React.memo "for performance" — while the checkout bundle quietly doubled.
We optimize what we can see in the code and ignore what we'd have to measure. It should be exactly the other way around.
Performance is UX, so treat it like UX
I try to hold onto this framing: a slow interface isn't a technical deficiency, it's a design the user experiences. The blank screen during a 5-second load is a screen you shipped. The 300ms of jank when a dropdown opens is an interaction you designed, just accidentally. Users don't file tickets that say "your Largest Contentful Paint is 4.1 seconds"; they just perceive the product as heavy and use it less. On mid-range Android over cellular — which is a lot more of your traffic than your MacBook suggests — every kilobyte of JavaScript is paid for twice: once to download, once to parse and execute.
Once you frame it that way, "we'll optimize later" sounds like "we'll design the UI later." Performance regressions are UX regressions, and the review bar should match.
Budgets turn vibes into decisions
The single most useful tool here is embarrassingly simple: a number, agreed on in advance. For example: "the checkout route ships at most 200KB of gzipped JS" or "PRs that grow any route's initial bundle by more than 10KB need a justification in the description."
The magic isn't the specific number — it's that a budget converts an unwinnable vibes argument ("is this library worth it?") into a tractable one ("we have 14KB left; this costs 38KB; what are we removing, or why is this the exception?"). It gives reviewers standing to ask the question without it being personal, and it makes the cost of the twentieth dependency visible before it lands, not in next quarter's audit.
Wire it into CI with any bundle-size action so the delta shows up right in the PR. A diff comment that says +38.2KB gzipped changes conversations in a way no style guide ever will. And before adding a dependency at all, spend thirty seconds checking its real cost and whether you need it:
// Do you need date-fns (or worse, moment) for this?
const daysAgo = (date: Date) =>
Math.round((Date.now() - date.getTime()) / 86_400_000);
// Or the platform, which ships at 0KB:
new Intl.RelativeTimeFormat("en").format(-3, "day"); // "3 days ago"
The platform has quietly gotten very good. Intl, fetch, structuredClone, CSS for animations that used to need a library — the cheapest dependency is the one you don't add.
Memoization: prescription drugs, not vitamins
Now for the other half of the story: the optimizations we do by hand. React.memo, useMemo, useCallback. I think of these as prescription medication — genuinely effective against a specific diagnosed condition, mildly harmful when taken "just in case."
Sprinkled speculatively, they cost you: real memory and comparison work on every render, and — the bigger price — readability. A component with nine useCallback wrappers is asserting nine dependency arrays that must stay correct forever, and each one is a place for a stale-closure bug to hide. Worse, speculative memoization usually doesn't even work: one inline object prop, one un-memoized child, one context change, and the whole careful chain re-renders anyway. You paid the complexity tax and didn't get the performance.
The discipline is the same as with bundles: measure first. Open the React Profiler, interact with the actual slow thing, and look at what re-renders and how long it takes. Then:
- If a measured expensive subtree re-renders because of a parent's unrelated state — that's the diagnosis
React.memotreats. - If a measured expensive computation reruns each keystroke — that's
useMemo. - If nothing measurable is slow — the memo is decoration. Leave the code simple.
Often the profiler points somewhere better than memoization anyway: state that lives too high in the tree (move it down), a list rendering 3,000 rows (virtualize it), or a context that changes on every keystroke (split it). Structural fixes beat memo Band-Aids and don't leave dependency arrays behind.
A note on the current moment: AI assistants have a noticeable habit of emitting useCallback and useMemo on everything — it pattern-matches to "professional React code" in their training data. I regularly review generated components where every handler is wrapped and nothing was ever profiled. The code isn't wrong, but it's pre-blurred: harder to read, asserting optimizations nobody validated. My review comment is always the same: "what measurement motivated this?" For human or AI authors alike, "none" means unwrap it. (The React Compiler is steadily making this whole category automatic — one more reason not to hand-fossilize memoization everywhere.)
Measure like a user, not like a build tool
Bundle size is the easiest thing to gate, but it's a proxy. What actually matters is felt experience, and we have decent names for it now: how fast does the main content appear (LCP), how quickly does the page respond when tapped (INP), does the layout jump around (CLS). Two habits cover most of the ground:
- Look at field data, not just your laptop. Real-user monitoring — even a lightweight web-vitals beacon — tells you what the 75th percentile actually experiences. Your dev machine on fiber is the 99.9th percentile.
- Test the slow path on purpose. CPU throttle 4x, network to Slow 3G, and click through the flow you just built. Ten minutes, once per feature. It's astonishing how much this catches — and how differently you design once you've felt your own app on a slow device.
The budget-minded review
What I actually do on PRs, condensed:
- New dependency? Ask for its gzipped cost and the one-sentence justification. Check if the platform or an existing dep already covers it.
- Bundle delta visible? If CI reports +10KB or more on a route, that's a conversation, not a veto — but it's a conversation.
- Memoization added? Ask what was measured. No measurement, no memo.
- Heavy feature? Should it be lazy-loaded behind interaction or route (
import()is right there)? - Felt it? For anything user-facing and substantial: throttled run-through before approval.
None of this is heroic optimization work. It's just refusing to let the cost be invisible. Slow apps aren't built in a day — they're approved, 40 kilobytes at a time, by reviewers who didn't have a number to point to. Give yourself the number.