A design system replaces 60 per-component entry points with a single index export. No consuming application changes an import. On the next release all 40 applications report route bundles up by 90 to 140 KB compressed, and the design system's own tests are green. What is the right first fix?
Show the full answer Hide the answer
The diagnosis
A bundler may drop an unused export only if it can prove that not evaluating the module changes nothing observable. A single barrel that re-exports 60 modules means importing one component pulls in a graph of 60, and the bundler now needs two things to prune it: ES module syntax the whole way down, and a promise that evaluating those modules has no side effects.
Without "sideEffects": false in package.json — or a precise list of the files that do have them — a
stylesheet import or a module-level registerIcon() anywhere in the 60 is a side effect the bundler must
preserve, so the module stays. One CommonJS interop boundary, or a transpiler emitting Object.defineProperty
at module top level, has the same effect. 90 to 140 KB compressed is roughly 0.3 to 0.45 MB decoded, which is
about the size of the whole library: the pruning stopped entirely rather than partially. On a route that
previously decoded 1.4 MB of JavaScript that is a third more main-thread work at start-up, paid by every user
of every one of the 40 applications.
The tests are green because the design system's tests import components directly and assert nothing about a consumer's bundle. Nothing in this pipeline measures the thing that regressed.
The fix, in order
- Declare the side effects honestly:
"sideEffects"listing the CSS and polyfill files, everything else prunable. - Keep per-component entry points published through the
exportsmap, so a consumer who wants one component gets a graph of one. - Move component CSS out of module side effects into a layer consumers import once.
- Add a size assertion to the design system's own CI — a fixture app that imports exactly one component and fails when its output grows — so this cannot regress silently again.
Why the other options fail
- Deep imports into internals. It works, and it makes the internal file layout a public contract for 40 consumers, so no file can ever be moved again. It also leaves the packaging defect in place for the next consumer who uses the documented import.
- More aggressive minification. Minification runs after the module graph is decided. It compresses the code that was kept, saving single-digit percentages. It would be the right answer if the growth were in your own identifiers rather than in modules that should not be there.
- Sixty separate packages. A defensible structure in its own right, and here it is a quarter of release
engineering to fix a two-line
package.jsondefect, and it multiplies version-skew bugs across 40 consumers who will not upgrade in step.
When this is the wrong first fix
If the components genuinely share a runtime registry or a theme provider that must be initialised at module load, then the side-effect-free declaration is a lie and in production it ships missing styles or unregistered icons — a failure that passes CI and appears only in the consumer's build. In that case the first move is per-component entry points plus one explicit initialisation import that consumers call once, with the barrel kept for teams who genuinely want everything. Verify before declaring: grep the package for top-level calls and CSS imports, and count them.
The honest trade-off in packaging is that a barrel costs bytes and buys discoverability. One documented
import path is easier to teach and to lint than 60, and for an internal library consumed by three applications
on the same build toolchain the saving is not worth the exports map. It flips the moment the consumer count
reaches double figures or the consumers stop sharing one bundler, because from then on you are paying the
overhead in everyone's routes and cannot see it.