10x Faster Builds: Migrating a Legacy CRA Monolith to Vite
A CRA monolith with 45-second builds and a team that had stopped enjoying frontend work.
Client name withheld under NDA. Industry, scope, and results are accurate.
The Challenge
Three years of growth on Create React App with Chakra UI, and the codebase was showing every year of it.
Production builds took 45 seconds. Hot reload after a file save? 8 to 12 seconds — enough time to lose your train of thought, check Slack, and forget what you were working on. The webpack config had been overridden so many times through CRACO that nobody on the team could confidently explain what half of it did.
Chakra UI's runtime CSS-in-JS was eating the performance budget. Lighthouse sat at 62. The engineering team — six developers — had normalized the slowness. They'd learned to work around it, but you could see it in the velocity numbers: features that should take a day were taking three.
The CTO wanted modernization without a full rewrite. Fair enough — but you can't "gradually" fix a 45-second build. You need a different foundation entirely.
The Approach
I started where I always start: measuring things. Ran webpack-bundle-analyzer on the production build and catalogued every CRACO override. Chakra UI accounted for a disproportionate share of the bundle, and the override layer had grown so complex that upgrading CRA itself was effectively off the table.
My instinct was to rip everything out and start clean. But this team was shipping 30+ PRs a week — a feature freeze wasn't an option. So I set up Vite alongside the existing CRA setup, both running in parallel. The team kept shipping on CRA while I migrated route by route, invisible to their release cycle.
A few decisions I made early that paid off:
- SWC over Babel. The
@vitejs/plugin-react-swcplugin gave near-instant JSX transforms. The team wasn't using any Babel plugins worth keeping — a clean break. - Explicit dependency pre-bundling. I mapped every heavy dependency into
optimizeDeps.includeto eliminate cold-start re-bundling. The kind of thing that saves five minutes of debugging every morning. - Custom chunk splitting. Vite's defaults put too much in the initial bundle. I wrote a
manualChunksstrategy to keep it under 180 kB gzipped.
The Chakra-to-Tailwind migration was the most tedious part — 140+ components to audit and replace. Not technically hard, just repetitive. I built a thin compatibility layer (cx utility + shared token map) so both styling approaches could coexist during the transition, then replaced components module by module. A CI size-budget check caught any accidental Chakra re-additions.
Last piece: developer experience. vitest dropped in as a Jest replacement without rewriting a single test file — same assertion API, 3x faster. I parallelized the GitHub Actions pipeline so lint, type-check, test, and build ran concurrently. Total CI wall-time went from ~8 minutes to ~3.
Tech Stack
The Results
The numbers were immediate:
- Production builds: 45 s → 3.8 s. A genuine 10x, measured on the same CI runner hardware.
- HMR: 8-12 s → under 200 ms. Developers got their feedback loop back.
- CI total wall-time: ~8 min → ~3 min. Across 30+ weekly PRs, this compounded into hours saved.
- Lighthouse: 62 → 91. Removing Chakra's runtime CSS-in-JS overhead and switching to Tailwind's purge-based output did most of the heavy lifting.
The less measurable impact was bigger. Engineers said development "felt fun again." Two team members who'd been avoiding frontend work started contributing UI for the first time.
The migration shipped with zero production incidents and no feature freeze.