Fixing CSS Imports Stripped by Tree-Shaking

Symptom: a component renders in development with full styling, but the same component in the production build is completely unstyled — no border, no spacing, no background color, as if the stylesheet was never linked. DevTools shows the <Button> markup rendering correctly, but the Elements panel’s Styles pane shows no rules matching .btn-primary at all. The component’s source still contains import './Button.css', and the file exists on disk with the expected rules. Nothing in the build log mentions an error.

$ grep -c "\.btn-primary" dist/assets/index-a1b2c3.css
0

The selector simply isn’t in the compiled CSS output, even though it’s in the source file the component imports.


Root Cause: A Bare Import With No Exports Looks Unused

This is a specific diagnostic scenario within the broader technique of configuring sideEffects for optimal tree-shaking. The mechanism is a direct consequence of how the sideEffects field is meant to work, applied to a case it wasn’t designed for. A statement like import './Button.css' imports no named bindings — it exists purely to trigger the side effect of the stylesheet being processed and emitted (by css-loader/MiniCssExtractPlugin in Webpack 5, or Vite’s built-in CSS handling in Rollup). When a package.json declares "sideEffects": false with no exceptions, it is telling the bundler “every file in this package can be safely dropped if none of its exports are consumed elsewhere.” A CSS import file has zero exports by definition, so under a blanket false declaration, the bundler concludes the import itself is dead code and removes it — along with every rule inside it.

This differs subtly from the failure mode covered in how to audit sideEffects in large npm packages, which focuses on third-party packages retaining too much code because of a missing declaration. This page covers the opposite and, in practice, more disruptive direction: a sideEffects: false declaration that is too aggressive, silently deleting styling that has no JavaScript export to protect it. Both failure modes stem from the same root cause — an inaccurate sideEffects contract — pulling in opposite directions.

The problem most commonly appears after one of two changes: a team adds "sideEffects": false to package.json for the first time, following generic tree-shaking advice, without accounting for CSS imports anywhere in the codebase; or a previously correct glob-based sideEffects array gets “cleaned up” to a bare false during a refactor, silently dropping the CSS exception that used to be there.

The reason this failure mode is disproportionately disruptive relative to how small the misconfiguration looks in a diff is that it produces no build error, no console warning, and no failing test in most test suites — component tests that render with @testing-library/react or similar tools typically assert on DOM structure and text content, not on computed styles, so an entire stylesheet can vanish from production while every automated test continues to pass. The regression is caught only when a human visually inspects the deployed site, often well after the change has shipped and possibly after several other unrelated deploys have layered on top of it, which makes bisecting the exact commit that introduced the problem needlessly time-consuming without the CI check described in the verification section below.

It is also worth understanding why this specific bug tends to recur even on teams that have fixed it once. sideEffects: false is frequently presented in blog posts, conference talks, and even official bundler documentation as a single-line, unconditionally beneficial optimization — “just add this to your package.json for smaller bundles.” That framing omits the CSS caveat almost universally, because most illustrative examples are pure-JavaScript utility libraries with no stylesheets in scope at all. A team that internalizes the simplified version of the advice, then later ports it to a package.json for a component library that does ship CSS, reproduces the bug in a new package without any signal that the advice needed qualification in this context. The same conservative-by-default reasoning that governs module resolution across bundlers applies here: a bundler will never guess that a side effect matters more than the bytes it costs, so the declaration has to be precise enough for every file type it covers, CSS included.

Exact Fix: Preserve CSS in the sideEffects Contract

The broken configuration

// package.json — BEFORE: strips every CSS import with no named export
{
  "name": "@app/ui-components",
  "sideEffects": false
}

The fix — explicit glob array covering every stylesheet extension in use

// package.json — AFTER: preserves CSS while still tree-shaking pure JS modules
{
  "name": "@app/ui-components",
  "sideEffects": [
    "*.css",
    "*.scss",
    "*.sass",
    "*.less",
    "src/polyfills.js"
  ]
}

The glob array format tells the bundler “these specific files may have side effects and must never be pruned based on unused-export analysis; every other file in the package is fair game.” JavaScript modules that export pure functions with no import-time side effects remain fully tree-shakeable — only the listed patterns are exempted.

Webpack 5 — confirming optimization.sideEffects reads the field at all

The package.json fix only takes effect if Webpack 5’s optimization.sideEffects flag is enabled, which it is by default in mode: 'production' but is worth confirming explicitly if a custom configuration overrides defaults:

// webpack.config.js — Webpack 5
module.exports = {
  mode: 'production',
  optimization: {
    sideEffects: true,   // must be true for package.json sideEffects declarations to have any effect
    usedExports: true
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [require('mini-css-extract-plugin').loader, 'css-loader'],
        sideEffects: true  // belt-and-suspenders: mark the CSS rule itself as side-effectful
      }
    ]
  }
};

The module.rules entry marking .css files as sideEffects: true at the rule level is a second, independent safeguard — it applies regardless of what any individual package’s package.json declares, which is useful when the stripped CSS import lives in first-party application code that has no package.json of its own to edit.

Vite 5+ — Rollup’s CSS handling and moduleSideEffects

