Part 13 · 6 chapters · ~45 min

Design Patterns, Mechanism First

A pattern is a shape of code over one or two engine features, and it inherits their costs and failure modes exactly. This part is module and factory as closures and shapes, observer as a Map of closures and a megamorphic call, strategy, command and state machine as call ICs and object shapes, memoisation as key cost plus retention, Proxy reactivity and builders and async patterns costed, each with the point at which it becomes the wrong tool.

86

Patterns as mechanisms

the question

"We use the observer pattern everywhere. Is that slow? Is the module pattern a memory problem? Should state machines be classes?"

Each pattern is a shape of code over one or two engine features, and it inherits their costs and failure modes exactly. The module pattern is closures and contexts, so part 8's sharing rule applies. Observer is a Map of listener sets and a megamorphic call site, so emit costs per listener and the leak is the missing unsubscribe. A state machine is either a switch or a table of same-shaped objects, and part 3 decides which is monomorphic. This part walks the common patterns with the mechanism beside each, so the decision "is this the right tool here" can be made from the cost model rather than from taste.

PatternMechanismCost centreFailure modeWrong tool when
Module / revealing moduleClosure over a context; or ESM module scopeOne context per instance; context loads per accessCaptured scope retains more than intended (sharing rule)Thousands of instances: use a class
FactoryA function returning a literal or class instanceAllocation per product; shape per literal siteConditional properties split shapesProducts have optional fields: define all, null the absent
Observer / pub-sub / emitterMap name → Set of closures; indirect callsPer-listener call; listener bodieson without off: emitter retains subscribersHot per-frame fan-out to hundreds: batch or direct calls
StrategyA function value chosen at runtimeCall IC state: mono/poly/megaA registry of many strategies makes the call site megamorphicThe strategy is one of two: an if is clearer and faster
Command (with undo)An object per action with execute/undo closuresAllocation per action; the undo stack retains captured stateUnbounded history retains everything ever touchedActions are tiny and frequent: a compact log
State machineSwitch on a literal, or a table of same-shaped state objectsCompare chain or jump table; or monomorphic method callsState objects with different shapes → polymorphic everythingTwo states: a boolean
BuilderA mutable object with chainable methods, finalised onceOne allocation at build; method calls return this (inlined)Builders that add properties conditionally (shape churn)The product is a literal: write the literal
MemoisationMap keyed by argumentKey construction; retentionUnbounded cache; object keys rebuilt per call (0% hits)Cheap function; unique arguments
Proxy reactivityJSProxy traps; WeakMap of depsTrap call per access (50 to 100×)Proxied data in hot loopsBulk data: typed arrays or explicit signals
Iterator / lazy pipelineGenerators or iterator helpersFrame resume per itemHot numeric loopsSmall eager arrays are simpler and faster
Async pipeline / poolPromises, a shared iterator, Promise.allA hop per stage per itemUnbounded concurrency; unhandled rejections in loopsCPU-bound work: workers, not promises
Mixin / decoratorObject.assign onto prototypes, or class wrappingPrototype edits after instances exist invalidate cellsDecorating at runtime, repeatedlyApply once at definition time
PATTERN TO MECHANISM
each pattern is a shape of code over one engine feature
swipe the figure sideways, or tap expand for full screen
1/6
the features
The engine features, as a row: closures and contexts (part 8), hidden classes and ICs (part 3), prototypes (part 7), Map/Set/WeakMap (part 12), Proxy and Reflect (part 7), promises and jobs (part 10), generators (part 9), modules (part 11).
87

Module, factory, and the shape they produce

code
// the module pattern: a closure over private state. with ES modules, the module scope IS the closure.
// counter.js
let count = 0                                   // module-scope let: a context slot in the module's environment (part 11)
const listeners = new Set()                     // module-scope: lives for the process; the retention is deliberate
export function inc() { count++; for (const l of listeners) l(count) }
export function onChange(fn) { listeners.add(fn); return () => listeners.delete(fn) }   // return the unsubscribe: the off() is the API

