Engineering
Our React 20 migration, with numbers
We moved a production logistics dashboard from React 18 to React 20 across three sprints, with the client’s traffic running the whole time. Cold builds went from 141s to 58s, the bundle shrank 22%, and two of our starting assumptions turned out to be wrong — the full metrics and the diffs that mattered are in here.
- React 20
- migration
- codemods
- build performance
- bundle size
- frontend
In March we agreed to move Fairhaven Logistics’ dispatch dashboard from React 18.3 to React 20. The app is 214 components and roughly 61,000 lines of TypeScript, used by about 900 dispatchers and drivers a day, and the client could not accept a maintenance window longer than a deploy. We planned three two-week sprints and kept their traffic on the app the whole time. This post is the ledger: what the codemods covered, what actually broke, the before-and-after numbers, and the one regression that made it to staging.
The setup
Sprint one was inventory and tooling: we upgraded the build toolchain, turned on the React 20 compiler in a shadow build, and ran the official codemods on a throwaway branch just to size the work. Sprint two was the migration itself behind a version flag. Sprint three was cleanup, deletion of the old code paths, and the test-suite slog nobody budgets for.
Two assumptions from our own proposal turned out to be wrong, and it is worth naming them. First, we assumed the vendor charting library would be the long pole — it took one day. The actual long pole was the test suite: eleven days of fixing tests that had quietly depended on render timing. Second, we assumed concurrent rendering would improve perceived performance on its own. It did not. Nothing moved until we wrapped the two heaviest filter panels in a transition; then interaction latency on the dispatch board dropped visibly.
What the codemods covered
The upgrade tooling flagged 474 call sites across the codebase. The codemods handled 412 of them cleanly — 87% — which matches what we have seen on two smaller React 20 jobs since. The remaining 62 were manual, and they clustered in three places: ref cleanup functions that the codemod would not infer, a custom test renderer from 2022, and effects that needed rethinking rather than rewriting. One recurring case, before and after:
// Before: the handler is recreated every render, so this
// effect resubscribed constantly. React 20's stricter
// effect replay turned a hidden inefficiency into a bug.
useEffect(() => {
const off = socket.on('leg:update', (leg) => setLegs(mergeLeg(leg)));
return off;
}, [socket, mergeLeg]);
// After: useEffectEvent keeps the handler fresh without
// making it a dependency. One subscription, full stop.
const onLeg = useEffectEvent((leg) => setLegs(mergeLeg(leg)));
useEffect(() => socket.on('leg:update', onLeg), [socket]);
The stricter effect replay in development found four real subscription leaks that had been shipping for over a year. That alone justified the noise.
What actually broke
The vendor problem was the one everyone predicted: the charting library, last published in 2021, still used a deprecated lifecycle method through the legacy escape hatch, and React 20 removes that hatch entirely. Our options were fork-and-patch, replace, or wrap. We wrapped — a thin adapter that converts the incoming props to derived state before the vendor code sees them — and put “replace charting library” on the client’s roadmap as a separate, honestly-priced item rather than smuggling it into this project.
The subtler breakage came from automatic batching changes. Code that had implicitly relied on two renders happening in a particular order kept working in the old version by accident. More on that below, because one of these accidents got past us.
The numbers
| Measure | React 18.3 | React 20 | Change |
|---|---|---|---|
| Cold build (CI) | 141 s | 58 s | −59% |
| Warm rebuild (local) | 3.9 s | 1.1 s | −72% |
| Main bundle, gzipped | 486 kB | 379 kB | −22% |
| Largest Contentful Paint, p75 | 2.4 s | 1.9 s | −21% |
| Hydration warnings per day | 31 | 0 | — |
Honest attribution matters here. The build-time win is mostly the toolchain upgrade the migration forced, not React itself — if you told your CFO “React 20 made builds 2.4× faster” you would be lying with a true chart. The bundle win is more directly creditable: the compiler’s automatic memoization let us delete about 1,100 lines of manual useMemo and useCallback scaffolding, and the new JSX transform output compresses better.
The regression that reached staging
An optimistic update on the driver-status pill assumed its confirmation render would land before the next user action. Under the new batching it sometimes did not, so two rapid status changes could arrive out of order and the pill would show a stale state for a couple of hundred milliseconds — occasionally permanently, until refresh. Unit tests missed it because they mocked the store; it surfaced on staging day two when a QA script hammered the status buttons. The fix was boring and correct: sequence numbers on updates, last-writer-wins. The lesson was that our test pyramid had a gap exactly where migration risk concentrates — timing.
What we'd tell you to do
- Measure before you start. Our before/after table exists because we spent half a day capturing baselines. Without it you cannot tell wins from noise.
- Run the codemods on a branch on day one — not to keep the output, but to count what they miss. That count is your real estimate.
- Budget the test suite explicitly. It was 40% of our effort and 0% of our first draft plan.
- Treat abandoned vendor libraries as their own project. Wrap now, replace deliberately, price it separately.
- Do not migrate for performance alone. Migrate for support windows and hiring; take the performance as a welcome side effect — ours was real, but only after we did the transition work by hand.
