skip to content

Side Effects and Tree Shaking

Why ES modules can be statically analyzed to drop unused exports, and what defeats it — top-level side effects, barrel re-exports, and CommonJS. Interviewers ask what the package.json `sideEffects` flag actually claims and when that claim is a lie.

part ofJavaScriptoverview, primer and where to startread it →
on this pageshow

questions

6

What counts as a top-level side effect in an ES module, and why does the presence of one force a bundler to keep code that nothing imports?

level: middleimportance: must knowfreq 55%

answer

  1. what can the rest of the program observe?
  2. declarations cost nothing on evaluation
  3. calls into unknown code are opaque
  4. undecidable in general
  5. unproven is treated as impure

basics

~20 s

A top-level side effect is anything a module body does on evaluation that is observable outside itself: assigning to a global, mutating a built-in prototype, registering with a shared registry, or calling an opaque function. Analysis is conservative, so unprovable purity keeps the module.

solid answer

~50 s

A module's side effects are whatever its body does at evaluation time that the rest of the program can observe — writing to `globalThis` or `window`, mutating a built-in prototype, pushing into a shared registry, calling `customElements.define`, logging, throwing, or simply calling a function whose body the analyser cannot see. Declarations are not side effects: `function`, `class`, and `const x = 42` produce nothing observable. The critical part is that the analysis is *conservative*: it must preserve behaviour, so anything it cannot prove pure is treated as impure. `const client = createClient()` at the top level is retained even if `createClient` happens to be pure, because proving that across module boundaries is undecidable in general. That is why a module with zero used exports can still sit in your bundle, and why annotations like `/*#__PURE__*/` and the `sideEffects` field exist — they let a human assert what the tool cannot derive.

code

javascript · 24 lines
javascript
// impure.js — every top-level statement is observable on evaluation
import { register } from './registry.js';

globalThis.FEATURE_FLAGS = { newCheckout: true };
String.prototype.titleCase = function () {
  return this.replace(/\b\w/g, (c) => c.toUpperCase());
};
register('checkout', { version: 2 });

export function unusedHelper(x) {
  return x * 2;
}

// pure.js — declarations only; skipping evaluation is unobservable
export const DEFAULT_LIMIT = 50;
export const RETRY_POLICY = { attempts: 3, backoff: 'exponential' };
export function clamp(n, lo, hi) {
  return Math.min(Math.max(n, lo), hi);
}
export class Money {
  constructor(cents) {
    this.cents = cents;
  }
}

go deeper

for a junior

Know that code sitting directly in a module body runs the moment the module is imported, and that assigning to globals or patching built-ins there is what makes a module impossible to skip.

for a middle

Draw the line precisely: declarations are inert, but any call into code the tool cannot see, any write to shared or global state, and any throw are effects. Explain why the analyser's default for the unknown case must be to keep the code.

for a senior

Demonstrate the library-author judgment — auditing which module bodies do work at import time, converting import-time initialisation into explicit factory calls, and measuring the bundle impact before and after rather than trusting the change.

for a principal

Own the standard: a rule that shared internal packages perform no work at import time, an entry-point convention that isolates the ones that must, and awareness that every import-time effect is a floor on bundle size for every consumer forever.

