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?
answer
- what can the rest of the program observe?
- declarations cost nothing on evaluation
- calls into unknown code are opaque
- undecidable in general
- unproven is treated as impure
basics
~20 sA 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 sA 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// 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
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.
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.
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.
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