Fixing Polyfill Side Effects Dropped in Production

Everything passes locally. The production build ships, and a subset of browsers throws immediately:

TypeError: undefined is not a function
    at t.exports (main.4f2a91.js:1:38210)
    at r (main.4f2a91.js:1:1204)

Unminified, it resolves to Array.prototype.at — a method the application polyfills at startup. The polyfill import is right there in the entry file. It is not in the bundle.

// main.js — this line is in the source and absent from the output
import '@acme/compat/array-at';

Root cause: a bare import looks like a module nothing depends on

Tree-shaking works by reachability over bindings. A normal import creates an edge the bundler can follow: something imports formatDate, formatDate is used, the module is retained. A bare import — import './polyfill' with no bindings at all — creates no such edge. The module’s entire contribution is what happens while it evaluates.

The bundler cannot infer that. It relies on a declaration, and the declaration is the sideEffects field described in configuring sideEffects for optimal tree-shaking. Setting it to false is a promise that no module in the package does anything observable at import time — which for a polyfill package is simply untrue, because mutating a global prototype is the only thing it does.

Development builds hide this: tree-shaking runs only in production, so the module is retained by default and the polyfill applies as written.

No Binding, No Edge, No Module The bundler retains a module because a used binding points at it. A bare import creates no binding, so with sideEffects false the module has nothing keeping it in the graph and is dropped. main.js (entry) always retained utils/format.js import { formatDate } — used retained: a binding points here binding edge compat/array-at.js import '…' — no bindings dropped: nothing points here effect only sideEffects tells the bundler which effect-only modules must survive without it, an effect-only module is indistinguishable from dead code

The fix: declare the effect-bearing files

{
  "name": "@acme/compat",
  "version": "3.2.0",
  "sideEffects": [
    "./src/array-at.js",
    "./src/object-groupby.js",
    "./src/polyfills/*.js",
    "*.css"
  ]
}

An array keeps tree-shaking enabled for everything not listed, which is the important property: the alternative of setting sideEffects: true protects the polyfills but disables shaking across the whole package, and the payload consequences are exactly what advanced tree-shaking and dependency optimization exists to avoid.

Three Ways to Declare Side Effects False drops effect-only modules; true disables shaking across the package; an array retains only the listed files and keeps shaking everywhere else. Only one of these is right for a package containing polyfills false shakes everything polyfills silently dropped a false statement true shakes nothing polyfills survive so does every unused export ["./src/polyfills/*.js"] shakes everything else listed files always kept accurate and precise Paths are matched against the PUBLISHED layout, not the repository source tree

Paths are matched relative to the package root, and glob patterns work. The most common mistake is listing source paths while publishing a build from dist/ — the field must describe the published file layout, not the repository’s.

If the package is a third-party dependency you cannot change, override the behaviour from your own build:

// webpack.config.js — Webpack 5: override a dependency's wrong declaration
module.exports = {
  module: {
    rules: [
      {
        // The package claims to be side-effect free; for these files it is not.
        include: /node_modules[\\/]@acme[\\/]compat[\\/]dist[\\/]polyfills/,
        sideEffects: true,
      },
    ],
  },
};
// vite.config.js — Vite 5+: the equivalent Rollup-level override
export default {
  build: {
    rollupOptions: {
      treeshake: {
        moduleSideEffects: (id) => /@acme\/compat\/dist\/polyfills/.test(id),
      },
    },
  },
};

Second fix: check the minifier separately

Surviving module-level shaking is not the end. The minifier makes its own decisions about which statements are observable, and an aggressive configuration can delete the polyfill’s body after the bundler kept the module.

// webpack.config.js — Webpack 5: keep the minifier away from the polyfills
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  optimization: {
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          compress: {
            // Assigning to a built-in prototype IS observable here, even
            // though it looks like a dead store to the compressor.
            pure_getters: false,
            unused: true,
            // Never list a polyfill's guard function in pure_funcs.
          },
        },
      }),
    ],
  },
};

The interaction between the two passes is covered from the other direction in configuring Terser pure_funcs for dead code removal: the same mechanism that usefully removes logging calls can remove a polyfill if its shape is misdeclared.

Two Passes That Can Delete a Polyfill Tree-shaking decides whether the module is included; minification then decides whether its statements are observable. A polyfill must survive both. 1. Tree-shaking is the module reachable? 2. Minification are its statements observable? Polyfill ships global patched at startup dropped: sideEffects false fix: declare the file dropped: assumed pure fix: relax the compressor

Step-by-step verification

  1. Confirm the removal. Search the production chunk for a marker string unique to the polyfill. Absent means removed; present but not running means an ordering problem instead.

  2. Test against a production build. Serve the built output and check that the patched global exists before the application’s first line runs.

  3. Bisect the passes. Build once with minification disabled. If the polyfill appears, the minifier removed it; if not, tree-shaking did.

  4. Check the published layout. Confirm the sideEffects paths match the shipped files, not the source tree.

  5. Add a build-output test. Assert the global’s presence in a test that loads the built bundle. A source-level unit test cannot catch this class of defect at all.

  6. Re-check the bundle size. Declaring files side-effectful retains them, so confirm nothing beyond the polyfills was retained by an over-broad glob — the attribution workflow in attributing bundle bloat with source maps will show it.

Edge cases and gotchas

Ordering, not removal. If the polyfill is present but the error persists, the problem is evaluation order: a module using the method evaluated before the patch applied. Bare imports must appear first in the entry, and re-export chains can reorder them.

Conditional polyfills. A polyfill imported inside a feature test — the pattern in conditionally importing polyfills at runtime — is reached dynamically, so it is not removed by shaking. If it disappears there, the cause is the minifier or a mis-declared chunk boundary.

Transitive polyfills. A dependency that polyfills something for its own use can have that import stripped for the same reason, breaking the dependency rather than your code. The stack trace points inside the package.

Monorepo workspace packages. An internal package built with a blanket sideEffects: false template inherits the problem across every consumer, and it typically surfaces in only one of them.

FAQ

Why does the polyfill work in development and disappear in production?

Because tree-shaking only runs in production builds. In development the bundler keeps every module it can reach, so the polyfill’s global mutation happens as written. In production the optimiser removes modules it believes nothing depends on, and a bare import with no bindings looks exactly like a module nothing depends on — unless the package declares that importing it has effects.

Is sideEffects: false ever safe on a package containing polyfills?

Not as a blanket value. A polyfill’s entire purpose is a side effect, so declaring the package free of side effects is a false statement about it. The correct form is an array listing the files that genuinely do have effects — polyfill entries, style imports, registration modules — which keeps tree-shaking enabled for the rest of the package while protecting the parts that must not be removed.

Can the minifier remove a polyfill the bundler kept?

Yes. The two make independent decisions: the bundler decides which modules to include, and the minifier then decides which statements inside them are observable. A configuration that marks broad classes of calls as pure, or a compress pass that assumes property assignment on a built-in is unobservable, can delete a polyfill’s body after it survived module-level shaking. Verify against the final minified output, not an unminified build.