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.
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.
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.
Layer 1 - dependency normalization
Semver manifestESLint import ruleMigration scriptLayer 2 - lazy boundary enforcement
Design-system core splitReact.lazy route gatesAsync chunksLayer 3 - CI bundle budget gate
Webpack stats parserPer-remote budget filePipeline 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.