Core principles
Keep V8 on the fast path:
- Consistent shapes — same properties, same order, never
delete - Stable types — don't mix parameter types across calls
- Minimize allocations — every
{},[], spread is GC work - Simple operations first — manual scanning over regex,
forover iterators
Optimization workflow
- Before running any benchmark, check power conditions, free disk space, CPU idle time and the busiest processes. If competing workloads, resource pressure or power constraints could distort timings, stop and report the evidence to the user; do not benchmark, wait, retry or relax the criteria
- Profile a representative workload before changing code; use a plain workload if generated benchmark functions obscure attribution. Measure gains without profiling
- Check equivalent outputs and observable behavior outside timing; for lazy APIs, benchmark construction, shallow access, full materialization and serialization separately as relevant
- Benchmark representative inputs: warm up steady-state runs, measure cold startup separately, and consume results to prevent dead-code elimination
- Compare library builds in fresh, sequential processes with identical inputs and flags; alternate AB/BA order across rounds and compare paired timing ratios
- For cases sharing a process, check for interference from persistent runtime state or background work: compare isolated runs with mixed runs in different orders, and isolate affected cases in fresh processes
- Keep load, power and thermal conditions stable; use repeated identical-build A/A controls with the same harness to estimate noise. Treat A/B differences within uncertainty as unresolved; ask the user to pause competing work only if needed
- Inspect per-run sample distributions and round timings before aggregating results; multiple timing modes or slow rounds shared by builds warrant investigation
- Check scan complexity by counting primitive operations on adversarial inputs at increasing sizes; elapsed-time thresholds can miss repeated rescanning
- Keep changes only when measured results or better asymptotic behavior justify their complexity. Record commits, runtime, machine and workload, and rerun affected benchmarks at the final revision
Object shapes & inline caches
- Initialize objects with the same properties in the same order — V8 assigns hidden classes (shapes); consistent shapes keep functions monomorphic (fastest), 5+ shapes = megamorphic (hash-table fallback)
- Never conditionally add properties — always set the property (to
undefinedif needed) to keep shapes consistent - Avoid
delete obj.prop— mutates hidden class, forces dictionary mode; set toundefinedinstead - Watch for megamorphism in shared utility functions — a helper called with many different object shapes across the codebase goes megamorphic; split into shape-specific fast paths or use a type-tag
switchto recover monomorphic performance - Keep function parameter types stable — mixed types prevent TurboFan specialization
- Measure string vs numeric tags before changing representation; V8 can compare internalized strings by identity. Benchmark
switch, object andMapdispatch with representative tag frequencies and handler bodies - Consider prototype accessors to share behavior across instances; measure construction and access costs, preserve own-property and serialization semantics, and keep mutable instance data per object
Strings & regex
- Avoid
.split()for simple parsing — scan withindexOf/sliceinstead (one pass, no intermediate array allocation) - Don't hand-roll char-by-char splitting to avoid
.split()— native.split()is a C++ builtin and beats a JS accumulation loop (cur += s[i]); even anindexOf/sliceloop that still builds the array loses to it. Only skip.split()when you can extract what you need in a singleindexOf/slicepass with no array materialization ("".split(sep)returns[""]not[], so guard if you need[]) - Use
.includes()or.startsWith()before regex — avoids regex engine for the common non-matching case - Avoid unnecessary
.trim(),.replace()— forces V8 to materialize internal rope/cons-string representations - Prefer
String.fromCharCode()overString.fromCodePoint()for BMP characters (< 0x10000) — faster path, and characters outside BMP are rare - Use
charCodeAt()for character classification in hot loops — numeric comparison is faster than string operations - Accumulate ranges, then
.substring()once — avoid character-by-character string concatenation - Pre-compute character code constants — avoid runtime
charCodeAt()on string literals in hot paths - Hoist invariant string building out of hot-loop comparisons —
arr.find(d => path.startsWith(${d}/))rebuilds${d}/every iteration; precompute the concatenated forms once and compare against the pre-built strings (applies to any+/template in a loop that doesn't vary with the iteration) - Compare manual scanning with built-in searches and regex for simple patterns; avoid rescanning when classification can share an existing loop
- For "contains any of these characters", compare
/[*?\[]/.test(s)with chainedindexOfcalls and a character loop across lengths, misses and match positions; regex search is not constant-time, and no length cutoff is portable - Cache compiled regexes outside loops — dynamic
new RegExp()in a loop recompiles every iteration - Reuse a single
Intl.Collatorinstance over repeatedlocaleCompare()calls
Collections
- Prefer
Mapover plain objects for dynamic key-value collections — large Maps are faster for insertion, key lookup and iteration; use objects only for static/known-shape data - Keep arrays homogeneous — all integers =
PACKED_SMI(fastest), adding a float transitions toPACKED_DOUBLE, adding a string transitions toPACKED_ELEMENTS(slowest); transitions are one-way - Prefer array literals over
new Array(n)— pre-sized arrays start holey and can stay on slower paths; when you need a pre-sized dense array, initialize it immediately with.fill(value) - Avoid out-of-bounds array reads: missing elements can require prototype checks and introduce
undefinedinto later operations; guard an empty stack beforestack[stack.length - 1], since-1is an ordinary property key - Use
Set.has()overArray.includes()for repeated lookups — O(1) vs O(n) - Don't double-look up a Map —
if (map.has(k)) map.get(k).add(v)hashes the key twice (hasandgeteach run the lookup); take the value once withconst inner = map.get(k); if (inner) inner.add(v)(ormap.get(k)?.add(v)). Caveat: the single-lookup form treats a missing key as falsy, so only use it whenundefined/falsy isn't a valid stored value - Cache repeated lookups over static data — a linear scan (
.find(),.filter()) called per item in an outer loop becomes O(n × m); memoize when the underlying data doesn't change between calls - Use binary search on sorted arrays instead of
.findIndex()— O(log n) vs O(n) - Prefer TypedArrays for large numeric data — contiguous memory enables CPU prefetching
Functions & control flow
- A function created per hot-loop iteration only costs when it escapes — stored, registered, or passed where its identity is observed. V8 often inlines or elides the rest (immediately-invoked callbacks, args to builtins like
.map/.then, object-property callbacks), so don't flag a non-escaping callback as a cost without evidence it's retained. For merge/compose fan-out, prefer an array of handlers + a plain iteration loop over nestedmerged = (x) => { prev(x); next(x) }wrappers applied N times: the nested chain gets progressively slower as it deepens, while the array loop stays flat. A one-off 2-way compose is fine (it inlines to two direct calls); don't build deeper wrapper chains expecting a speedup - Push, don't poll, on growing shared collections — when a producer mutates a Set/Array the consumer needs to react to, fire a callback on each add; polling "did the size grow? then re-iterate" is O(N×M) because each consumer pass re-walks the full collection from the start
- Deduplicate repeated registrations in multi-tenant loops — when the same handler/callback is registered once per iteration (e.g. per workspace, per route, per config entry), identical work multiplies; track what's already registered and skip duplicates
- Keep hot functions small — V8's inlining budgets change by version and code shape; extract cold/error paths when it keeps the hot path simple enough to inline
- Prefer
for/whileover.forEach()in measured tight loops — callback overhead and inlining limits can matter, but don't flag non-escaping callbacks without profiler or benchmark evidence - Avoid the
argumentsobject — use rest params (...args); even referencingargumentsinhibits optimization - Match function arity at call sites — mismatched arity creates arguments adaptor frames
- Arguments are evaluated eagerly:
peek(computeCtx())callscomputeCtx()even on a cached path; move it into the branch that needs it when skipping its effects preserves behavior - Generators have inherent overhead — provide array-returning alternative for callers that need all results; use generators only when lazy evaluation is needed
- A generator's cost is the per-element
yieldsuspend/resume itself — V8 inlines surrounding identity wrappers, nested closures, and dead constant-guarded branches (if (DEBUG) …) to ~0, so stripping that wrapping for speed does nothing. To remove the cost, return an array instead of yielding (trades a one-time allocation for no per-element suspension). Eliminate the generator, not the wrapping - Don't use try/catch for expected control flow — use APIs that return null/undefined (e.g.
fs.statSync(file, { throwIfNoEntry: false })) because Error objects capture stack traces, which is expensive - Keep try blocks small — V8 optimizes code outside try blocks more aggressively
- Avoid
Proxyin hot paths — V8 falls back from JIT to interpreter
Allocation & GC pressure
- Minimize object allocations in hot paths — every
{},[],newis GC work - Avoid
{...spread}for copying objects in hot paths — allocates + copies all properties; mutate or use a dedicated clone function;structuredCloneis even worse — for shallow copies,Object.assign/spread is dramatically faster thanstructuredClone - Cache deep property chains in local vars —
const x = obj.a.b.cavoids repeated pointer dereferences - Short-circuit common cases to avoid allocations — e.g. return single element directly instead of
.join()on a one-element array - Reuse objects with
reset()/copyFrom()instead of allocating new ones — swap references instead of creating - Measure before reusing allocations — object pooling/reuse adds complexity; V8 handles short-lived same-shape objects efficiently via young-generation GC, so per-call
new Set()may be cheaper than maintaining reusable state - Memoize allocators whose output is deterministic from their input — a function returning a fresh
Set/array/object built from its args (e.g.getDependencies(name) { return new Set([...a, ...b]) }) reallocates an identical result on every call; cache by input when the same inputs recur. Caveats: the cached value is now shared, so callers must treat it as immutable, and it only pays off when inputs actually repeat - Allocate optional caches on first use: initialize
this.cache = nullconsistently, then usethis.cache ??= new Map(); for lazy results that may be nullish, use a separate initialized flag or a sentinel outside the result domain - Never use an unbounded
Map/object as a cache — it's a memory leak; uselru-cachewithmax+ttlto bound growth, orWeakMapwhen keys are objects with independent lifetimes
Loops & state machines
- Use numeric state variables — integer comparison is cheaper than string/object state
- Use index-based
whileloops for tight scanning — faster than iterators - Pre-compute lookup tables (Map/object) for classification — trade O(1) build time for O(1) lookups in hot paths
- Pack multiple boolean flags into a
Uint8Arraylookup table — use bitwise& 1,& 2to test individual flags from a single byte - Benchmark lookup tables against short
===chains; neither wins universally, and input distribution matters - Before adding bits to a shared lookup table, audit truthiness consumers (
if (table[c])); a new nonzero entry can change their meaning, so use explicit masks for individual categories - Cache
indexOfresults and search forward from last position — avoid rescanning from the start - Split fast path / slow path — check for the common simple case first and return early, only fall through to complex parsing when needed
- Fold classification into the consuming scan when it avoids duplicate lookahead without complicating parser state or backtracking
- Put cheap, selective predicates before forward scans when evaluation order is interchangeable; repeatedly scanning overlapping suffixes can turn a linear loop quadratic
- Measure loop-invariant branch removal before claiming a gain; the JIT may already hoist or eliminate it, and predictable branches can be cheap
- Use character lookahead before committing to state changes — peek at next char(s) to decide the operation without advancing position
Async
- Avoid unnecessary
async/await— async boundaries are not free, especially in hot loops; don'tawaitnon-promise values, don't wrap already-async functions in redundantasyncwrappers, and measure before contorting readable code - Cap concurrency on
Promise.allover dynamic-length arrays — unbounded parallel I/O exhausts memory, file descriptors, and connection pools; usep-limitorp-mapwith an explicit concurrency limit
Modules
- Lazy-load non-critical modules — dynamic
import()orrequire()inside conditional blocks avoids loading unused code - Avoid barrel files (
index.tsre-exporting everything) — each re-export adds to the module graph
Resources
V8 internals — the "why" behind most of the rules above:
- Sparkplug — baseline compiler: why simple code gets fast quickly before top-tier optimization
- Maglev — mid-tier optimizer: how modern V8 reaches optimized code without going straight to TurboFan
- TurboFan — optimizing compiler: function inlining, dead-code elimination, and version-dependent inlining budgets
- Fast properties in V8 — hidden classes (shapes); why consistent property order and avoiding
deletekeep objects monomorphic - Elements kinds in V8 — packed vs holey arrays and the one-way SMI → double → elements transitions
- What's up with monomorphism? — Vyacheslav Egorov on inline caches and mono/poly/megamorphic call and property sites
- Speculative optimization in V8 — Benedikt Meurer on feedback vectors driving the fast path
- Trash talk: the Orinoco garbage collector — generational GC; why short-lived, same-shape allocations are cheap
- Built-in functions — native methods (
.split(), etc.) are CSA/Torque builtins, hard to beat with hand-written JS
Specific patterns:
- Faster async functions and promises — async/promise optimization history; treat old microtask-cost rules as version-sensitive
- Speeding up spread elements — iterator-protocol overhead vs. the array fast path
- ECMAScript generators from a performance perspective — Andy Wingo on the
yieldsuspend/resume cost
Reference:
- Node.js profiling — official
node --profworkflow - Node.js flame graphs — official guide for CPU flame graph analysis
- Node.js heap snapshots — official memory diagnostics workflow
- ECMA-262 — the language spec, e.g.
String.prototype.splitedge cases - CPU profile schema: sampled nodes and optional per-line
positionTicks - Node.js CPU profiling flags:
--cpu-profoutput and sampling configuration
Measure before trusting any rule here — V8 behavior shifts between versions; use a harness that defeats dead-code elimination such as mitata.