## The question the analyser is actually asking When a bundler considers deleting a module, it is not asking "is this code useful?" — it is asking "if I never evaluate this module body, can any remaining code tell the difference?" If the answer is no, the module is inert and removable. If the answer is yes, or *unknown*, it stays. Everything below follows from that framing. ## What is definitely a side effect These make the module observable to the outside world the moment it evaluates: ```js globalThis.APP_VERSION = '4.2.0'; // writes a global Array.prototype.last = function () { // mutates a built-in return this[this.length - 1]; }; registry.push(myPlugin); // mutates imported state customElements.define('x-card', XCard); // registers with the host console.log('module loaded'); // observable output if (!crypto.randomUUID) throw new Error(); // throws during evaluation let started = Date.now(); // reads mutable ambient state ``` So is any call into another module, because the callee's body may do any of the above: ```js import { configure } from './logging.js'; configure({ level: 'warn' }); // side effect: unknown callee behaviour ``` ## What is not a side effect Pure declarations produce a value and nothing else: ```js export function slugify(s) { /* ... */ } // declaration only export class Money { /* ... */ } const DEFAULT_LIMIT = 50; const OPTIONS = { retries: 3, backoff: 'exp' }; export const TABLE = ['a', 'b', 'c']; ``` Evaluating those allocates values that only this module can reach. If nobody imports them, nobody can observe that they were never created. ## The grey zone, and why "unknown" means "keep" Most real modules sit between the two lists: ```js const client = createClient({ retries: 3 }); // does createClient touch anything? const SLUG_RE = new RegExp(pattern, 'g'); // constructor is pure, but can throw class Widget extends BaseWidget {} // evaluating extends reads BaseWidget ``` Deciding whether an arbitrary call is pure is undecidable in the general case, and even the tractable cases require whole-program analysis across module and package boundaries, through re-exports and dynamic property access. Bundlers therefore adopt the only safe default: unproven means impure. Correctness beats bundle size — a tool that silently deletes your feature-flag registration is worthless no matter how small the output. A useful consequence: two modules that look equally "unused" can behave completely differently at build time, purely because one of them happened to call something at the top level. ## Where side effects actually come from in real code - **Polyfills.** A module whose entire job is to patch built-ins. - **Registration patterns.** Plugin registries, custom elements, service locators, i18n message bundles that register themselves on import. - **Module-level singletons.** `export const db = connect(process.env.DSN)` — the connection is created at import time. - **Instrumentation.** Error handlers, performance marks, analytics queues installed at the top level. - **Style and asset imports.** `import './x.css'` in bundlers that support it; the effect is the emitted stylesheet. All of these are legitimate. The point is not to eliminate side effects but to *know which of your modules have them*, because those modules are a permanent floor under your bundle. ## The two escape hatches Because the analyser cannot derive purity, the ecosystem gives humans two ways to assert it. `/*#__PURE__*/` immediately before a call expression asserts that this one call is safe to delete when its result is unused. The `sideEffects` field in a package's `package.json` asserts that whole files, or the whole package, are safe to skip when none of their exports are used. Both are unverified promises: if you assert wrongly, the effect disappears from production builds and the failure shows up nowhere else. ## How to keep a module shakeable Move effects out of module bodies and into functions the consumer calls: ```js // hard to shake — runs on import export const client = createClient(config); // shakeable — nothing happens until called export function getClient(config) { return createClient(config); } ``` Lazy initialisation, explicit `init()` entry points, and keeping registration in application code rather than library modules all turn an unconditional cost into an opt-in one. For a library author this is the single highest-leverage habit: consumers can only drop what your module bodies do not already do.

  • Why can't the analyser just look inside `createClient` and decide whether the call is pure?
    Sometimes it can, for small local functions. In general it cannot: the callee may live in another package, be re-exported through several layers, call something reached by computed property access, or itself call code the tool never sees. Proving purity of arbitrary code is undecidable, and even the decidable subset needs whole-program analysis. Since a wrong answer silently breaks behaviour, tools default to assuming impurity.
  • Is `export const CONFIG = { retries: 3 };` a side effect?
    No. Evaluating it allocates an object that only this module can reach, so skipping the module is unobservable. It becomes a side effect if the initialiser calls something opaque, reads mutable ambient state such as `Date.now()` or `process.env` in a way another module can observe, or if the object is pushed into shared state elsewhere in the module body.
  • How would you restructure a library module so consumers can actually drop it?
    Move every effect out of the module body into an exported function. Replace `export const client = createClient(cfg)` with `export function createAppClient(cfg)`, replace self-registering plugins with an explicit `register()` the app calls, and keep polyfills in a separate entry point consumers opt into. Then the module body is pure declarations, and an unused export costs nothing.

saying these in an interview costs you the question

  • Thinks only I/O counts as a side effect
  • Believes function and class declarations are side effects
  • Assumes bundlers can decide purity of any function call
  • Says a module with no used exports is always removed
  • Treats console.log at module scope as harmless to the analysis

context

open as a page

In a bundled ES-module app, an unused `import { formatDate } from './utils.js'` disappears from the production bundle, but `import './analytics.js'` — an import statement with no bindings at all — is kept. Why does the bundler treat these two import forms differently?

level: juniorimportance: should knowfreq 50%

basics

~20 s

An unused named binding can be deleted because static analysis proves no code reads it. A bindings-free import asks for the module's evaluation rather than for a name, so its top-level code is the requested effect and must still run.

open as a page

An app imports a single function from a barrel `index.js` whose body is nothing but `export * from './...'` lines covering forty modules, yet most of those modules still end up in the production bundle. What about that barrel undermines tree shaking, and how would you change it?

level: middleimportance: should knowfreq 45%

basics

~20 s

A barrel makes every re-exported module part of the graph, so each must be parsed and, unless its top level is provably inert, evaluated. One impure module among forty is enough to pull itself in, and re-export chains amplify the cost.

open as a page

Why can a build tool usually not eliminate unused exports from a CommonJS module the way it can from an ES module?

level: middleimportance: should knowfreq 40%

basics

~20 s

CommonJS builds its exports at runtime: require is an ordinary function call and module.exports is a mutable object whose properties can be added conditionally and read with computed keys. The export set is not knowable before execution, so tools keep the whole module.

open as a page

What does `"sideEffects": false` in a package's package.json actually claim, and what goes wrong when a package makes that claim untruthfully?

level: seniorimportance: should knowfreq 42%

basics

~20 s

It claims that no file in the package does anything observable merely by being evaluated, so a bundler may skip importing any module whose exports go unused. It is an unverified promise; a false one silently deletes polyfills, styles and registrations from production builds.

open as a page

In a library's published output you find `const registry = /*#__PURE__*/ createRegistry();`. What does that comment assert, what reads it, and what is the risk of adding one?

level: seniorimportance: nice to knowfreq 22%

basics

~20 s

It asserts that the immediately following call has no observable effects, so if its result is unused the whole call may be deleted. Minifiers and bundlers read it. Nothing verifies it, so a wrong annotation silently removes real behaviour from production builds.

open as a page