Runnable companion to From Production Flamegraph to Fixed Megamorphic Call Site — a walkthrough of diagnosing and fixing V8 JIT deoptimization in Node.js.
Profilers tell you where time goes. They don't tell you why the JIT gave up.
There's a class of production performance problem that profilers can locate but not explain. The flamegraph is unambiguous — handleEvent is eating 40% of CPU. The function looks fine. Local benchmarks look fine. The problem isn't in the code; it's in what the runtime decided to do with it.
V8 optimizes by speculating on object shapes. When too many distinct shapes flow through a property access, V8 gives up on the fast path — megamorphism — and falls back to a hashtable lookup on every access. This doesn't show up in source. It's a runtime fact, invisible to the reader and undetectable by profiler sampling.
This repo shows the full loop: find it with --log-ic, fix it at the shape boundary, enforce it in CI.
events: 2,000,000, hot iterations: 20
broken 229.4ms (megamorphic loads, un-inlinable call)
fixed 54.5ms (monomorphic loads, inlined) ← ~4.2× faster
The fix is one line at the ingestion boundary. broken/handler.js and fixed/handler.js are byte-for-byte identical.
npm install
npm run bench # broken vs fixed timing (~4-5×)
npm run typecheck # strict tsc on the TypeScript example
npm run lint # static JIT-friendliness rules (--max-warnings=0)
npm run trace:broken # raw --trace-deopt --log-ic for the broken hot path
npm run trace:fixed # raw --trace-deopt --log-ic for the fixed hot path
npm run gate # CI deopt-gate on the fixed path -> PASS
npm run gate:broken # CI deopt-gate on the broken path -> FAIL
npm run ts # the TypeScript versionThis repo also ships ic-fuzzer, a tool that finds the minimal set of object shapes that push a function's inline caches to megamorphic. It uses IC severity — not crashes or coverage — as the feedback signal, which makes it novel: no existing fuzzer does this.
V8 runs JavaScript in three tiers:
Ignition (interpreter) → Maglev (mid-tier JIT) → Turbofan (optimizing compiler)
At each property access (.type, .id, etc.), Ignition maintains an inline cache (IC) — a record of which object shapes (hidden classes) that site has seen. monomorphic = 1 shape, polymorphic = 2–4, megamorphic = 5+.
When a function gets hot, Turbofan reads the IC feedback Ignition collected, then replaces the IC with specialized machine code. Monomorphic feedback → direct memory load (fast). Megamorphic feedback → generic hash-table lookup (slow, but still "optimized").
ic-fuzzer's purpose: find input shapes that push a call site to megamorphic during Ignition, before Turbofan reads the accumulated feedback. The ICs disappear after Turbofan compiles — but their legacy is baked into the generated machine code. Megamorphic IC feedback causes Turbofan to emit permanently slower code.
What "no IC data" means:
| Situation | Cause | What to do |
|---|---|---|
| Function never called | Wrong export name, or seed doesn't trigger the call path | Fix the seed |
X/no_feedback state |
Turbofan compiled before the probe ran; IC sites replaced by specialized code | Use --no-turbofan to observe Ignition-phase ICs |
| No property accesses | Function doesn't read properties on its argument | ic-fuzzer doesn't apply |
cd ic-fuzzer
node bin/ic-fuzzer.js ../broken/handler.js handleEvent \
--seed='{"type":"click","id":1,"value":2}'[ic-fuzzer] minimal set: 5 shape(s) (0 shrink step(s))
1. literal:type-id-value { type, id, value } literal
2. incr:type-value-id e.type; e.value; e.id incremental
3. incr:id-type-value e.id; e.type; e.value incremental
4. incr:value-type-id e.value; e.type; e.id incremental
5. incr:value-id-type e.value; e.id; e.type incremental
[ic-fuzzer] IC sites at megamorphic:
[MEGAMORPHIC ] .id handleEvent (broken/handler.js:10:16)
[MEGAMORPHIC ] .type handleEvent (broken/handler.js:10:32)
[MEGAMORPHIC ] .value handleEvent (broken/handler.js:10:52)
Use --watch=<file> when your entry point is a thin wrapper around a library — it redirects IC collection to the file you actually care about:
node bin/ic-fuzzer.js test/fixtures/picomatch-scan-entry.js scanDirect \
--seed='{"pattern":"**/*.{js,ts}","parts":false,...}' \
--watch=../node_modules/picomatch/lib/scan.jsOther flags: --target=polymorphic, --corpus=<file>, --collector=<file>, --dry-run, --rng=<n> (reproduce), --runs=<n>, --count=<n>, --iters=<n>, --function=<name>, --report-maps, --trace-reads, --trace-deopt, --no-turbofan, --max-maps=N.
ic-fuzzer covers the complete find → quantify → fix → verify loop:
# 1. Find it
ic-fuzzer ./handler.js handleEvent --seed='{"type":"click","id":1,"value":2}'
# → minimal set: 5 shapes, IC sites: .id .type .value
# 2. Quantify — is this worth fixing? (--bench compares mono vs mixed-shape timing)
ic-fuzzer ./handler.js handleEvent --seed='...' --bench
# → [ic-fuzzer] bench: mono=54ms mixed=229ms Δ=4.2×
# 3. Fix it — write a boundary normalizer (e.g. toEvent()) that coerces inputs
# to a single canonical shape (class instance or literal) before the hot path
# 4. Verify — CI gate that fails if any IC rises above monomorphic
ic-fuzzer ./handler.js handleEvent --seed='...' --assert-max=monomorphic
# → [ic-fuzzer] PASS — handleEvent stays within monomorphic (exit 0)Why fixed/handler.js still reports megamorphic without a gate.
ic-fuzzer feeds plain objects directly to the target function, bypassing any boundary normalizer. If your fix is a toEvent() that coerces raw inputs to a new Event(...), running ic-fuzzer on handleEvent after the fix will still report megamorphic — because ic-fuzzer never calls toEvent. This is expected: the megamorphic reads were moved to the boundary, not eliminated.
The right targets after a boundary-normalization fix:
handleEventwith--assert-max=monomorphic— verifies the hot path is now cleantoEvent(without--assert-max) — verifies the boundary does what it should: finds megamorphism, confirming the coalescing reads are confined there
ic-fuzzer exposes its full internals as a Node module:
const { fuzz, probe, bench, derive, fromCorpus, SEV_LABEL } = require('ic-fuzzer');probe(fnFile, fnName, strategies, watchFile?, count?, iters?) → { severity, ics, hasICs, crashed, error }
Run one shaped probe and get back raw IC severity. Use this for a CI assertion that a hot path stays monomorphic:
// ci/monomorphic-gate.js
const path = require('path');
const { probe, derive } = require('ic-fuzzer');
const file = path.resolve('src/handler.js');
const seed = { type: 'click', id: 1, value: 2 };
(async () => {
// Five distinct shapes — enough to trigger megamorphism if the IC is polymorphic
const five = derive(seed).slice(0, 5);
const { severity } = await probe(file, 'handleEvent', five);
if (severity >= 3) {
console.error(`handleEvent is megamorphic (severity ${severity}) — normalize at the boundary`);
process.exit(1);
}
console.log('handleEvent is JIT-friendly');
})();fuzz(opts) → { found, minimalStrategies, ics, numShrinks, rngSeed, anyICs, anyMonomorphic, numCrashes }
Run the full fast-check search + shrink loop. Useful for fuzzing internal closures or class methods that aren't exported directly — wrap them in a thin harness file:
// test/fuzz-internal.js
const path = require('path');
const { fuzz, derive } = require('ic-fuzzer');
// harness.js exports a wrapper around the private function under test
const file = path.resolve('test/fuzz-harness.js');
(async () => {
const result = await fuzz({
fnFile: file,
fnName: 'processNode',
strategies: derive({ type: 'Identifier', name: 'x', start: 0, end: 1 }),
targetSev: 3, // 2 = polymorphic, 3 = megamorphic
numRuns: 200,
onRun({ subset, severity, phase }) {
if (severity >= 3) console.log(' megamorphic:', subset.join(', '));
},
});
if (result.found) {
console.error(`megamorphic set: ${result.minimalStrategies.map(s => s.name).join(', ')}`);
process.exit(1);
}
console.log('no megamorphism found');
})();ts-jit-deopt/
ARTICLE.md ← the full article
event-source.js ← heterogeneous event source (pure shape variation)
bench.js ← broken vs fixed timing harness
broken/
handler.js ← the hot path (receives many shapes → megamorphic)
drive.js ← driver
fixed/
event.js ← canonical shape + boundary normalizer
handler.js ← identical hot path (receives one shape → monomorphic)
drive.js ← driver (normalized at the boundary)
ci/
deopt-gate.js ← fail CI on mono→megamorphic regression
gate-driver.js ← replays the corpus through the chosen handler
ic-fuzzer/
bin/ic-fuzzer.js ← CLI: ic-fuzzer <file> <export> --seed=<json> [--watch=<file>]
src/mutate.js ← derives shape strategies from a seed object
src/probe.js ← subprocess driver: runs under --log-ic, parses IC severity
src/fuzzer.js ← fast-check loop: search + shrink to minimal shape set
src/reporter.js ← progress, result formatting, corpus.json output
fuzzer/ ← legacy shape fuzzer (predates ic-fuzzer)
ts/
example.ts ← the TypeScript version (boundary encoded in types)
.github/workflows/
deopt-gate.yml ← drop-in GitHub Actions job
MIT © 2026 Eric San. See LICENSE.