Next.js v8-jit
git clone https://github.com/vercel/next.js
T=$(mktemp -d) && git clone --depth=1 https://github.com/vercel/next.js "$T" && mkdir -p ~/.claude/skills && cp -r "$T/.agents/skills/v8-jit" ~/.claude/skills/vercel-next-js-v8-jit && rm -rf "$T"
.agents/skills/v8-jit/SKILL.mdV8 JIT Optimization
Use this skill when writing or optimizing performance-critical code paths in Next.js server internals — especially per-request hot paths like rendering, streaming, routing, and caching.
Background: V8's Tiered Compilation
V8 compiles JavaScript through multiple tiers:
- Ignition (interpreter) — executes bytecode immediately.
- Sparkplug — fast baseline compiler (no optimization).
- Maglev — mid-tier optimizing compiler.
- Turbofan — full optimizing compiler (speculative, type-feedback-driven).
Code starts in Ignition and is promoted to higher tiers based on execution frequency and collected type feedback. Turbofan produces the fastest machine code but bails out (deopts) when assumptions are violated at runtime.
The key principle: help V8 make correct speculative assumptions by keeping types, shapes, and control flow predictable.
Hidden Classes (Shapes / Maps)
Every JavaScript object has an internal "hidden class" (V8 calls it a Map, the spec calls it a Shape). Objects that share the same property names, added in the same order, share the same hidden class. This enables fast property access via inline caches.
Initialize All Properties in Constructors
// GOOD — consistent shape, single hidden class transition chain class RequestContext { url: string method: string headers: Record<string, string> startTime: number cached: boolean constructor(url: string, method: string, headers: Record<string, string>) { this.url = url this.method = method this.headers = headers this.startTime = performance.now() this.cached = false // always initialize, even defaults } }
// BAD — conditional property addition creates multiple hidden classes class RequestContext { constructor(url, method, headers, options) { this.url = url this.method = method if (options.timing) { this.startTime = performance.now() // shape fork! } if (options.cache) { this.cached = false // another shape fork! } this.headers = headers } }
Rules:
- Assign every property in the constructor, in the same order, for every
instance. Use
/null
/undefined
as default values rather than omitting the property.false - Prefer factory functions when constructing hot-path objects. A single factory makes it harder to accidentally fork shapes in different call sites.
- Never
a property on a hot object — it forces a transition to dictionary mode (slow properties).delete - Avoid adding properties after construction (
) on objects used in hot paths.obj.newProp = x - Object literals that flow into the same function should have keys in the same order:
- Use tuples for very small fixed-size records when names are not needed. Tuples avoid key-order pitfalls entirely.
// GOOD — same key order, shares hidden class const a = { type: 'static', value: 1 } const b = { type: 'dynamic', value: 2 } // BAD — different key order, different hidden classes const a = { type: 'static', value: 1 } const b = { value: 2, type: 'dynamic' }
Real Codebase Example
Span in src/trace/trace.ts initializes all fields in the constructor in a
fixed order — name, parentId, attrs, status, id, _start, now.
This ensures all Span instances share one hidden class.
Monomorphic vs Polymorphic vs Megamorphic
V8's inline caches (ICs) track the types/shapes seen at each call site or property access:
| IC State | Shapes Seen | Speed |
|---|---|---|
| Monomorphic | 1 | Fastest — single direct check |
| Polymorphic | 2–4 | Fast — linear search through cases |
| Megamorphic | 5+ | Slow — hash-table lookup, no inlining |
Once an IC goes megamorphic it does NOT recover (until the function is re-compiled). Megamorphic ICs also prevent Turbofan from inlining the function.
Keep Hot Call Sites Monomorphic
// GOOD — always called with the same argument shape function processChunk(chunk: Uint8Array): void { // chunk is always Uint8Array → monomorphic } // BAD — called with different types at the same call site function processChunk(chunk: Uint8Array | Buffer | string): void { // IC becomes polymorphic/megamorphic }
Practical strategies:
- Normalize inputs at the boundary (e.g. convert
→Buffer
once) and keep internal functions monomorphic.Uint8Array - Avoid passing both
andnull
for the same parameter — pick one sentinel value.undefined - When a function must handle multiple types, split into separate specialized functions and dispatch once at the entry point:
// Entry point dispatches once function handleStream(stream: ReadableStream | Readable) { if (stream instanceof ReadableStream) { return handleWebStream(stream) // monomorphic call } return handleNodeStream(stream) // monomorphic call }
This is the pattern used in
stream-ops.ts and throughout the stream-utils
code (Node.js vs Web stream split via compile-time switcher).
Closure and Allocation Pressure
Every closure captures its enclosing scope. Creating closures in hot loops or per-request paths generates GC pressure and can prevent escape analysis.
Hoist Closures Out of Hot Paths
// BAD — closure allocated for every request function handleRequest(req) { stream.on('data', (chunk) => processChunk(chunk, req.id)) } // GOOD — shared listener, request context looked up by stream const requestIdByStream = new WeakMap() function onData(chunk) { const id = requestIdByStream.get(this) if (id !== undefined) processChunk(chunk, id) } function processChunk(chunk, id) { /* ... */ } function handleRequest(req) { requestIdByStream.set(stream, req.id) stream.on('data', onData) }
// BEST — pre-allocate the callback as a method on a context object class StreamProcessor { id: string constructor(id: string) { this.id = id } handleChunk(chunk: Uint8Array) { processChunk(chunk, this.id) } }
Avoid Allocations in Tight Loops
// BAD — allocates a new object per iteration for (const item of items) { doSomething({ key: item.key, value: item.value }) } // GOOD — reuse a mutable scratch object const scratch = { key: '', value: '' } for (const item of items) { scratch.key = item.key scratch.value = item.value doSomething(scratch) }
Real Codebase Example
node-stream-helpers.ts hoists encoder, BUFFER_TAGS, and tag constants to
module scope to avoid re-creating them on every request. The bufferIndexOf
helper uses Buffer.indexOf (C++ native) instead of a per-call JS loop,
eliminating per-chunk allocation.
Array Optimizations
V8 tracks array "element kinds" — an internal type tag that determines how elements are stored in memory:
| Element Kind | Description | Speed |
|---|---|---|
| Small integers only, no holes | Fastest |
| Numbers only, no holes | Fast |
| Mixed/objects, no holes | Moderate |
| Any of above with holes | Slower (extra bounds check) |
Transitions are one-way — once an array becomes
HOLEY or PACKED_ELEMENTS,
it never goes back.
Rules
- Pre-allocate arrays with known size:
creates a holey array. Prefernew Array(n)
and[]
, or usepush()
.Array.from({ length: n }, initFn) - Don't create holes:
on an empty array creates 100 holes.arr[100] = x - Don't mix types:
immediately becomes[1, 'two', {}]
.PACKED_ELEMENTS - Prefer typed arrays only when you need binary interop/contiguous memory or have profiling evidence that they help. For small/short-lived collections, normal arrays can be faster and allocate less.
// GOOD — packed SMI array const indices: number[] = [] for (let i = 0; i < n; i++) { indices.push(i) } // BAD — holey from the start const indices = new Array(n) for (let i = 0; i < n; i++) { indices[i] = i }
Real Codebase Example
accumulateStreamChunks in app-render.tsx uses const staticChunks: Array<Uint8Array> = [] with push() — keeping a packed array of a single type
throughout its lifetime.
Function Optimization and Deopts
Hot-Path Deopt Footguns
object: usingarguments
in non-trivial ways (e.g.arguments
with variablearguments[i]
, leakingi
). Use rest params instead.arguments- Type instability at one call site: same operation sees both numbers and strings (or many object shapes) and becomes polymorphic/megamorphic.
/eval
: prevents optimization entirely.with- Highly dynamic object iteration: avoid
on hot objects; preferfor...in
/Object.keys()
when possible.Object.entries()
Favor Predictable Control Flow
// GOOD — predictable: always returns same type function getStatus(code: number): string { if (code === 200) return 'ok' if (code === 404) return 'not found' return 'error' } // BAD — returns different types function getStatus(code: number): string | null | undefined { if (code === 200) return 'ok' if (code === 404) return null // implicitly returns undefined }
Watch Shape Diversity in switch
Dispatch
switch// WATCH OUT — `node.type` IC can go megamorphic if many shapes hit one site function render(node) { switch (node.type) { case 'div': return { tag: 'div', children: node.children } case 'span': return { tag: 'span', text: node.text } case 'img': return { src: node.src, alt: node.alt } // Many distinct node layouts can make this dispatch site polymorphic } }
This pattern is not always bad. Often the main pressure is at the shared dispatch site (
node.type), while properties used only in one branch stay
monomorphic within that branch. Reach for normalization/splitting only when
profiles show this site is hot and polymorphic.
String Operations
- String concatenation in loops is usually fine in modern V8 (ropes make
many concatenations cheap). For binary data, use
.Buffer.concat() - Template literals vs concatenation: equivalent performance in modern V8, but template literals are clearer.
> regex for simple substring checks.string.indexOf()- Reuse RegExp objects: don't create a
inside a hot function — hoist it to module scope.new RegExp()
// GOOD — regex hoisted to module scope const ROUTE_PATTERN = /^\/api\// function isApiRoute(path: string): boolean { return ROUTE_PATTERN.test(path) } // BAD — regex recreated on every call function isApiRoute(path: string): boolean { return /^\/api\//.test(path) // V8 may or may not cache this }
Map
and Set
vs Plain Objects
MapSet
is faster than plain objects for frequent additions/deletions (avoids hidden class transitions and dictionary mode).Map
is faster thanSet
for membership checks with dynamic keys.obj[key] = true- For static lookups (known keys at module load), plain objects or
are fine — V8 optimizes them as constant.Object.freeze({...}) - Never use an object as a map if keys come from user input (prototype pollution risk + megamorphic shapes).
Profiling and Verification
V8 Flags for Diagnosing JIT Issues
# Trace which functions get optimized node --trace-opt server.js 2>&1 | grep "my-function-name" # Trace deoptimizations (critical for finding perf regressions) node --trace-deopt server.js 2>&1 | grep "my-function-name" # Combined: see the full opt/deopt lifecycle node --trace-opt --trace-deopt server.js 2>&1 | tee /tmp/v8-trace.log # Show IC state transitions (verbose) node --trace-ic server.js 2>&1 | tee /tmp/ic-trace.log # Print optimized code (advanced) node --print-opt-code --code-comments server.js
Targeted Profiling in Next.js
# Profile a production build node --cpu-prof --cpu-prof-dir=/tmp/profiles \ node_modules/.bin/next build # Profile the server during a benchmark node --cpu-prof --cpu-prof-dir=/tmp/profiles \ node_modules/.bin/next start & # ... run benchmark ... # Analyze in Chrome DevTools: chrome://inspect → Open dedicated DevTools # Quick trace-deopt check on a specific test node --trace-deopt $(which jest) --runInBand test/path/to/test.ts \ 2>&1 | grep -i "deopt" | head -50
Using %
Natives (Development/Testing Only)
%With
--allow-natives-syntax:
function hotFunction(x) { return x + 1 } // Force optimization %PrepareFunctionForOptimization(hotFunction) hotFunction(1) hotFunction(2) % OptimizeFunctionOnNextCall(hotFunction) hotFunction(3) // Check optimization status // 1 = optimized, 2 = not optimized, 3 = always optimized, 6 = maglev console.log(%GetOptimizationStatus(hotFunction))
Checklist for Hot Path Code Reviews
- All object properties initialized in constructor/literal, same order
- No
on hot objectsdelete - No post-construction property additions on hot objects
- Functions receive consistent types (monomorphic call sites)
- Type dispatch happens at boundaries, not deep in hot loops
- No closures allocated inside tight loops
- Module-scope constants for regex, encoders, tag buffers
- Arrays are packed (no holes, no mixed types)
-
/Map
used for dynamic key collectionsSet - No
object — use rest paramsarguments -
at function boundary, not inside tight loopstry/catch - String building via array +
orjoin()Buffer.concat() - Return types are consistent (no
mixes)string | null | undefined
Related Skills
— DCE-safe require patterns (compile-time dead code)$dce-edge
— runtime bundle debugging and profiling workflow$runtime-debug