Back to case studies
PerformancePublished

The Bundle That Wouldn't Stop Growing

Diagnosing and fixing a micro-frontend performance collapse, without touching product code

  • Performance
  • Webpack
  • Module Federation
  • Core Web Vitals
  • Bundle Analysis
Initial JS before
2.1 MB
Initial JS after
890 KB
LCP improvement
6.8s -> 2.3s

01

How you end up with a 2.1MB initial load and no obvious culprit

It didn't happen in one release. It crept in across maybe eight sprints. Each team shipped their remote independently, each build was green, each Lighthouse score on staging looked fine because staging ran on a fast internal network. Then someone opened the app on a field laptop from a warehouse floor on a 4G connection and waited thirty-two seconds for the dashboard to show anything.

The initial JavaScript payload had grown to 2.1MB uncompressed. LCP was sitting at 6.8 seconds on a mid-tier device. Nobody on any team had noticed because the degradation was distributed - no single change caused it, so no single team owned it. That is the specific failure mode that micro-frontend architectures are vulnerable to: the shell and four remotes each passing their own performance checks in isolation, while the combined runtime cost compounds silently.

Diagram

Distributed regression across eight sprints

Illustrated accumulation of the regression across independently green builds, culminating in the measured 6.8s field result.

Distributed regression across eight sprintsIllustrated accumulation of the regression across independently green builds, culminating in the measured 6.8s field result.LCP2s3.5s5s6.8sTarget thresholdeach team's build: greenS1S2S3S4S5S6S7S8Field report: 6.8sLCP (seconds)

02

What the bundle audit actually revealed

The first thing I did was run webpack-bundle-analyzer across every remote and the shell, then cross-referenced the Module Federation shared dependency manifests. Three things jumped out immediately.

First, lodash was being bundled in full by two remotes because they had imported from the root path (import _ from 'lodash') rather than using named cherry-picks or the lodash-es package. That alone was 72KB gzipped that was loading twice, because Module Federation's singleton deduplication doesn't kick in if the import paths don't match exactly across remotes.

Second, a charting library - roughly 180KB gzipped - was listed as a shared singleton in the shell's federation config but one remote had pinned a minor version that didn't satisfy the shell's semver range. Module Federation resolved this by falling back to bundling that remote's private copy. Nobody had noticed because the charts still rendered correctly.

Third, and this was the one that took longest to find: date-fns was being imported at full library size inside a utility file that was itself re-exported by the design system package. Every remote consumed the design system. Every remote was pulling in the entire date-fns library at startup, even if none of the date formatting utilities were used on the initial route.

Diagram

How duplicated dependencies reached the initial load

Schematic audit map: remote labels distinguish the affected paths, while dependency sizes reflect the measured findings described above.

How duplicated dependencies reached the initial loadSchematic audit map: remote labels distinguish the affected paths, while dependency sizes reflect the measured findings described above.ShellHost runtimeRemote ARoot importRemote BRoot importRemote CVersion mismatchRemote DDesign systemduplicate - import path mismatchlodash (full import)72KB gzipped, loaded twiceversion mismatch - private copycharting private copy~180KB gzippeddate-fns (full) via design-system re-exportLoaded at startup by every remoteduplicationversion mismatchtransitive import

03

The fix architecture: three layers, no product code touched

The rule I set for this project was that product teams shouldn't need to change application logic. The performance fix had to happen at the infrastructure layer - dependency governance, federation config, and build tooling. That constraint made the work harder but made adoption tractable.

Layer one was dependency normalization. I published a canonical shared-dependencies manifest for the platform, pinning specific semver ranges that all remotes would satisfy. Teams ran a migration script that updated their federation configs; the script caught version mismatches before CI ran. The lodash issue was fixed by banning the root import path via an ESLint rule that redirected to cherry-picked lodash-es imports. The rule was added to the shared lint config that all remotes inherited.

Layer two was lazy boundary enforcement. I audited which modules were genuinely needed on the initial render path versus deferred routes. For the design system, I split the package into a core export (components, tokens) and an extended export (date utilities, data formatting, chart helpers). The core was kept synchronous; the extended package became async via React.lazy boundaries at the route level. This alone dropped the initial parse cost by approximately 40% on the design system import.

Layer three was a bundle budget gate in CI. I wrote a small webpack stats parser that ran post-build and compared each remote's initial chunk size against a per-remote budget file checked into the platform repo. A budget breach failed the pipeline before the remote deployed. This made the next regression impossible to ship invisibly.

Diagram

Fix architecture at the infrastructure layer

Three coordinated controls reduced initial load without requiring changes to product application logic.

  1. Layer 1 - dependency normalization

    Semver manifest
    ESLint import rule
    Migration script
  2. Layer 2 - lazy boundary enforcement

    Design-system core split
    React.lazy route gates
    Async chunks
  3. Layer 3 - CI bundle budget gate

    Webpack stats parser
    Per-remote budget file
    Pipeline fail on breach

No product code changed

04

What the numbers looked like after, and what they don't tell you

LCP dropped from 6.8s to 2.3s on a mid-tier device on a throttled 4G connection. Initial JS payload went from 2.1MB to 890KB. The dashboard was usable in under three seconds on the hardware it actually ran on in the field.

What the numbers don't tell you is how much of the improvement came from things that are hard to measure: the ESLint rule that will prevent the lodash regression from ever happening again, the semver manifest that future teams use as a starting point, and the CI gate that makes every future remote justify its bundle size before merging. Those are quiet wins. They don't show up in a Lighthouse report. But six months later, when a new team joins the platform and their remote ships at 120KB because the budget file told them 130KB was the ceiling, that is the number that matters.

continue reading