Closures, feedback vectors, and call-site polymorphism
Every closure instance has its own feedback vector but shares bytecode via SharedFunctionInfo. A call through a variable forms a call IC recording targets: monomorphic for one callee, megamorphic for many. A hot dispatcher fed many function identities can't be inlined.
You built a clean dispatcher: one hot loop that calls handler(event), where handler is whichever of forty callbacks the registry returns. It is elegant, and it is slow — slower than forty separate loops would be. The reason is not your logic; it is that V8 watches which function flows through that one call site, and when it sees forty different function identities, it gives up trying to predict and inline, falling back to a generic indirect call on the hottest line in your program.
Shared code, private feedback
Why does the same function body sometimes optimise well in one place and stay slow in another? The answer is that each closure instance maintains its own private record of what it has seen at runtime — and that record is what TurboFan compiles against. When you create many instances of the same function — every arrow returned from a factory, every closure pushed in a loop — they do not each carry their own copy of the bytecode. They share one SharedFunctionInfo (SFI): the bytecode, the ScopeInfo, the source metadata. What each closure instance carries separately is its feedback vector: a per-instance array where the engine records the runtime type observations for that instance’s call sites and property accesses.
This split matters for performance. Sharing the SFI keeps memory low and lets the optimiser compile once. The per-instance feedback vector means two closures from the same factory can specialise differently if they are fed different types — and it means a closure only starts collecting useful feedback once that instance has run enough times.
Call sites form inline caches too
You already know property reads form inline caches keyed on hidden class (see the hidden-classes unit’s mono/poly/mega lesson). Calls do the same. A call expression like fn(x) — where fn is a variable or property holding a function — is a call site with its own IC slot in the feedback vector. That slot records which function (which callee identity) has been called here:
- Monomorphic — the site has only ever called one function identity. V8 can speculate hard: it knows the exact target and can inline its body.
- Polymorphic — a small number (up to ~4) of distinct callees. V8 keeps a little table and branches among them.
- Megamorphic — too many distinct callees. V8 stops tracking identities and emits a generic indirect call through whatever
fncurrently holds.
Why inlining is the whole game
Inlining is the optimisation that unlocks the others. When TurboFan inlines a monomorphic callee into the caller, it can then constant-fold across the boundary, eliminate the call’s argument-passing overhead, and specialise the merged code on the observed types. A megamorphic call site blocks all of that: TurboFan does not know which function will run, so it must emit an indirect call — push arguments, jump through a function pointer, no inlining, no cross-boundary optimisation. On a hot dispatch loop that overhead lands on every iteration.
// Megamorphic: one site, many callee identities.
const registry = { click: onClick, hover: onHover, /* …38 more */ };
function dispatch(type, ev) {
return registry[type](ev); // this call site sees dozens of identities -> megamorphic
}
// Monomorphic-friendly: route to a few stable, separately-compiled paths.
function dispatchFast(type, ev) {
switch (type) {
case 'click': return onClick(ev); // each call site has ONE identity
case 'hover': return onHover(ev); // monomorphic, inlinable
default: return onOther(ev);
}
}The switch version has many call sites, each monomorphic, instead of one megamorphic site. Each becomes individually inlinable. The “elegant” single-dispatch version concentrates all the identity variety onto one line and poisons it.
Higher-order functions: keep callbacks uniform
The same effect bites map/filter/reduce and any higher-order helper. A generic helper called with a different callback each time has, internally, one call site (cb(item)) that sees many identities — megamorphic. It still works, but it cannot inline the callback. Patterns that keep it fast:
- Pass the same callback identity when you can, rather than a fresh inline arrow every call. A fresh
arr.map(x => f(x))literal creates a new function identity each call;arr.map(f)reuses one. - Specialise hot helpers. A truly hot reduction over numbers can be a hand-written loop with the operation inlined, rather than a generic
reduce(fn)whosefn(acc, x)site is megamorphic. - Keep argument shapes uniform too. Even monomorphic-callee sites deopt if the arguments change hidden class wildly; uniform callees and uniform argument shapes both matter.
- Distinct callees: monomorphic
- 1
- Distinct callees: polymorphic
- 2–4
- Distinct callees: megamorphic
- ~5+
- Monomorphic call
- inlinable
- Megamorphic call
- indirect, not inlined
- Shared per function family
- SharedFunctionInfo
- Private per closure instance
- feedback vector
A single hot line `registry[type](ev)` dispatches to ~40 different functions. What IC state does that call site reach, and what does TurboFan do?
Order what happens at one call site as more distinct function identities flow through it over time.
- 1 First call: the IC slot is uninitialised, records the first callee identity
- 2 Same identity repeats: site is monomorphic, TurboFan can inline the callee
- 3 A few more identities appear: site becomes polymorphic with a small dispatch table
- 4 Many identities flow through: site goes megamorphic — generic indirect call, no inlining
▸Why this works
You can watch this directly. Run with --trace-ic and grep for CallIC; a degrading dispatcher shows transitions 0 -> 1 -> P -> N (uninitialised → monomorphic → polymorphic → megamorphic). Confirm the inlining loss with --trace-turbo-inlining: a monomorphic callee appears as inlined, a megamorphic site does not. For a quick local experiment, %GetOptimizationStatus(fn) under --allow-natives-syntax in d8 tells you whether the dispatcher itself optimised.
- 01What do sibling closures share, and what is private to each, and why does it matter?
- 02What are the call-site IC states, and what does each mean for inlining?
- 03How do you keep a hot dispatcher or higher-order helper from going megamorphic?
Many instances of one function share a single SharedFunctionInfo — bytecode, ScopeInfo, metadata — but each closure instance carries its own feedback vector recording the runtime observations at its call sites and property accesses, so sibling closures specialise independently and do not pool feedback. A call expression whose callee lives in a variable or property is itself a call site with an IC slot keyed on callee identity: monomorphic when only one identity is seen (TurboFan can inline the callee), polymorphic for a few (a small dispatch table), and megamorphic for many (a generic indirect call that cannot be inlined). Because inlining is what unlocks constant folding and cross-boundary specialisation, a megamorphic call on a hot dispatch line is a genuine performance cliff: the indirect-call overhead lands every iteration. The remedies concentrate identity variety away from the hot line — replace one many-target dispatch with several single-target call sites via switch, pass stable callback identities into higher-order helpers rather than fresh inline arrows, specialise truly hot reductions as explicit loops, and keep argument shapes uniform. This is the closure-and-scope view of the same mono/poly/mega machinery the hidden-classes unit introduced for property access, now applied to which function flows through a call site — observable with —trace-ic and —trace-turbo-inlining. Now when you see a hot dispatch loop that benchmarks slower than expected, check how many distinct function identities flow through its one call site — if the answer is more than four, you have a megamorphic bottleneck and a switch to write.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
appears again in184
- Why GraphQL gets N+1junior
- DataLoader mechanics: tick-boundary batchingmiddle
- Batch function contracts: ordering, shapes, errorsmiddle
- Federation and lookahead: batching beyond DataLoadermiddle
- Query complexity defences: depth, cost, persisted queriesmiddle
- Senior GraphQL API: scheduling contract, tenant isolation, observabilitysenior
- Why idempotency: making retries safejunior
- Server-side state machine: four states of an idempotency keymiddle
- Outbox and inbox: effectively-once across the dual-write boundarymiddle
- Concurrency and cache architecture for idempotency at scalesenior
- Observability, production failures, and global-scale designsenior
- The event loop: one thread, three queuesjunior
- Tasks, microtasks, and scheduler.yield()middle
- Microtask starvation, Long Tasks, and LoAFsenior
- Node.js event loop: phases, nextTick, and loop lagsenior
- React, Vue, and INP observability in productionsenior
- The render pipeline: six stages from bytes to pixelsjunior
- Stage costs and the renderer process modelmiddle
- Invalidation, dirty bits, and containmiddle
- Compositor layers: promotion, overlap, and GPU memorymiddle
- DevTools flame strip and the frame lifecyclemiddle
- Layout thrash: forced synchronous layoutsenior
- BeginMainFrame, compositor-driven animations, and GPU memorysenior
- Production observability: LoAF, INP, and the full attack surfacesenior
- What V8 is and why performance varies 100×junior
- V8''''s four-tier JIT pipeline and profile-guided tieringmiddle
- Hidden classes, transition trees, and memory layoutmiddle
- Inline caches, IC states, and deoptimizationmiddle
- Orinoco GC: parallel scavenger, concurrent marking, and write barriersmiddle
- TurboFan''''s speculative engine and the deopt-loop trapsenior
- V8 in production: isolates, pointer compression, and real failuressenior
- Service worker lifecycle and cache strategiesmiddle
- Service worker edge cases: version skew, durability, and navigation trapssenior
- What the reconciler does: render vs commitjunior
- The fiber object and the double-buffer treemiddle
- Render phase purity and commit phase sub-stepsmiddle
- Reconciliation: diffing heuristics and the key trapmiddle
- Priority lanes, time-slicing, and useTransitionmiddle
- Bailout, memoisation, and tearingsenior
- React Profiler, the Compiler, and production observabilitysenior
- Rendering strategies: SSG, SSR, ISR, streaming, and hydrationjunior
- SSG, SSR, ISR, streaming, and RSC — how each worksmiddle
- Hydration cost: selective, progressive, islands, resumabilitymiddle
- Hydration mismatch: causes, detection, and the determinism rulesenior
- RSC, per-route strategy, and production observabilitysenior
- Core Web Vitals: what LCP, INP, and CLS measurejunior
- CLS: why layout shifts happen and how to stop themmiddle
- Metric tradeoffs, RUM attribution, and the CI+field loopsenior
- The full picture: URL to LCP to INP as a relay racejunior
- Eight layers traced: from the service worker to the second navigationmiddle
- Five canonical breaks: where production reliably diessenior
- The three-track method: reading traces and building a monitored systemsenior
- What is a cache stampede and why it makes things worsejunior
- Lock and single-flight: bounding concurrent rebuildsmiddle
- XFetch: coordination-free probabilistic early expirationmiddle
- Stale-while-revalidate and CDN request coalescingmiddle
- Detecting stampedes and designing TTL for productionmiddle
- Metastable failure, fencing tokens, and production postmortemssenior
- What a relation is: tables, rows, keys, and constraintsjunior
- Constraints, keys, and Postgres data typesmiddle
- Normal forms, denormalization, and why schemas stickmiddle
- JSONB, arrays, and when a side table winsmiddle
- Heap storage, TOAST, and column alignmentsenior
- Schema integrity: deferral, versioning, and production failure modessenior
- Relational vs document, wide-column, graph, and key-valuesenior
- Index-only scans, the Visibility Map, and INCLUDEsenior
- Production failure modes and the index audit playbooksenior
- pg_statistic, ANALYZE, and production observabilitymiddle
- Production failure modes and plan stabilitysenior
- MVCC: why readers and writers never wait for each otherjunior
- Row versions and snapshots: the on-disk mechanicsmiddle
- HOT updates and isolation levels: what you gain and what you paymiddle
- Vacuum and bloat: keeping the storage tax boundedmiddle
- CLOG, XID wraparound, and MultiXact: deep visibility internalssenior
- SSI internals and production autovacuum tuningsenior
- Real-world MVCC failures, deployment patterns, and distributed snapshotssenior
- Connection pools: amortising the cost of a Postgres backendjunior
- PgBouncer session, transaction, and statement modesmiddle
- Pool sizing: the (cores × 2) + spindles formula and the two-layer stackmiddle
- Pool exhaustion and idle-in-transaction: the 3 AM failure modemiddle
- Migrating to transaction mode: rollout playbook and PgBouncer 1.21 prepared statementsmiddle
- The Postgres process model and why raising max_connections degrades throughputsenior
- Pooler landscape 2026, serverless connection storms, and the full failure-mode taxonomysenior
- What a schema migration is and why it replaces ad-hoc DDLjunior
- ADD COLUMN: instant in PG 11+ vs rewrite in older Postgresjunior
- The lock-queue failure mode: why instant DDL can freeze the databasemiddle
- Safe DDL patterns: NOT VALID, CONCURRENTLY, and unsafe-op fixesmiddle
- Expand-contract: zero-downtime for breaking schema changesmiddle
- Advisory locks, migration tools, and deploy coordinationsenior
- Migration failure taxonomy and production disciplinesenior
- Why sharding exists: the single-Postgres ceilingjunior
- Shard-key selection: hash, range, list, and directory strategiesmiddle
- Partitioning vs sharding: same word, two different thingsmiddle
- Co-location and Citus: the invariant that makes sharding usablemiddle
- The hot-shard failure mode: detection, isolation, and durable policymiddle
- Schema-based sharding and multi-tenancy alternativessenior
- Online resharding, 2PC, and the operational cost of shardingsenior
- The seven acts: from CREATE TABLE to Citusjunior
- Acts 1–3 in depth: schema, indexes, and planner statisticsmiddle
- Acts 4–6 in depth: MVCC bloat, connection pooling, and safe migrationsmiddle
- Act 7 in depth: sharding, co-location, and the seven-tier tradeoff cascademiddle
- Observability, anti-patterns, and production triagesenior
- Raft roles, terms, and why majority quorums prevent split brainjunior
- How Raft replicates a log entry and decides it is safe to commitmiddle
- Raft leader election: timeouts, voting rules, and the four safety propertiesmiddle
- Raft in the real world: partitions, slow disks, and client routingmiddle
- Raft extensions: pre-vote, learners, snapshots, and linearizable readssenior
- Raft in production: membership changes, Multi-Raft, and observabilitysenior
- Where data fetching happens — and why it decides LCPjunior
- Fetch waterfalls — diagnosis and the Promise.all curemiddle
- React Server Components and Suspense streamingmiddle
- Client-side cache: TanStack Query, SWR, and stale-while-revalidatemiddle
- LCP, prefetch, and race conditions in interactive fetchingmiddle
- Senior internals: RSC payload, caching layers, and production failure modessenior
- The three-way handshakejunior
- Sequence numbers and connection statemiddle
- DNS: what it does and why it existsjunior
- The resolver walk: referrals, record types, and gluemiddle
- TTL, caching, and DNS propagationmiddle
- The 1-RTT handshake: key shares and ECDHEmiddle
- Session resumption and 0-RTTmiddle
- WebSocket: the HTTP upgrade handshakejunior
- WebSocket frame format: opcodes, masking, fragmentationmiddle
- WebSocket backpressure: when clients can''''t keep upmiddle
- Reconnection: jittered backoff, thundering herd, message resumptionsenior
- WebSocket at scale: HTTP/2 multiplexing, permessage-deflate, C10Msenior
- WebSocket in production: proxies, security, and distributed architecturesenior
- What reverse proxies dojunior
- Health checks, connection draining, and slow startmiddle
- Session affinity, consistent hashing, and the right fixmiddle
- Retry storms, circuit breakers, and load sheddingsenior
- Resilient LB architecture: anycast, zone-aware routing, and observabilitysenior
- Why QUIC and not TCP+TLSjunior
- Connection IDs and network migrationmiddle
- 0-RTT resumption and packet encryptionsenior
- DDoS: what it is and why it worksjunior
- Amplification attacks and state exhaustionmiddle
- Rate limiting: algorithms and architecturemiddle
- WAFs, firewalls, mTLS, and HSTSmiddle
- DNS cache poisoning and BGP hijackingsenior
- Defense-in-depth architecture and attack economicssenior
- DNS, TCP, TLS in sequence: where the milliseconds gomiddle
- Proxy intercepts and security gates: rate limiters, WAF, mTLSmiddle
- Alternate paths: QUIC 0-RTT, WebSocket upgrade, connection migrationmiddle
- Observability: distributed traces, USE/RED, and samplingsenior
- Resilience: cascading retries, circuit breakers, and error budgetssenior
- What the three signals are: logs, metrics, and tracesjunior
- Why structured logs exist: the diary vs the spreadsheetjunior
- The production log schema: fields every line must carrymiddle
- PII redaction and log injectionsenior
- OTel Logs Data Model and audit logs as a subsystemsenior
- SLI, SLO, and the error budget: reliability by the numbersjunior
- Error budget policy, latency SLOs, and composite journeysmiddle
- Production SLO failures, self-observability, security, and the big picturesenior
- The incident loop: from pager to postmortem to preventionmiddle
- Cache lines, struct layout, and false sharingmiddle
- SIMD, SoA vs AoS, and memory bandwidthmiddle
- Cache-oblivious algorithms, PGO, and production failuressenior
- GC in production: observability, security, edge cases, and fleet governancesenior
- Batching: amortize fixed cost per operationjunior
- The batching window: size and wait timemiddle
- Batching in Kafka and Postgresmiddle
- io_uring and observability of batchingmiddle
- From Nagle to io_uring: evolution of batchingmiddle
- Backpressure, failure isolation, and batch security in productionsenior
- CI enforcement and RUM: making budgets stickmiddle
- V8 JIT pipeline, HTTP priorities, and bundle securitysenior
- The performance loop: discipline, not a projectjunior
- Classify and fix: matching bottleneck families to remediesmiddle
- Observability stack and CI gates: catching regressions before they shipmiddle
- Incident to enforcement: SLO burn to verified fix in 35 minutesmiddle
- Culture, economics, and org-scale performancesenior
- At-most-once, at-least-once, exactly-once: the three delivery contractsjunior
- The three failure legs — where duplicates and losses actually happenmiddle
- Consumer-side dedup: the cheapest path to exactly-once processingmiddle
- Kafka exactly-once semantics: idempotent producer and transactionsmiddle
- SQS visibility timeout, DLQ, and the outbox patternmiddle
- Exactly-once in production: impossibility proof, hybrid patterns, and real incidentssenior
- What OAuth is and why passwords are not the answerjunior
- Authorization code flow with PKCEmiddle
- ID token validation and JWKS cache managementmiddle
- Refresh token rotation and scope-based least privilegemiddle
- Sender-constrained tokens: DPoP and mTLSsenior
- OAuth in production: audience attacks, observability, and real failuressenior
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.