DZ

Writing

How I Cut an Enterprise React Build by 49% and Reduced TBT by 88%

21.2 MB to 10.7 MB of build output. Lighthouse 67.6 to 86.3. Total Blocking Time 228 ms to 28 ms, across a micro-frontend React platform with 14 routes and a shared design system.

Performance optimization in a large React application is rarely about finding one bad library and replacing it.

In this case, the application was an enterprise React platform built with micro-frontends, around 14 active routes, and a shared design system. The production build had grown to approximately 21.2 MB of generated client-side JavaScript.

The goal was not simply to make the number smaller. It was to answer three questions: what are we shipping, when are we shipping it, and what work are we making the browser do at startup? That became the foundation for the entire optimization effort.

1. First, I had to understand where the cost was coming from

I started with production build analysis using Rspack, RSDoctor, and Lighthouse, rather than immediately changing dependencies. The initial metrics were:

MetricBeforeAfter
Generated build output21.2 MB10.7 MB
Lighthouse score67.686.3
Total Blocking Time228 ms28 ms

There was not one single cause. There were several different types of cost:

What?

  • Dependencies
  • Design system
  • Static assets

When?

  • Initial load
  • Route chunks
  • Dynamic imports

Work?

  • JS execution
  • Component init
  • Third-party scripts

A dependency can be small but unnecessary on the initial path. A large dependency can be perfectly acceptable if it is loaded only when the user needs the feature. And a relatively small bundle can still produce poor Web Vitals if it performs too much work during startup.

2. Diagnosing and pinpointing the bloat

Finding specific inefficiencies in a 21.2 MB codebase required pairing automated build telemetry with targeted code parsing:

  • RSDoctor graph analysis. Mapping the full dependency tree exposed critical overhead: the main application and its sub-dependencies were pulling in different, conflicting versions of the same utility packages at the same time.
  • A two-model diagnostic loop. Claude evaluated the macro-level chunk-splitting architecture. Codex parsed monolithic files as a syntax scanner, flagging unoptimized runtime object imports, non-tree-shakable code, and eager initialization.

3. Remove and reduce unnecessary cost

Once the debt was isolated, four architectural patterns were inflating the bundle:

  • Redundant and legacy libraries. Moment.js, which does not tree-shake, was replaced with Day.js. Standard lodash imports moved to lodash-es, so only the utilities actually used reached production.
  • Importing objects instead of types. TypeScript files were importing heavy runtime objects when only the type was required. Cleaning those up let the bundler discard the underlying modules entirely.
  • Duplicate packages. Package manager resolutions in package.json forced sub-dependencies onto unified versions and dropped megabytes of duplicate code.
  • Legacy polyfills. Auditing the Browserslist configuration and dropping polyfills modern browsers no longer need trimmed the baseline runtime.

4. The bigger win: change when code is loaded

Reducing the size of a dependency was not always the best optimization. Sometimes the better solution was simply not to load it until the user needed it.

Excel export made this obvious. The library was unused during startup and only required when someone clicked Download. It moved behind an asynchronous dynamic import:

export async function downloadExcel(data) {
  // The library loads on demand, isolated from initial boot
  const XLSX = await import("xlsx");
  // generate and download the Excel file
}

The architecture shifted from downloading every library at startup to loading heavy features on demand. The same principle applied across the 14 active routes with React.lazy().

Splitting into a high volume of small chunks can introduce network waterfalls and layout instability. Every lazy-loaded boundary was paired with a CSS skeleton that reserved layout space while the chunk downloaded, so Cumulative Layout Shift stayed in check.

5. The design system as a loading boundary

The shared design system was a major source of bundle inflation. It exposed a broad set of elements through centralized entry points, so a small application inherited massive dependencies. The package was refactored into clear dependency boundaries, separating lightweight presentation primitives from heavy components:

Lightweight

  • Button
  • Input
  • Badge

Heavy

  • Data grid
  • Date picker
  • Other feature components

Heavy internal icon SVG sets came out. The design system was acting as a redundant wrapper around Lucide, so applications imported the icon library directly.

Global SCSS was decoupled from the JavaScript runtime and loaded as a parallel network resource. Critical Google fonts and core stylesheets were prefetched to avoid a flash of unstyled text.

6. Static data and assets don’t always belong in JavaScript

A large JSON dataset used only by a specific demo route lived inside src, which forced the bundler to include it in the primary module graph.

The dataset moved out of the source tree and into /public. Moving an asset to the public folder does not shrink the payload, but it removes it from the critical compilation path. It became an asynchronous request, so users downloaded those megabytes only if they navigated to that demo route.

7. Native Rspack tooling and less JavaScript work

Bundle reduction alone did not explain the 88% improvement in Total Blocking Time. The browser’s main thread still had to do less work during startup.

Instead of a heavy third-party compression chain, the Rspack configuration compressed and optimized assets during the production build. The AG Grid implementation was audited as a work problem, not only a size problem: selective module imports dropped unused grid features, and expensive grid initialization was deferred until after the initial paint. Non-critical telemetry, including FullStory, was shifted later in the lifecycle so it no longer occupied the main thread during boot.

8. Micro-frontends made the problem architectural

Because the application used micro-frontends, dependency optimization could not stop at a single bundle. A typical setup duplicates dependencies across host and remote containers.

Rspack’s Module Federation sharing configuration shared stable, high-value dependencies, such as React and core layout utilities, across build boundaries. The objective was not to share every library. Sharing everything introduces strict version coupling and runtime risk. Code was shared only where the reduction in duplication justified the architectural coupling.

9. AI was an accelerator, not the optimization strategy

Refactoring more than 10,000 lines of production code introduces real regression risk. AI coding agents accelerated the transformations inside a strict sandbox.

Claude handled macro architectural design and route boundaries. Codex ran the micro-level code transformations, one isolated change at a time: move this utility behind a dynamic import without changing its exported behavior. Codex was token-efficient for that workflow and reliable at debugging compilation and TypeScript mismatches.

The process stayed metric-driven: measure, understand, form a hypothesis, define a safe change, implement with AI assistance, build and test, then measure again. AI accelerated implementation. Measurement and engineering judgment drove the strategy.

10. Key architectural takeaways

The valuable lesson was not a specific library replacement. It was learning to design performance in terms of system boundaries:

  • Dependency boundary. Does this dependency need to exist in the global scope?
  • Loading boundary. Does the user need this code right now?
  • Component boundary. Does this heavy component need to initialize on boot?
  • Architecture boundary. Are the micro-frontends and design-system packages sharing code without breaking team autonomy?

The question is not only how to make the bundle smaller. It is what the user needs for this specific journey, when they need it, and what computation the browser can avoid until then. Shifting from file-level tweaks to boundary enforcement is what holds up at this scale.