Tree-shaking side-effect-free component libraries
A single "sideEffects": false line, the field every bundling guide tells you to add, is the fastest way to ship a web component library whose tags never upgrade: the bundler proves your customElements.define() call is unreferenced, prunes the module that contains it, and the consumer is left with an inert <acme-button> element. This deep-dive reproduces that failure, explains why registration is a side effect bundlers are entitled to drop, and shows how to scope sideEffects so tree-shaking and tag registration coexist.
This is a deep-dive under Packaging & Publishing, within the Distribution, Testing & Tooling section.
Problem statement: the tag that never upgrades
Consider a library that publishes a button and, following standard advice, marks the whole package side-effect-free for optimal tree-shaking:
{
"name": "@acme/components",
"type": "module",
"sideEffects": false,
"exports": { ".": { "import": "./dist/index.js" } }
}
// dist/index.js — the single entry the library re-exports from
export class AcmeButton extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<button part="btn"><slot></slot></button>`;
}
}
// Registration side effect, colocated in the same entry module:
customElements.define('acme-button', AcmeButton);
A consumer uses the tag declaratively but, naturally, never references the AcmeButton class by name in JavaScript:
// app.js — imports the package only for its registration side effect
import '@acme/components';
<acme-button>Buy</acme-button>
After a production build, the button renders as plain text with no shadow root, no part="btn", no slot. In the console:
> customElements.get('acme-button')
undefined
> document.querySelector('acme-button') instanceof HTMLElement
true // it's a generic unknown element, never upgraded
The define() call has vanished from the bundle. Nothing in app.js reads AcmeButton, the package swore it had no side effects, so the bundler removed the entire module as dead code.
Root cause: define() is a side effect, and you told the bundler it wasn’t
Tree-shaking is static dead-code elimination built on ES module semantics. A bundler keeps a module’s exports only if something imports and uses them; it keeps a module’s top-level statements only if either an export from that module is used, or the module is declared to have side effects. The sideEffects field (a webpack convention now honored by Rollup, esbuild, Vite, and Parcel) is a promise from the package author: “evaluating these modules for their imports alone changes nothing observable, so if you don’t use their exports you may delete them entirely.”
customElements.define('acme-button', AcmeButton) breaks that promise. It mutates the global CustomElementRegistry — the shared, document-wide registry that the Custom Element Registry & Definition maintains — so that any matching tag in the DOM upgrades to the custom class. That mutation is the entire point of the import, and it is observable everywhere. It is, by the precise definition the bundler uses, a side effect.
When you write "sideEffects": false, you assert no module in the package has such effects. The bundler trusts you. Because app.js imports the package purely for effect (import '@acme/components') and never references AcmeButton, the static analysis concludes the module’s only export is unused and its side-effect promise is “none” — so the whole module, define() included, is safe to delete. The tool did exactly what the manifest authorized. The registry mutation, the one thing that had to survive, is the one thing the flag told it to discard. The relevant rule is in the webpack sideEffects documentation and is honored identically by Rollup’s treeshake.moduleSideEffects and esbuild’s sideEffects support.
Production-safe fix: scope sideEffects to the registration modules
The fix is to stop lying to the bundler. Split the pure class export from the side-effectful registration, then declare a precise sideEffects allowlist that names only the registration modules. The pure class stays fully tree-shakeable; the registration module is protected.
// dist/button/button.js — PURE. No top-level side effects.
export class AcmeButton extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<button part="btn"><slot></slot></button>`;
}
}
// dist/define-button.js — SIDE-EFFECTFUL by design and by name.
import { AcmeButton } from './button/button.js';
// Idempotent guard: safe under HMR and accidental double-import.
if (!customElements.get('acme-button')) {
customElements.define('acme-button', AcmeButton);
}
{
"name": "@acme/components",
"type": "module",
"sideEffects": ["**/define-*.js"],
"exports": {
"./button": {
"types": "./dist/button/button.d.ts",
"import": "./dist/button/button.js"
},
"./define-button": {
"types": "./dist/define-button.d.ts",
"import": "./dist/define-button.js"
}
}
}
Now consumers choose their contract explicitly. To self-register a tag, import the protected module for its effect:
// app.js — the define-* module matches the allowlist, so it is never pruned.
import '@acme/components/define-button';
To control registration manually (custom tag name, lazy registration, scoped registries), import the pure class and define it yourself — and that import is tree-shaken away if unused:
import { AcmeButton } from '@acme/components/button';
customElements.define('my-buy-button', AcmeButton);
The ["**/define-*.js"] glob tells every bundler: treat any file matching define-*.js as having side effects (never prune it), but keep tree-shaking everything else. The class module, having no top-level side effects, is still eliminated when unused — so an app that imports only the class for its own registration pays for nothing extra, while an app that imports define-button reliably gets a working tag. This pure-vs-register split also depends on a correct entry map; see configuring package.json exports for the matching subpath and types-ordering rules.
Verification: prove the define() call survives
Confirm the fix with a bundle analyzer and a runtime registry check — do not trust the source, trust the built output.
# Build a minimal consumer that imports the define module, then inspect output.
$ npx esbuild app.js --bundle --minify --format=esm --outfile=out.js
# 1. The define call must be present in the emitted bundle.
$ grep -c "customElements.define" out.js
1 # ✅ survived (0 means it was pruned)
// 2. Runtime assertion — the tag is registered and upgrades.
console.assert(
customElements.get('acme-button') !== undefined,
'acme-button was not registered — define() was tree-shaken'
);
const el = document.createElement('acme-button');
document.body.append(el);
console.assert(el.shadowRoot !== null, 'tag never upgraded — no shadow root');
For a visual audit, generate a treemap with rollup-plugin-visualizer or source-map-explorer out.js and confirm the define-button module node is present in the graph. Run the same build with the broken "sideEffects": false config to see the module disappear from the treemap — the side-by-side comparison makes the pruning unmistakable. A regression test that asserts customElements.get(...) is defined after import is the cheapest permanent guard, and it pairs naturally with the contract testing covered in Contract & Visual Testing.
When to mark side-effect-free vs. preserve
| Module / situation | sideEffects treatment |
Why |
|---|---|---|
Pure class export (button.js) |
Eligible for tree-shaking (not in allowlist) | No top-level mutation; drop it if unused |
Registration module (define-*.js) |
In the allowlist — always preserved | Mutates the global registry; pruning breaks the tag |
A blanket "sideEffects": false library that self-registers |
Avoid | Bundlers will drop define(); the canonical failure above |
| Polyfill / global patch module | Preserve (allowlist or true) |
Side effect is the purpose |
CSS imported for effect (import './x.css') |
Add "*.css" to the allowlist |
Otherwise styles vanish from the bundle |
| Pure utility / helper modules | Tree-shakeable | Default-correct; the win you want |
Auto-registering “everything” entry (index.js that defines all tags) |
Allowlist it explicitly | It is one big side effect by design |
Rule of thumb: sideEffects should be an allowlist, never false, for any package that registers custom elements. A component library is, almost by definition, not side-effect-free — its reason for existing is to mutate the registry — so the only safe global claim is a precise enumeration of which modules do the mutating.
Related
- Packaging & Publishing — the parent topic on the full package contract and the split-entry pattern.
- Configuring package.json exports for web components — the subpath and condition map the pure/register split relies on.
- Distribution, Testing & Tooling — the grandparent section on shipping and proving components.
- Custom Element Registry & Definition — the global registry that
define()mutates, the side effect at the heart of this issue.