Why sideEffects: false Deleted Your CSS (and How to Fix It)

This walkthrough sits under Tree Shaking and Dead Code Elimination inside JavaScript Bundle Optimization & Code Splitting. You added "sideEffects": false to a package to unlock tree shaking, the bundle got pleasingly smaller, and the production build shipped unstyled components. The flag is a promise to the bundler that importing any module from this package does nothing except define exports — and a stylesheet import does exactly the thing you just promised it would not.

The fix is not to remove the flag. It is to declare which files do have side effects.

Rapid Diagnosis: Confirm the Bundler Dropped It

  • Compare dev and production. Styles present in development and missing in production is the signature: dev builds usually skip tree shaking entirely.
  • Search the output for a known selector. If a class from the missing stylesheet does not appear in any emitted CSS file, it was eliminated, not merely overridden.
  • Check which package declares the flag. The offending package.json may be an internal workspace package rather than your app's.
  • Look for other side-effectful imports. Polyfills, global registrations, web component definitions and analytics bootstraps fail the same way and are less visible than missing styles.
What the bundler is allowed to delete With sideEffects false, an imported stylesheet with no used exports is eliminated along with unused JavaScript; with a scoped array the stylesheet is preserved while the unused module is still removed. Same graph, two permissions sideEffects: false index.js (entry) Button.js ✓ unused.js ✗ button.css ✗ styles deleted sideEffects: ["*.css"] index.js (entry) Button.js ✓ unused.js ✗ button.css ✓ styles kept, JS still shaken

Root Cause Analysis

1. The flag is a global promise. "sideEffects": false says every module in the package is free of side effects. A CSS import's entire purpose is its side effect, so the claim is false and the bundler acts on it.

2. Only production builds enforce it. Development builds usually skip minification and elimination, so the bug is invisible until a production deploy — often days after the change.

3. Re-export barrels hide the import. An index.js that re-exports a component whose module imports its stylesheet gives the bundler a clean opportunity to drop the whole module when only a sibling export is used.

4. Other side effects fail silently. Missing CSS is visible. A dropped polyfill or an unregistered custom element produces a runtime error on some browsers only, which can survive review and testing entirely.

Step-by-Step Resolution

1. Replace the blanket flag with a scoped array.

json
{
  "name": "@internal/ui",
  "sideEffects": ["*.css", "*.scss", "./src/polyfills/*.js", "./src/register-*.js"]
}

Expected outcome: unused JavaScript is still eliminated, while stylesheets and registration modules are preserved.

2. Keep the pattern list honest as the package grows. Any new file whose import does something beyond defining exports must be added.

javascript
// A quick audit: which modules perform work at import time?
// grep for top-level statements that are not declarations or exports.
// rg -n '^(?!import|export|//|/\*|\s*$)' src --glob '*.js' | head -40
// trade-off: this is a heuristic and will flag constants and type-only files.
// Use it to build a candidate list, then confirm each one by reading it.

3. Import styles where they cannot be orphaned. If a component's styles belong to it, import them from the component module rather than from a barrel, so the two share a fate.

javascript
// Button.jsx — the style import lives with the code that needs it.
import './button.css';
export function Button(props) { /* … */ }
// trade-off: co-located imports make per-component CSS easy to reason about,
// but they also mean the style is only included when the component is. For a
// design system's base styles, a single explicit entry import is safer.

4. Verify with a production build before merging, not after deploying. Add a check that fails when expected CSS is missing.

bash
# Fail if a known selector from each package's stylesheet is absent from output.
for sel in '.btn-primary' '.card-header' '.modal-backdrop'; do
  grep -rq -- "$sel" dist/assets/*.css || { echo "MISSING $sel"; exit 1; }
done
# trade-off: selector-presence checks are coarse and break when class names are
# refactored. They are still the cheapest guard against a whole stylesheet
# vanishing, which is the failure this page exists to prevent.

Verification

Build for production and diff the emitted CSS against the previous release. A sudden drop in total CSS bytes is the leading indicator — far more reliable than noticing an unstyled component in review.

Then render the affected components in a real browser, not just a unit test. Tree shaking operates on the bundle graph, so a test that imports the module directly will pass while the built application fails.

Keep the CSS budget visible alongside the JavaScript one:

json
{ "ci": { "assert": { "assertions": {
  "unused-css-rules": ["warn", { "maxNumericValue": 40000 }],
  "total-byte-weight": ["error", { "maxNumericValue": 1400000 }]
} } } }

If CSS bytes fall sharply while JavaScript is unchanged, treat it as a regression until proven otherwise — that pattern is almost always elimination rather than optimisation.

FAQ

Should I just remove sideEffects entirely?

No — omitting it makes the bundler assume every module might have side effects, which disables most cross-module elimination and inflates the bundle. The scoped array gives you the same safety with none of the cost. Removing the field is the right move only for a package that genuinely is side-effect-heavy throughout, such as a polyfill collection.

Does this apply to CSS modules too?

Yes, and it is subtler. A CSS module import does produce a value — the class-name map — so it looks like a normal export, but the stylesheet injection is still a side effect. If the imported map is unused on some path, the whole module can be dropped along with its styles. List the CSS module files in sideEffects exactly as you would plain stylesheets.

Why did this only break in production?

Because development builds skip the optimisation. Bundlers keep the module graph intact in dev for fast rebuilds and useful stack traces, and only run elimination in production mode. Any bug that depends on tree shaking is therefore invisible locally — which is why a production build belongs in the pre-merge pipeline, not only in the release step.

How do I know which files in my package have side effects?

Read every module that is not purely declarative and ask what happens when it is merely imported. Stylesheets, polyfills, custom-element registrations, service-worker installs, global monkey-patches and anything that mutates a shared registry all qualify. A quick heuristic is to look for top-level statements that are neither imports, exports nor declarations — those lines run at import time. Confirm each candidate by reading it, because the heuristic also flags harmless constant tables and type-only modules.

Does the sideEffects field affect my application, or only libraries?

Both, and applications are the case people forget. The field is read from every package.json in the graph, including your app's own. Setting it to false at the root of an application that imports global stylesheets, a polyfill entry or an analytics bootstrap will drop those in the production build exactly as it does for a library. Applications are also where the failure is most visible, because the missing styles are the ones the user sees first.

Can I keep the byte saving without any risk?

Largely, yes: the elimination that the flag unlocks applies to unused JavaScript exports, and listing your side-effectful files does not prevent any of that. The one thing you give up is elimination of unused stylesheets, which the bundler can no longer prove are safe to drop. In practice that is a small, bounded cost — and the CSS-level answer is a per-component style strategy that only ships the rules a rendered component needs, not a packaging flag.

/html>