---
name: v8-jit
description: >
V8 JIT optimization patterns for writing high-performance JavaScript in
Next.js server internals. Use when writing or reviewing hot-path code in
app-render, stream-utils, routing, caching, or any per-request code path.
Covers hidden classes / shapes, monomorphic call sites, inline caches,
megamorphic deopt, closure allocation, array packing, and profiling with
--trace-opt / --trace-deopt.
user-invocable: false
---
# V8 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:
1. **Ignition** (interpreter) — executes bytecode immediately.
2. **Sparkplug** — fast baseline compiler (no optimization).
3. **Maglev** — mid-tier optimizing compiler.
4. **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
```ts
// 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
}
}
```
```ts
// 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` / `false` as default values rather than
omitting the property.
- Prefer factory functions when constructing hot-path objects. A single factory
makes it harder to accidentally fork shapes in different call sites.
- Never `delete` a property on a hot object — it forces a transition to
dictionary mode (slow properties).
- Avoid adding properties after construction (`obj.newProp = x`) on objects
used in hot paths.
- 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.
```ts
// 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
```ts
// 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` → `Uint8Array`
once) and keep internal functions monomorphic.
- Avoid passing both `null` and `undefined` for the same parameter — pick one
sentinel value.
- When a function must handle multiple types, split into separate specialized
functions and dispatch once at the entry point:
```ts
// 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
```ts
// 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)
}
```
```ts
// 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
```ts
// 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 |
| ----------------- | ----------------------------- | --------------------------- |
| `PACKED_SMI` | Small integers only, no holes | Fastest |
| `PACKED_DOUBLE` | Numbers only, no holes | Fast |
| `PACKED_ELEMENTS` | Mixed/objects, no holes | Moderate |
| `HOLEY_*` | 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: `new Array(n)` creates a holey array.
Prefer `[]` and `push()`, or use `Array.from({ length: n }, initFn)`.
- Don't create holes: `arr[100] = x` on an empty array creates 100 holes.
- Don't mix types: `[1, 'two', {}]` immediately becomes `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.
```ts
// 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
- **`arguments` object**: using `arguments` in non-trivial ways (e.g.
`arguments[i]` with variable `i`, leaking `arguments`). Use rest params
instead.
- **Type instability at one call site**: same operation sees both numbers and
strings (or many object shapes) and becomes polymorphic/megamorphic.
- **`eval` / `with`**: prevents optimization entirely.
- **Highly dynamic object iteration**: avoid `for...in` on hot objects; prefer
`Object.keys()` / `Object.entries()` when possible.
### Favor Predictable Control Flow
```ts
// 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
```ts
// 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.
- **`string.indexOf()` > regex** for simple substring checks.
- **Reuse RegExp objects**: don't create a `new RegExp()` inside a hot
function — hoist it to module scope.
```ts
// 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
- **`Map`** is faster than plain objects for frequent additions/deletions
(avoids hidden class transitions and dictionary mode).
- **`Set`** is faster than `obj[key] = true` for membership checks with
dynamic keys.
- For **static lookups** (known keys at module load), plain objects or
`Object.freeze({...})` are fine — V8 optimizes them as constant.
- 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
```bash
# 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
```bash
# 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`:
```js
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 `delete` on hot objects
- [ ] 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`/`Set` used for dynamic key collections
- [ ] No `arguments` object — use rest params
- [ ] `try/catch` at function boundary, not inside tight loops
- [ ] String building via array + `join()` or `Buffer.concat()`
- [ ] Return types are consistent (no `string | null | undefined` mixes)
## Related Skills
- `$dce-edge` — DCE-safe require patterns (compile-time dead code)
- `$runtime-debug` — runtime bundle debugging and profiling workflow