Vite extracts CSS through its own pipeline before Rollup’s general tree-shaking pass runs, which makes it more resilient to this specific failure by default — but a project setting rollupOptions.treeshake.moduleSideEffects: false globally (rather than the safer 'no-external') can still strip first-party CSS imports:

// vite.config.js — Vite 5+
export default {
  build: {
    rollupOptions: {
      treeshake: {
        moduleSideEffects: (id) => {
          if (id.endsWith('.css') || id.endsWith('.scss')) return true; // never treat stylesheets as side-effect-free
          return 'no-external';
        }
      }
    }
  }
};

Avoid setting moduleSideEffects: false as a blanket value in Vite for any project that imports CSS through JavaScript — that setting removes Rollup’s ability to distinguish stylesheet imports from any other side-effect-free module and is a common cause of the exact symptom described at the top of this page.

Step-by-Step Verification Checklist

  1. Confirm the missing selector before making any change.

    grep -c "\.btn-primary" dist/assets/index-*.css

    A 0 result confirms the selector is genuinely absent from the build output, not just visually overridden by a later rule.

  2. Locate every sideEffects declaration in the dependency chain, not just the application root:

    find . -path '*/node_modules' -prune -o -name package.json -print | xargs grep -l '"sideEffects"'

    Internal workspace packages in a monorepo are the most common place for an overly broad false to hide.

  3. Apply the glob array fix shown above to every package.json that both ships CSS and declares sideEffects.

  4. Rebuild from a clean cache to rule out stale build artifacts masking the fix:

    rm -rf dist node_modules/.cache node_modules/.vite && npm run build
  5. Re-run the grep check and confirm the selector is present.

    grep -c "\.btn-primary" dist/assets/index-*.css
    # Expected: 1 or more
  6. Visually confirm in a browser, not just via grep — a selector can be present in the CSS but still not apply if a specificity or ordering issue exists independently of the tree-shaking bug.

  7. Add a CI regression check that fails the build if a known critical selector disappears from the compiled CSS:

    # CI step — fail the build if a known selector is missing from compiled CSS
    grep -q "\.btn-primary" dist/assets/*.css || { echo "Critical selector missing from build output"; exit 1; }

Edge Cases and Gotchas

Gotcha 1 — CSS Modules mask the problem in some components but not others

A component importing CSS Modules (import styles from './Button.module.css') is partially protected because the module has a real default export — the generated class-name map — that the component actually references, which usually satisfies the bundler’s used-exports check and prevents pruning. A sibling component using a plain global import (import './Legend.css') with no export usage has no such protection. Diagnostic signal: some components in the same codebase lose styling while others using CSS Modules do not, under the identical sideEffects: false declaration. Fix: the glob-array fix above protects both patterns uniformly and removes the inconsistency.

Gotcha 2 — A third-party component library’s own broad sideEffects: false strips its bundled CSS

When a UI library ships both JavaScript and CSS with a bare "sideEffects": false in its own package.json, your application’s bundler respects that declaration when processing the library’s files — you cannot fix this by editing your own package.json. Diagnostic signal: the missing styles belong to a component imported from node_modules, not from first-party source. Fix: apply a patch-package override to the library’s package.json sideEffects array (the same technique covered in auditing sideEffects in large npm packages), or configure a Webpack module.rules override scoped to that package’s CSS files specifically.

Gotcha 3 — CSS-in-JS runtime injection is a different failure with a similar symptom

If styles are generated at runtime via a CSS-in-JS library (styled-components, Emotion) rather than static .css imports, the missing-styles symptom can look identical but the root cause is unrelated to sideEffects glob patterns — it is usually the CSS-in-JS runtime’s own initialization module being pruned because it performs a side effect (injecting a <style> tag) that the library’s package.json fails to declare. Diagnostic signal: no .css files exist in the import graph at all; the missing rules were never in a static stylesheet to begin with. Fix: confirm the CSS-in-JS library’s own package declares its runtime entry point in its sideEffects array, rather than looking for a stripped .css import that does not exist in this architecture.


FAQ

Why does setting sideEffects: false remove my CSS imports?

A bare import './Button.css' has no exports, so a bundler treats it purely as a side effect — the only reason the import exists is to cause the stylesheet to be emitted. When sideEffects: false is declared with no exceptions, the bundler concludes that if nothing imports a named export from that file, the whole import is unused and prunes it, deleting the CSS from the output entirely.

Does this affect CSS Modules the same way as global CSS imports?

CSS Modules imports (e.g. import styles from './Button.module.css') are somewhat protected because the module has a named default export (the class-name map) that components actually reference, which usually keeps the bundler from treating the whole file as unused. Plain global imports like import './Button.css' with no export usage have no such protection and are the pattern most commonly stripped.

Should I ever set sideEffects: false for a whole package that includes CSS?

Only if you explicitly list every stylesheet extension in the sideEffects glob array alongside false’s absence — meaning you never set the field to a bare false at all for a package containing CSS. Use a glob array like ['*.css', '*.scss', 'src/polyfills.js'] instead, which preserves the CSS while still allowing pure JavaScript modules in the same package to be tree-shaken freely.