// the revealing module / factory: a closure per instance, returning a fixed-shape object
export function makeCounter(initial = 0) {
  let count = initial                           // captured → context slot, one context per makeCounter call
  return { inc: () => ++count, get: () => count }   // literal: one map for every counter; two closures sharing one context
}
// vs a class: methods on the prototype (one function object), state as fields (same map). closures cost a context + N functions per instance.
// pick closures for a handful of instances or true privacy; a class for thousands of instances.
the module pattern, costed
  1. ESM module scope is a context that lives for the process. Module-level state is the simplest singleton and the simplest leak: anything stored there is retained until exit. That is correct for caches with bounds and registries with removal; wrong for per-request accumulators.
  2. Factory closures: one context per instance holding the captured variables, plus one JSFunction per method per instance. A counter with two methods is three allocations plus the returned literal. A class instance is one allocation; its methods are on the prototype. At ten instances, nobody cares; at a hundred thousand, closures cost several times the memory.
  3. Privacy: closures are private by construction; class #fields are private by brand check and cost nothing extra. The historical reason for closure-based objects (privacy) is gone; the remaining reason is a small number of instances with genuinely independent behaviour.
  4. Shape: a factory that returns a literal gives every product the literal's map. A factory with if (x) obj.extra = ... gives two maps. Define every property in the literal; use null for absent ones.
singletons and registries
  1. A singleton is a module. Module identity (part 11) is the guarantee: one evaluation per resolved key. The failure mode is two keys for the same code (duplicate packages, symlink differences, a query-string cache buster), which gives two singletons.
  2. A registry is a Map at module scope with register and unregister. Without unregister it is a leak by design; with unregister and no caller of it, a leak by omission. The retainer path in a snapshot ends at the module's context.
  3. Lazy initialisation (let instance; export function get() { return instance ??= create() }) is a module-scope let, a context slot, a nullish check: free.
88

Observer, pub/sub, emitters

The observer pattern is a list of callbacks and a loop. Its cost is the fan-out, its leak is the subscription that outlives the subscriber, and its one engine-level property is that the call site inside emit is megamorphic forever, which is fine, because the listener bodies are where the time goes anyway.

the mechanics
  1. Storage: Map<string, Set<Function>> or arrays. Set gives O(1) off; arrays give stable order and cheap iteration. Node's EventEmitter uses arrays (a single function for one listener, an array for more) and caps listener count with a warning at 10 by default, precisely to catch the leak early.
  2. emit: a Map lookup, a loop, an indirect call per listener. The call IC in emit sees every listener in the application: megamorphic. Each call is an indirect jump, not inlined. Tens of nanoseconds each.
  3. on: stores the closure. The closure's context holds whatever it captured: a component, a DOM node, a request. The emitter retains all of it until off.
  4. Ordering: synchronous listeners run in registration order inside emit; a listener that emits re-enters (reentrancy is the subtle bug class: iterate a copy if listeners may unsubscribe during emit).
