+1 (781) 556-6029

Cutting a React bundle: read the analyzer before you split

"The bundle is too big" is the most common performance complaint we hear and the one most often acted on without evidence. The usual sequence is a team reads a blog post, wraps a dozen routes in React.lazy, swaps one library for a smaller one, and ships. Sometimes the numbers move. Often they do not, because the weight was somewhere else, or because bytes were never the reason the page felt slow.

This is the workflow we use. It starts with a treemap and ends with a field measurement, and the code changes in between are ordinary.

Step 0: Decide whether bundle size is your problem

JavaScript weight shows up in two places. It delays LCP when the render-blocking or hydration-critical script has to be downloaded, parsed, and executed before content is usable. It damages INP when a long task on the main thread blocks an interaction. If your LCP is fine and your INP is poor, the fix is more likely a re-render or an unyielded long task than a smaller bundle — see the profiler workflow in Find the re-render before you reach for memo.

So check the field data first. Pull the CrUX or RUM numbers for the routes that matter, and note where the failing metric is. If mobile LCP on the entry route is over 4s and the largest contentful element only appears after hydration, bundle work is likely to pay. Write the current number down before you touch anything.

Step 1: Build a treemap from real source maps

Raw totals are not diagnostic. You need a per-module breakdown of the shipped bytes, and it has to come from the production build's source maps, not from node_modules sizes.

  • Vite: rollup-plugin-visualizer, with gzipSize: true and brotliSize: true.
  • Next.js: @next/bundle-analyzer, and separately next build output, which prints per-route First Load JS and — the number people miss — the shared chunk size.
  • Anything with source maps: source-map-explorer dist/assets/*.js.

Read the treemap with two questions. Which single modules are largest, and what is the shape of the long tail? A bundle dominated by three known libraries is a dependency problem with a short fix list. A bundle that is 60% application code in many small modules is an architecture problem, and no dependency swap will help it.

Record gzipped or brotli sizes, not raw. Raw bytes overstate text-heavy code — locale tables and icon sets compress far better than minified logic — and the transfer number is what the user waits for. Parse and execution cost tracks raw size more closely, so keep both in view if your problem is INP rather than LCP.

Step 2: Remove weight before you move it

Code splitting relocates bytes. Deletion removes them. Do the deletions first, because they are usually smaller diffs with clearer results.

Duplicate copies of the same package. The most common cause of a mysteriously large bundle. Two versions of a date library, three of a state library, two Reacts. Run npm ls <package> or pnpm why <package>, and look for the same name appearing at different versions in the treemap. Fix with a version alignment, a dependency override/resolution, or by upgrading the transitive dependent. Duplicate React in particular also causes runtime hook errors, so it is worth checking for on principle.

Barrel files defeating tree-shaking. import { Button } from '@acme/ui' where @acme/ui/index.ts re-exports 200 components. Bundlers can tree-shake this when every module is side-effect free and marked as such, and quite often one module is not — a CSS import, a polyfill, a module-level registration — so the whole barrel is retained. Two checks: set "sideEffects": false (or an accurate array) in the package's package.json, and try a deep import (@acme/ui/button) for one heavy component to see whether the treemap changes. If it does, the barrel is the problem, and a codemod over import statements is worth more than any splitting work.

Libraries you are using at 5%. Moment for two format calls, where Intl.DateTimeFormat is built in. A full icon package imported as a namespace. A charting library shipped on every route so one dashboard can draw one graph. Replace or scope these — but only the ones the treemap shows near the top. Swapping a 4kB library because a blog post said to is noise.

Polyfills for browsers you do not support. Check the build's browserslist target. A stale target aimed at legacy browsers can add tens of kilobytes of transpiled helpers and core-js entries to every chunk.

Step 3: Split where the user actually changes context

Now relocate what is left. Two split points are worth the complexity, and the rest usually are not.

Routes. The entry route should not download the admin console. With a data router, use the route-level lazy loading the router provides so the chunk fetch overlaps with data loading; with plain React.lazy, wrap the route element in Suspense with a layout-stable fallback. In Next.js App Router this is mostly automatic per route segment, so the equivalent work is checking the shared chunk and finding what dragged a large client component into it.

Heavy, interaction-gated widgets. The rich-text editor, the chart pack, the PDF viewer, the map. const Editor = lazy(() => import('./Editor')) behind the click that opens it, ideally with a prefetch on hover or intent so the chunk is warm before the user commits.

What to avoid: splitting at component granularity across the app. Each split is a network request, a fallback, and a chance to shift layout. Dozens of small chunks on one route makes the page slower than a single chunk of the same total size, particularly on high-latency mobile connections.

Two details that bite. First, a lazy component whose fallback has a different height than the loaded component contributes to CLS; reserve the space. Second, a dynamic import() inside a render path that runs on every keystroke will not re-download, but the promise state churn can produce the flicker people blame on Suspense. Hoist the import.

Step 4: Check what is in the shared chunk

After splitting, one number usually decides whether the work landed: the size of the chunk every route loads. A library reachable from a module that the root layout imports ends up there, and it takes a treemap to notice.

The common causes we find are a shared utils barrel that transitively imports a heavy formatter, an analytics or error-reporting SDK imported eagerly at the top of the app rather than loaded after first paint, and a design-system theme object that pulls in every component through a token map. All three are fixed by changing where the import lives, not by adding another split.

Step 5: Re-measure, and be willing to revert

Re-run the production build and the treemap. Compare the entry route's transferred JavaScript before and after. Then wait for field data — one to two weeks of RUM or a CrUX update — and check whether LCP and INP at p75 moved on the routes you changed. Lab numbers confirm the bytes left; only field data confirms users noticed. The methodology is in Measuring Core Web Vitals properly.

If p75 LCP did not improve, say so in the pull request and consider reverting the splits. They cost readability and add loading states; keeping them for a number that did not move is a maintenance debt with no return. It is a normal outcome — plenty of slow React pages are slow because of a server response time, an unoptimized hero image, or a blocking third-party tag, and no amount of bundle work touches those.

Keep it from growing back

Bundle size regresses one dependency at a time. Two cheap controls hold the line: a size budget checked in CI against the built entry chunks, failing the build on an increase above a threshold you choose, and a note in the pull request template asking for the reason when a new dependency is added. Neither requires a tool purchase, and both are more effective than an annual cleanup.

If you want the analysis done once, thoroughly, with the evidence attached to each finding, that is part of our architecture and performance review. If your treemap is already clear about what is wrong, you may not need us for it.