Modules (and the Road to Runtime Behavior)
This chapter assumes you know closures, prototypes, this binding, and promises from earlier chapters. We move fast.
Trade offs
ES modules (import/export) give static structure: bindings are live links, not copies, and the graph resolves before any code runs.
// counter.js
export let count = 0;
export function inc() { count += 1; }
// main.js
import { count, inc } from './counter.js';
inc();
console.log(count); // 1 — live binding, not a snapshot- CommonJS (
require) copies values and loads synchronously; ES modules are asynchronous and hoisted. - Static analysis enables tree-shaking; dynamic
import()trades that for lazy loading. - Circular imports are legal in ES modules but force careful ordering of initialization.
Failure modes
// top-level await blocks the whole module graph downstream
const data = await fetch('/config');
export const ready = data.ok; // every importer waits- Unhandled promise rejections crash Node processes and vanish silently in some browsers — always attach
catchor use a global handler. - Microtask starvation: a
whileloop that queues microtasks (e.g., recursivePromise.resolve().then) starves rendering and timers. - Accidental retained state: a module-level cache keeps every entry alive for the page lifetime; closures over large DOM nodes in long-lived listeners leak memory.
Internals
The event loop drains all microtasks (promise reactions, queueMicrotask) after each macrotask, before the next task. Generators suspend a stack frame without holding the call stack:
function* range(n) { for (let i = 0; i < n; i++) yield i; }
const it = range(3);
console.log([...it]); // [0, 1, 2]Proxy intercepts operations via traps; Reflect exposes the default behavior for forwarding. GC is reachability-based: an unreachable Proxy target is collected even if the proxy is referenced through a weak structure like WeakRef or FinalizationRegistry — but finalizers are not guaranteed to run.
const handler = { get(t, k, r) { return Reflect.get(t, k, r) ?? 'default'; } };
const p = new Proxy({}, handler);
console.log(p.missing); // defaultPrefer proxies for cross-cutting concerns (validation, logging), not for hiding type mismatches — they add indirection that hurts performance and debuggability.
Exercise
Why does an infinitely recursing then chain freeze the page instead of letting timers fire?
Show answer
Promise reactions are microtasks; the event loop drains every microtask before running the next macrotask (timers, I/O), so a self-perpetuating microtask queue starves them.
Spotted a mistake, or code that does not work?