the leak, and the designs that prevent it
  1. Return the unsubscribe from on (as the module example does) so the caller has it without needing to keep the listener reference.
  2. AbortSignal: on(name, fn, { signal }); one controller.abort() on teardown removes everything a component registered. The DOM's addEventListener supports it; emitters should too.
  3. Lifetime ownership: whichever object is shorter-lived (the subscriber) owns the subscription and removes it in its teardown. If the subscriber has no teardown hook, it is not safe to subscribe from it.
  4. WeakRef listeners let the emitter not retain the subscriber at all, at the cost of a deref per emit and the subscriber having to keep its own listener alive. Rarely worth it; the explicit off is simpler.
  5. Max listener warnings (Node's setMaxListeners) are a leak detector in production. Do not silence them without understanding the count.
when the pattern is wrong
  1. Per-frame fan-out to hundreds of subscribers (a scroll position broadcast): the indirect calls and the allocation of event objects add up. Batch (one emit per frame with all changes), or let subscribers pull from shared state on their own schedule.
  2. Ordering-dependent logic across listeners is a design smell; the order is registration order, which is load order, which is fragile.
  3. Request/response over an emitter (emit a request, listen for a reply) is a promise with extra steps; use a promise.
AN EMITTER, UNDER THE PROFILER
where the time goes in emit, and where the memory goes in on
swipe the figure sideways, or tap expand for full screen
1/6
emit
emit("tick", data): map.get("tick") (a hash of an internalised string: fast); then for each listener: a call through a function value. The call site in emit is the same for every event and every listener in the program: megamorphic from the first few registrations. Indirect calls, never inlined.
89

Strategy, command, state machine

code
// state machine, two shapes
// 1. a switch: the whole logic is transitions; states are strings (internalised; TestEqualStrict is a pointer compare)
function next(state, event) {
  switch (state) {
    case 'idle':    return event === 'start' ? 'running' : state
    case 'running': return event === 'pause' ? 'paused' : event === 'stop' ? 'idle' : state
    case 'paused':  return event === 'resume' ? 'running' : event === 'stop' ? 'idle' : state
  }
}
// V8: a chain of compares for strings; a jump table for dense small integers. both fine. keep states as literals, not computed strings.

// 2. a table of state objects: each state has behaviour; every state object has the SAME shape and method set
const states = {
  idle:    { enter() {}, on: { start: 'running' }, render(ctx) { ctx.text('idle') } },
  running: { enter() { startTimer() }, on: { pause: 'paused', stop: 'idle' }, render(ctx) { ctx.text('running') } },
  paused:  { enter() { stopTimer() }, on: { resume: 'running', stop: 'idle' }, render(ctx) { ctx.text('paused') } },
}
// states[current].render(ctx): the receiver map is the same for all three (same literal shape) → monomorphic; the call target is
// polymorphic (3 functions) → still inlinable. the `on` tables differ in shape (different keys) → keep lookups there keyed, not named.
// the failure mode: states defined in different files with different key orders → three maps → polymorphic everywhere.
strategy
  1. A function value chosen at runtime, called at one site. The site's call IC records the targets: two or three strategies → polymorphic, inlinable with a guard; dozens → megamorphic, an indirect call. Both are fine unless the site is in a hot inner loop, in which case the number of strategies is the number of code paths the optimiser must keep, and splitting the loop per strategy (hoisting the choice out) makes each copy monomorphic.
  2. Strategy objects versus functions: an object with a method is a load plus a call (two ICs: the receiver shape and the target); a bare function is one call IC. Prefer functions unless the strategy has state.
command
  1. An object per action with execute and undo. Each is a closure or a method on a class with the action's data as fields. Allocation per action; fine for user actions (hundreds per minute), expensive for thousands per frame.
  2. The undo stack retains every command and everything each captured (the document state before the change, the DOM nodes it touched). Bound the history; store deltas, not snapshots; clear captured references when a command is compacted out.
  3. Serialisable commands (plain data plus a registry of handlers keyed by type) replace closures with a Map lookup and keep the stack JSON-able. Worth it for collaboration and persistence; a step more code for a single-user editor.
state machine
  1. Switch form: states as string literals (internalised; comparison is a pointer compare) or small integers (a jump table when dense). Transitions are the whole logic. Fast, flat, easy to read for a dozen states. The optimiser handles a switch on strings as a chain of compares, which is fine up to tens of cases; beyond that a Map from state to handler is cleaner.
  2. Table form: a state object per state with the same shape and method set. states[current].render(ctx) is a keyed load (the state name) then a method call whose receiver map is the same for every state (if the literals match) and whose target is one of N functions (polymorphic up to 4, megamorphic beyond). Behaviour-rich states belong here.
  3. Class-per-state (the GoF form): instances of different classes have different maps; every method call site on the current state is polymorphic across all state classes. Works; costs an IC entry per state class per site. The table of same-shaped literals is the monomorphic version of the same idea.
  4. Libraries (XState and kin) add hierarchy, guards, actions and persistence; their runtime cost is interpretation of a config object, which is dictionary-shaped by nature. Fine for UI flows at human speed; not for a per-packet protocol parser.
90

Memoisation and caching

A memo is a Map in front of a function. The Map is cheap; the key is not always; the retention is never free. Three questions decide whether a memo helps: what does the key cost to build, what is the hit rate, and what bounds the cache.

keys
  1. One primitive argument: key directly. Numbers and internalised strings hash in nanoseconds.
  2. Object argument by identity: WeakMap keyed on the object. Free, and entries die with their keys. Requires the caller to pass the same object for the same logical input, which frameworks that rebuild props every render do not.
  3. Object argument by value: build a key. JSON.stringify is O(size) and allocates; a hand-picked tuple of the fields that matter is O(fields). The tuple is usually right and is the step people skip.
  4. Multiple arguments: nested Maps (one probe per argument, identity for objects) or a joined string for a few primitives. Never JSON.stringify(arguments).
retention
  1. Unbounded: every distinct key forever. Only acceptable when the key space is small and finite (a memo over enum values).
  2. LRU: the Map delete-and-reinsert trick (part 12) or a proper structure. Size it from the working set, not a round number.
  3. TTL: a timestamp per entry, checked on get; sweep occasionally or on insert.
  4. WeakMap: when the key is an object and the entry should die with it. The only memo that cannot leak.
  5. Scoped: a memo that lives for one request, one render, one computation (create the Map at the start, drop it at the end). The simplest bound there is.
the measurement
  1. Hit rate: count hits and misses in development. Below roughly 30% the memo is usually a net loss (key cost on every miss plus the map's growth).
  2. Key cost versus function cost: if building the key takes a meaningful fraction of the function's time, the memo helps only at high hit rates.
  3. The optimiser already memoises some things: a pure function of a loop-invariant value called in a loop is hoisted or inlined and folded. A memo on top adds a Map probe. Check the profile first.
MEMOISATION: THE KEY IS THE COST
four ways to key a cache and what each one pays
swipe the figure sideways, or tap expand for full screen
1/6
primitive key
memo(fib)(40): the argument is a Smi. Map.get(40): hash the integer, probe. A few nanoseconds. The ideal case: primitive argument, Map keyed directly.
91

Proxy-based reactivity, builders, and async patterns

code
// proxy-based reactivity, the discipline
const deps = new WeakMap()                      // target → Map<key, Set<effect>>: WeakMap so targets can die
let activeEffect = null
function reactive(target) {
  return new Proxy(target, {
    get(t, k, r) { track(t, k); const v = Reflect.get(t, k, r); return typeof v === 'object' && v !== null ? reactive(v) : v },  // lazy, shallow
    set(t, k, v, r) { const ok = Reflect.set(t, k, v, r); trigger(t, k); return ok },
  })
}
// costs: every read through the proxy is a trap call (~50 to 100 ns) plus track (a WeakMap get, a Map get, a Set add)
// so: wrap state at the boundary; read it in effects/templates (few reads); never iterate a proxied array of 100k in a hot loop
// toRaw(proxy) before heavy computation; computed values cache results so the trap runs once per invalidation, not per read
// Vue's `shallowReactive`, MobX's `observable.shallow`, Solid's stores: all this same trade-off, with the same escape hatches
reactivity via Proxy: the discipline
  1. The cost is accepted: every read through the proxy is a trap (part 7), ~50 to 100× a plain read, plus dependency tracking. Frameworks make it worthwhile by keeping proxied reads to the places where tracking is needed (templates, effects) and few relative to the work they drive.
  2. Shallow and lazy: wrap nested objects on access, not up front; shallowReactive / shallowRef for large structures whose internals are not observed. Deep-wrapping a 100,000-row dataset is the classic performance bug in Proxy-based frameworks.
  3. Escape hatches: toRaw, markRaw, unref: computations over the raw object; only the result is reactive.
  4. Signals (Solid, Preact signals, Angular, the TC39 proposal) are the alternative: explicit getter/setter pairs, no Proxy, tracking at the signal read. Fine-grained and cheap per read (a function call, inlined), at the cost of explicitness. The two models are converging; the engine-level difference is "trap per access" versus "call per access".
builders and fluent chains
  1. A builder is a mutable object with methods that set a field and return this. Each call is a monomorphic method call that the optimiser inlines; the chain compiles to a sequence of stores. Cheap, as long as the builder's shape is fixed (declare every field in the constructor).
  2. Immutable fluent chains (each call returns a new object) allocate per step; fine for configuration at startup, costly in a loop. Array method chains (filter().map().slice()) are this: an intermediate array per step, each a young allocation; iterator helpers avoid the intermediates.
  3. The product: build it once at build() as a literal with every field, so consumers see one shape.
code
// async patterns and their job counts (part 10)
// sequential: N awaits = N hops, serial latency
for (const u of urls) results.push(await fetch(u))
// parallel: N promises started, one await on all: latency = max, not sum
const results = await Promise.all(urls.map(u => fetch(u)))
// bounded concurrency: a pool of K workers pulling from a shared iterator (generator = the queue; no library needed)
async function pool(items, k, fn) { const it = items[Symbol.iterator](); const out = []
  await Promise.all(Array.from({ length: k }, async () => { for (const x of it) out.push(await fn(x)) })); return out }
// the shared iterator is the trick: K async workers each pull the next item; iteration state is the generator's frame
// retry with backoff: a loop with await on a timer promise; the AbortSignal passed through so a cancel reaches the sleep
// debounce / throttle: a timer id in a closure (context slot) + clearTimeout; the pending call's arguments retained until it fires
async patterns, costed
  1. Sequential await serialises latency; Promise.all parallelises it; the job counts are the same (one hop per promise), the wall time is not.
  2. Bounded concurrency with a shared iterator: the iterator's state (part 9) is the queue; K workers pull from it. No library, no allocation beyond the promises.
  3. Debounce and throttle: a timer id in a closure, cleared and reset. The pending call's arguments are retained in the context until it fires or is cleared; for large payloads, store a reference you can null.
  4. Retry with backoff: a loop with await sleep(ms, { signal }); the signal lets a cancel interrupt the sleep. Jitter the backoff.
  5. Unhandled rejections in patterns: any started-but-not-yet-awaited promise can reject unhandled (part 10). Promise.all attaches handlers to all inputs; a hand-rolled loop does not.
the pointer
Part 14 is how to find out which of these costs matters in your program: benchmarks that survive the optimiser, flame charts, allocation profiles, and the hot-path checklist. Every claim in this part is "it depends on the numbers"; the next part is how to get them.