JavaScript Performance Benchmarks: Loops, Arrays, Strings, and Map vs Object (Node 24)

Which loop, array-building pattern, string-concatenation style, or key-value container is actually fastest in modern Node.js? This article answers with numbers measured on one machine on 2026-10-17, explains why each result comes out the way it does, and includes the complete benchmark script so you can reproduce every number.

Quick answer: In hot code that V8 has fully optimized, loop syntax barely matters: an indexed for loop, for...of and reduce all summed numbers in about 0.26–0.31 ns per element (forEach 0.65 ns), but which compiler tier the code is running in changed the same loop by more than 10×. The bigger, more reliable wins were elsewhere: filling a preallocated new Array(n) was 18.9× faster than Array.from({ length: n }, fn), concat merged large arrays 5.5× faster than spread, and Map beat plain objects for deletes and insert/delete churn while objects won lookups with reused key strings.

Difficulty: Intermediate to Advanced

You'll understand this better if you know: arrays and array methods, Map and Set, closures, and the basic idea of JIT optimization in V8.

1. Test Setup

ItemValue
MachineApple M6, 24 GB RAM (12 cores; sysctl reports 2 “Super”, 4 “Performance” and 6 “Efficiency” cores)
Operating systemmacOS 27.0.1 (build 26A434)
RuntimeNode.js v24.21.0, V8 13.6.233.17-node.53 (from node --version and process.versions.v8)
V8 buildPointer compression off (process.config.variables.v8_enable_pointer_compression is 0), so every array element slot takes 8 bytes
Node flags--expose-gc only (to force a GC between runs); extra runs with V8 tier flags are labeled
Timerprocess.hrtime.bigint() (nanoseconds)
Date measured2026-10-17

Methodology

  • One process per case. The driver starts a fresh Node process for every case, so type feedback, compiled code and heap contents from one case cannot affect another.
  • Warm-up. At least 1 s of single repetitions, then at least 5 full-size runs and another 1 s, none of it timed. Section 2 shows why warm-up length matters so much in JavaScript.
  • 10 measured runs per case. A run repeats the operation enough times to last milliseconds. Tables report the median time per element or operation (ns/op), the min–max range across the 10 runs, and speed relative to the fastest variant in the same table.
  • Garbage collection. A forced gc() runs before each measured run, outside the timed region. Collections triggered during a run are included, because allocation cost is part of what is being compared.
  • No dead code. Every result is folded into a checksum that the script prints, so V8 cannot drop the work. String results are read (one character), which forces V8 to finish any deferred concatenation work (section 4).
  • Repeated twice. The full suite ran twice back to back; the Re-run column is the second run’s median. A third full run used --no-maglev (explained in section 2). The machine was idle apart from normal background desktop processes (1-minute load average 4.25 before the first run, partly from the author’s own test runs just before, and 1.71 before the second).

Across all 74 cases, the median min–max spread within a run was 4% and the median difference between the two runs was 2%. Cases that were noisier are called out in section 9.

2. Loops: for vs for...of vs forEach vs reduce

Each variant sums an array of small integers. Two situations are measured, because they behave very differently in V8:

  • Hot: a 10,000-element array summed 1,000 times per run. The summing function is called hundreds of thousands of times during warm-up, like a hot function in a server or UI.
  • Single pass: one 10,000,000-element array (80 MB) summed once per run. Each call takes milliseconds, so the function is called only a few hundred times at most, like the main loop of a one-off data-processing script.

Hot: 10,000 elements, called many times

VariantMedian (ns/op)Min – maxRelativeRe-run--no-maglev
for (let i = 0; i < a.length; i++)0.280.28 – 0.281.00×0.290.27
for (const x of a)0.310.31 – 0.321.14×0.320.31
a.forEach(x => { s += x; })0.640.64 – 0.642.30×0.650.65
a.reduce((acc, x) => acc + x, 0)2.812.80 – 2.8410.15×2.840.26

Single pass: 10,000,000 elements, called a few times

VariantMedian (ns/op)Min – maxRelativeRe-run--no-maglev
for (let i = 0; i < a.length; i++)0.420.42 – 0.421.00×0.440.28
for (const x of a)3.123.10 – 3.317.44×3.230.35
a.forEach(x => { s += x; })3.033.02 – 3.047.22×3.103.07
a.reduce((acc, x) => acc + x, 0)2.892.88 – 2.956.88×2.852.85

Why the numbers move so much: V8’s compiler tiers

V8 runs JavaScript through several tiers: the Ignition interpreter, the Sparkplug baseline compiler, the Maglev optimizing compiler, and the TurboFan optimizing compiler (see V8’s Maglev announcement). Functions move up as they get hot. To see how much the tier matters, the hot cases were re-run with V8’s --max-opt flag, which caps the highest tier, and with --no-maglev, which lets hot code go straight from Sparkplug to TurboFan (ns per element):

LoopIgnition only (--max-opt=0)Sparkplug (--max-opt=1)Maglev (--max-opt=2)TurboFan (--no-maglev)Default flags
for (let i = 0; i < a.length; i++)8.795.540.430.260.28
for (const x of a)13.97.723.720.310.31
a.forEach(x => { s += x; })8.414.410.870.640.64
a.reduce((acc, x) => acc + x, 0)7.144.052.870.262.81

This table explains almost everything in the loop results:

  • In TurboFan code, loop style is nearly irrelevant. reduce (0.26 ns) matched the indexed loop (0.26 ns), and for...of added about 0.05 ns per element. TurboFan inlines array built-ins such as reduce and the callback into the caller: running the hot reduce case with --no-maglev --trace-turbo-inlining showed both the benchmark function and the (anonymous) reduce callback being inlined into the timing function.
  • In Maglev code, for...of and reduce were 8.6× and 6.7× slower than an indexed loop. With the default flags, the function containing reduce was still running Maglev code after the warm-up (--trace-opt showed it compiled by Maglev and never by TurboFan), which is why the default-flag hot reduce row is about 2.81 ns rather than 0.26 ns.
  • In the unoptimized tiers, the built-ins win. In Ignition and Sparkplug, reduce (4.05 ns under Sparkplug) and forEach (4.41 ns) beat the hand-written loops (5.54 and 7.72 ns). Their loop runs inside V8 built-ins, whose machine code is generated when V8 itself is built (V8: Embedded builtins), while your own loop is still interpreted or baseline-compiled.

Tier-up also takes longer than many people expect. In a separate script that calls the reduce function 1,000 times per round on a 10,000-element array and prints each round as it finishes, --trace-opt showed the function compiled by Maglev before the first round. The first 9 rounds ran at about 2.96 ns per element; TurboFan finished compiling the calling loop (through OSR) during round 9, and every round after that ran at 0.26 ns. That is about 9,000 calls before the fast code appeared.

Why forEach stays about 2× slower even in TurboFan. The forEach callback updates s, a variable captured from the enclosing function. An indexed for loop that calls the same kind of closure measured 0.66 ns per element under --no-maglev, the same as forEach (0.67 ns), while the plain loop with a local accumulator measured 0.28 ns. When the callback writes out[i] instead, forEach (0.72 ns) and an indexed loop (0.60 ns) were within about 19% of each other. So the extra cost comes from updating a captured variable, not from forEach itself.

Why the single pass is different. A function that is called only a few times but contains a 10-million-iteration loop is optimized while the loop is running, through on-stack replacement (OSR). Re-running the single-pass cases with --no-use-osr brought for...of from 3.12 down to 0.35 ns per element, no slower than the indexed loop (0.43 ns), and the --no-maglev run gave the same 0.35 ns. So the 7× penalty for for...of appeared only with the default combination of Maglev and OSR in this V8 version. forEach and reduce stayed near 3 ns in every configuration (3.13 and 2.85 ns without OSR, 3.07 and 2.85 ns without Maglev): with so few calls, the function that contains them never gets optimized itself, so the built-in calls your callback once per element instead of being inlined into a loop. Even the indexed loop is slower per element here than in the hot case (0.42 vs 0.28 ns), probably because it streams 80 MB from memory instead of re-reading an 80 KB array that stays in cache.

3. Building Arrays: push vs new Array(n) vs Array.from vs map

Each variant creates a new array of n numbers (i * 2). The 1,000-element case repeats 5,000 times per run; the 1,000,000-element case repeats 4 times.

n = 1,000

VariantMedian (ns/op)Min – maxRelativeRe-run--no-maglev
[] + a.push(i * 2)1.411.40 – 1.422.27×1.411.41
new Array(n) + a[i] = i * 20.620.62 – 0.641.00×0.600.62
Array.from({ length: n }, (_, i) => i * 2)17.117.0 – 17.127.63×17.317.2
new Array(n).fill(0).map((_, i) => i * 2)1.301.29 – 1.322.11×1.281.35

n = 1,000,000

VariantMedian (ns/op)Min – maxRelativeRe-run--no-maglev
[] + a.push(i * 2)3.063.03 – 3.103.33×3.093.05
new Array(n) + a[i] = i * 20.920.91 – 0.931.00×0.980.92
Array.from({ length: n }, (_, i) => i * 2)17.317.3 – 17.518.87×17.617.5
new Array(n).fill(0).map((_, i) => i * 2)4.724.68 – 4.965.13×4.694.59

Transforming an existing array (doubling every element)

n = 1,000:

VariantMedian (ns/op)Min – maxRelativeRe-run--no-maglev
src.map(x => x * 2)0.760.75 – 0.771.32×0.770.78
[] + out.push(src[i] * 2)1.421.41 – 1.472.46×1.421.41
new Array(n) + out[i] = src[i] * 20.580.58 – 0.581.00×0.600.60

n = 1,000,000:

VariantMedian (ns/op)Min – maxRelativeRe-run--no-maglev
src.map(x => x * 2)3.763.71 – 3.812.89×3.773.73
[] + out.push(src[i] * 2)3.063.04 – 3.072.36×3.103.06
new Array(n) + out[i] = src[i] * 21.301.25 – 1.411.00×1.390.95

Reading back a packed vs a holey array

VariantMedian (ns/op)Min – maxRelativeRe-run--no-maglev
packed (built with push)0.280.28 – 0.281.05×0.280.27
holey (built with new Array(n))0.260.26 – 0.261.00×0.270.27

What explains these results:

  • Preallocating wins. new Array(n) allocates the backing store once; push has to check capacity on every call and occasionally grow and copy the store. The gap was 2.3× at n = 1,000 and 3.3× at n = 1,000,000.
  • “Holey” is not a measurable read penalty here. An array created with new Array(n) starts with holes, so V8 marks it HOLEY_SMI_ELEMENTS, and it stays holey even after every slot is filled because elements kinds only move toward more general kinds (V8: Elements kinds; confirmed here with %HasHoleyElements). Summing it back was not slower than summing a packed array (0.26 vs 0.28 ns), which matches the V8 team’s own advice that the difference is often not even measurable.
  • Array.from({ length: n }, fn) is the slow outlier at about 17.3 ns per element. { length: n } is not iterable, so the specification’s array-like path applies: for every index it performs a property Get on that plain object (which has no such properties) and then calls the mapping function. For 1,000 elements that is still only about 17.10 µs per call.
  • new Array(n).fill(0).map(fn) makes two passes plus a callback per element. V8 documents fill as an exception to the “holey forever” rule, and %HasHoleyElements confirmed the filled array is packed.
  • For transformations, map was 1.3× the cost of a preallocated loop at 1,000 elements and 2.9× at 1,000,000.

4. String Building: += vs Template Literals vs join

Each variant builds one string from 100,000 pieces such as item123; (about 1 MB in total). The checksum reads a character from the middle of the result, so any deferred work is included.

VariantMedian (ns/op)Min – maxRelativeRe-run--no-maglev
s += "item" + i + ";"16.216.0 – 16.61.38×16.214.8
s += `item${i};`14.714.2 – 15.31.25×14.914.9
s = `${s}item${i};`15.315.0 – 15.91.30×15.616.0
parts.push(`item${i};`) + parts.join("")21.020.7 – 21.71.78×21.222.3
s += `item${i};` (result never read)11.811.6 – 12.21.00×11.812.6

The reason += does so well is that V8 does not have to copy on every concatenation. Inspecting a concatenated string with %DebugPrint (run with --allow-natives-syntax) shows the type CONS_ONE_BYTE_STRING_TYPE: a rope that points to its two halves. The characters are copied into one flat buffer later, when something needs them. In a separate measurement, the first charCodeAt on a freshly built 1 MB rope took 256–278 µs (the flattening) and the next one under 1.2 µs. That deferred work is the difference between the “result never read” row (11.8 ns) and the normal += rows. Even with flattening included, += beat join (21.0 ns), which also has to build an array of 100,000 separate strings first.

Template literals and + were within about 10% of each other (16.2, 14.7 and 15.3 ns), and in the --no-maglev run the + and template versions were equal (14.8 vs 14.9 ns), so choose between them for readability.

5. Object vs Map with String Keys

Keys are strings like "key123" (deliberately not array-index-like, which objects store differently). Lookups use a shuffled order. “Fresh key strings” builds an equal key with "key" + i for every lookup, as happens when keys come from parsed input; the other lookups reuse the exact string objects that were inserted. Values are ns per operation, with the re-run in parentheses.

1,000 keys

Operation, 1k keysObject {}Object.create(null)MapFastest
insert14.5 (re-run 15.3)11.6 (re-run 11.3)11.8 (re-run 11.7)Object.create(null)
lookup hit2.79 (re-run 2.88)2.94 (re-run 2.87)4.05 (re-run 4.05)Object {}
lookup hit, fresh key strings32.9 (re-run 31.2)34.2 (re-run 31.5)27.8 (re-run 27.8)Map
lookup miss5.92 (re-run 5.27)3.45 (re-run 3.29)3.24 (re-run 3.46)Map
delete10.6 (re-run 10.2)10.1 (re-run 10.2)8.47 (re-run 8.47)Map

100,000 keys

Operation, 100k keysObject {}Object.create(null)MapFastest
insert36.4 (re-run 36.1)33.6 (re-run 34.1)32.0 (re-run 32.9)Map
lookup hit6.82 (re-run 6.88)7.06 (re-run 7.01)18.3 (re-run 18.9)Object {}
lookup hit, fresh key strings42.7 (re-run 44.9)42.9 (re-run 44.0)48.7 (re-run 47.8)Object {}
lookup miss13.2 (re-run 12.4)10.0 (re-run 10.2)17.0 (re-run 16.8)Object.create(null)
delete35.4 (re-run 35.6)35.8 (re-run 35.5)26.0 (re-run 25.8)Map

Steady-state churn: 1,000 live keys, insert + lookup + delete per step

VariantMedian (ns/op)Min – maxRelativeRe-run--no-maglev
Object {}52.150.5 – 52.71.18×51.151.2
Object.create(null)49.648.5 – 50.71.13×49.049.5
Map44.043.1 – 44.41.00×42.542.0

Before interpreting these, it helps to know that none of these objects stay in V8’s fast-property mode. Checked with V8’s %HasFastProperties in this Node version: a {} that receives string keys one at a time switched to dictionary (hash-table) mode at its 20th key, Object.create(null) objects start in dictionary mode, and a delete switched a small object to dictionary mode too. So this is a comparison between two hash tables: V8’s dictionary-mode objects and its Map implementation (background: V8: Fast properties).

  • Lookups with reused key strings favor objects, by 2.7× at 100,000 keys. V8 stores property names as internalized (deduplicated) strings, so a key string that has already been used as a property name can be matched directly, while a newly built string first has to be looked up in V8’s table of internalized strings. The next row shows that cost.
  • Fresh key strings erase that advantage. With a newly built key on every lookup, the object lookups jumped to 32.9 ns at 1,000 keys and Map won (27.8 ns); at 100,000 keys objects were slightly faster (42.7 vs 48.7 ns). Part of that cost is building the key string itself, which every container pays.
  • Misses are slower on {} than on Object.create(null) (5.92 vs 3.45 ns at 1,000 keys) because a missing property must also be looked up on Object.prototype. At 100,000 keys, Map had the slowest misses (17.0 ns).
  • Deletes and churn favor Map: 26.0 vs 35.4 ns per delete at 100,000 keys, and 44.0 vs 52.1 ns per insert-lookup-delete step. This is the pattern MDN describes when it says Map performs better with frequent additions and removals (MDN: Map).
  • Inserts were within about 10–25% of each other, with Map or Object.create(null) slightly ahead.

6. Merging Arrays: Spread vs concat vs push(...)

Each variant creates a new array containing a followed by b, without modifying either input. ns/op is per element of the result.

Two arrays of 10 elements

VariantMedian (ns/op)Min – maxRelativeRe-run--no-maglev
[...a, ...b]1.231.22 – 1.251.24×1.201.22
a.concat(b)2.352.32 – 2.412.35×2.392.43
c = a.slice(); c.push(...b)1.251.21 – 1.601.25×1.251.21
c = a.slice(); for (...) c.push(b[i])1.000.98 – 1.021.00×0.990.99

Two arrays of 1,000 elements

VariantMedian (ns/op)Min – maxRelativeRe-run--no-maglev
[...a, ...b]1.031.02 – 1.042.08×1.031.03
a.concat(b)0.500.49 – 0.511.00×0.500.50
c = a.slice(); c.push(...b)0.780.78 – 0.791.58×0.770.80
c = a.slice(); for (...) c.push(b[i])0.970.96 – 0.991.96×0.970.96

Two arrays of 100,000 elements

VariantMedian (ns/op)Min – maxRelativeRe-run--no-maglev
[...a, ...b]3.483.47 – 3.535.51×3.531.71
a.concat(b)0.630.63 – 0.641.00×0.640.65
c = a.slice(); c.push(...b)2.652.62 – 2.734.20×2.632.60
c = a.slice(); for (...) c.push(b[i])1.671.66 – 1.712.64×1.671.67
  • concat wins for anything but tiny arrays: 2.1× faster than spread at 2 × 1,000 elements and 5.5× at 2 × 100,000.
  • For tiny arrays, everything costs tens of nanoseconds; concat was the slowest option here (47 ns vs 25 ns for spread), a difference that only matters in extremely hot code.
  • push(...b) has a hard limit. Spreading into a call passes every element as a separate argument. On this machine a.push(...b) threw RangeError: Maximum call stack size exceeded once b had more than 124,691 elements (the exact limit depends on stack size and current stack depth). MDN warns about this argument-length limit in its spread syntax reference.

7. What This Means for Your Code

  • Choose loop syntax for readability. Once code is hot, the differences are fractions of a nanosecond per element. If one huge pass over data is a measured bottleneck (for example, a batch script), an indexed for loop is the safest choice because it was fast in every tier and situation tested.
  • Avoid callbacks that mutate captured variables in the hottest loops. That pattern, not forEach itself, cost about 2× here.
  • Preallocate when you know the length and will fill every slot. new Array(n) plus index assignment was the fastest way to build and transform arrays. Do not worry about the resulting array being holey.
  • Avoid Array.from({ length: n }, fn) for large n in hot paths. For small arrays it is fine and readable.
  • Build strings however reads best. += was fastest, template literals cost the same as +, and join was within 2×.
  • Use Map for dynamic collections: frequent inserts and deletes, keys from input, or unknown key sets. A plain object works well for read-mostly lookups with stable keys; if you use an object as a dictionary, Object.create(null) avoids prototype keys and made misses faster.
  • Merge large arrays with concat, and never use push(...b) when b can grow without bound.

When none of this matters: the worst variant in most tables costs tens of nanoseconds per element. Building a 1,000-element array the slowest way measured here took about 17.10 µs; one network request, database query or layout pass costs orders of magnitude more. Profile first (see Performance Profiling) and change code only where a profile shows time being spent.

8. Caveats

  • One machine, one Node version. V8’s tiering thresholds, inlining heuristics and built-ins change between releases. Re-measure on your target runtime before relying on a specific ratio.
  • Tier state dominates. As section 2 shows, the same code can differ by 10× depending on whether it runs in Sparkplug, Maglev or TurboFan, and how long a function needs to reach TurboFan depends on how much work it does per call.
  • Micro-benchmarks are monomorphic. Each case runs alone with one array type and one callback. Real programs pass many shapes and functions through the same code, which can block inlining or trigger deoptimization (see Deoptimization).
  • Element types matter. All arrays here hold small integers. Arrays of doubles, strings or objects use different elements kinds and can behave differently.
  • GC is simplified. A forced GC before each run gives every run the same starting heap. In an application, other allocations change GC timing.
  • Hardware differences. Apple silicon, x86 servers and phones have different caches and memory bandwidth, which mostly affects the large-array cases.

9. Noise and Reproducibility

Most cases were stable: the median min–max spread within a run was 4%, and the median change between the two full runs was 2%. Cases with a within-run spread of 15% or more: bulk/1k keys/lookup hit, fresh key strings / Object.create(null) (2.24×), bulk/100k keys/lookup hit, fresh key strings / Map (1.37×), merge/2x10 / c = a.slice(); c.push(...b) (1.32×), bulk/1k keys/lookup miss / Object {} (1.27×), bulk/1k keys/lookup hit, fresh key strings / Object {} (1.25×), bulk/1k keys/lookup hit / Object {} (1.16×). Cases whose median moved by 10% or more between the two runs: bulk/1k keys/lookup miss / Object {} (-11%). Treat differences smaller than about 10% as noise.

10. Run the Benchmarks Yourself

Save the script below as js-bench.mjs. It uses only Node’s built-in modules; the numbers in this article come from Node 24.21. The full suite takes about 3–5 minutes.

Terminal
mkdir js-bench && cd js-bench
# save the script below as js-bench.mjs, then:
node js-bench.mjs                               # every case, printed as tables
node js-bench.mjs merge                         # only cases whose id contains "merge"
node js-bench.mjs --json results.json           # also save every raw timing
node js-bench.mjs --node-flags "--no-maglev" iterate/hot   # pass V8 flags to each case
node js-bench.mjs --node-flags "--max-opt=1" iterate/hot   # cap the tier at Sparkplug

Close other heavy applications first, plug in a laptop, and run the suite at least twice. Compare the min–max range before trusting a small difference.

Complete benchmark source (js-bench.mjs)

JavaScript
// js-bench.mjs — JavaScript micro-benchmarks for Node.js
//
//   node js-bench.mjs                        run every case, each in a fresh Node process
//   node js-bench.mjs merge                  run only cases whose id contains "merge"
//   node js-bench.mjs --json out.json        also write every raw timing to out.json
//   node js-bench.mjs --node-flags "--no-use-osr" iterate   pass extra V8 flags to the cases
//
// Each case runs in its own child process (started with --expose-gc) so that type
// feedback, JIT state and heap contents from one case cannot affect another.
// Per case: an untimed warm-up (at least WARMUP_RUNS runs and WARMUP_MIN_MS), then
// MEASURED_RUNS timed runs. A forced GC runs before each timed run (outside the timed
// region). The table reports the median, min and max of the timed runs.

import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { writeFileSync } from "node:fs";
import os from "node:os";

const WARMUP_RUNS = 5;      // at least this many untimed runs...
const WARMUP_MIN_MS = 2000; // ...and at least this much warm-up time, so V8 reaches its top tier
const MEASURED_RUNS = 10;

// Every result is folded into `sink`, and `sink` is printed, so the engine
// cannot treat the benchmarked work as dead code.
let sink = 0;
const keep = (x) => { sink = (sink + (x | 0)) | 0; };

// ---------- deterministic inputs ----------
function makeNumbers(n) {
  const a = [];
  for (let i = 0; i < n; i++) a.push((i * 7) % 100); // small integers -> PACKED_SMI_ELEMENTS
  return a;
}
function makeKeys(n, prefix = "key") {
  const k = [];
  for (let i = 0; i < n; i++) k.push(prefix + i); // "key0", "key1", ... (not array-index-like)
  return k;
}
function shuffle(arr, seed = 12345) {
  const a = arr.slice();
  let s = seed >>> 0;
  for (let i = a.length - 1; i > 0; i--) {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0; // LCG
    const j = s % (i + 1);
    const t = a[i]; a[i] = a[j]; a[j] = t;
  }
  return a;
}
const consumeArray = (a) => a.length + a[a.length >> 1];

// ---------- cases ----------
// id: "group/size/name"; ops: operations per run (for ns/op); inner: repetitions per timed run
// setup(): input (untimed, once); prepare(input): state (untimed, before every repetition)
// run(state): result (timed); consume(result): number folded into sink
const cases = [];
const add = (c) => cases.push({ inner: 1, consume: (r) => r, ...c });

// 1. Iterating a numeric array (sum), in two situations:
//    "hot": a 10,000-element array summed 1,000 times per run (the function is called often)
//    "single pass": one 10,000,000-element array summed once per run (called only a few times)
for (const [label, n, inner] of [["hot 10k x1000", 10_000, 1_000], ["single pass 10M", 10_000_000, 1]]) {
  const common = { ops: n, inner, setup: () => makeNumbers(n) };
  add({ ...common, id: `iterate/${label}/for (index)`, run: (a) => {
    let s = 0;
    for (let i = 0; i < a.length; i++) s += a[i];
    return s;
  } });
  add({ ...common, id: `iterate/${label}/for...of`, run: (a) => {
    let s = 0;
    for (const x of a) s += x;
    return s;
  } });
  add({ ...common, id: `iterate/${label}/forEach`, run: (a) => {
    let s = 0;
    a.forEach((x) => { s += x; });
    return s;
  } });
  add({ ...common, id: `iterate/${label}/reduce`, run: (a) => a.reduce((acc, x) => acc + x, 0) });
}

// 2. Building a new array of n numbers from scratch
for (const [label, n, inner] of [["1k", 1_000, 5_000], ["1M", 1_000_000, 4]]) {
  const common = { ops: n, inner, setup: () => n, consume: consumeArray };
  add({ ...common, id: `build/${label}/push`, run: (n) => {
    const a = [];
    for (let i = 0; i < n; i++) a.push(i * 2);
    return a;
  } });
  add({ ...common, id: `build/${label}/new Array(n) + a[i] =`, run: (n) => {
    const a = new Array(n);
    for (let i = 0; i < n; i++) a[i] = i * 2;
    return a;
  } });
  add({ ...common, id: `build/${label}/Array.from({length})`, run: (n) => Array.from({ length: n }, (_, i) => i * 2) });
  add({ ...common, id: `build/${label}/new Array(n).fill(0).map`, run: (n) => new Array(n).fill(0).map((_, i) => i * 2) });
}

// 3. Transforming an existing array (doubling every element)
for (const [label, N, inner] of [["1k", 1_000, 5_000], ["1M", 1_000_000, 4]]) {
  const common = { ops: N, inner, setup: () => makeNumbers(N), consume: consumeArray };
  add({ ...common, id: `transform/${label}/map`, run: (src) => src.map((x) => x * 2) });
  add({ ...common, id: `transform/${label}/for + push`, run: (src) => {
    const out = [];
    for (let i = 0; i < src.length; i++) out.push(src[i] * 2);
    return out;
  } });
  add({ ...common, id: `transform/${label}/for + new Array(n)`, run: (src) => {
    const out = new Array(src.length);
    for (let i = 0; i < src.length; i++) out[i] = src[i] * 2;
    return out;
  } });
}

// 4. Reading back: summing a packed array vs a holey (preallocated) one, 10,000 elements x1000
{
  const N = 10_000;
  const sum = (a) => {
    let s = 0;
    for (let i = 0; i < a.length; i++) s += a[i];
    return s;
  };
  add({ id: "read/hot 10k x1000/packed (built with push)", ops: N, inner: 1_000, setup: () => makeNumbers(N), run: sum });
  add({ id: "read/hot 10k x1000/holey (built with new Array(n))", ops: N, inner: 1_000, setup: () => {
    const a = new Array(N);
    for (let i = 0; i < N; i++) a[i] = (i * 7) % 100;
    return a;
  }, run: sum });
}

// 5. Building a string from 100,000 pieces
{
  const N = 100_000;
  const common = { ops: N, inner: 4, setup: () => N };
  // Reading one character forces V8 to flatten a rope (ConsString), so the deferred
  // concatenation cost is included in the measurement.
  const read = (s) => s.length + s.charCodeAt(s.length >> 1);
  add({ ...common, id: "string/100k/s += 'item' + i + ';'", consume: read, run: (n) => {
    let s = "";
    for (let i = 0; i < n; i++) s += "item" + i + ";";
    return s;
  } });
  add({ ...common, id: "string/100k/s += `item${i};`", consume: read, run: (n) => {
    let s = "";
    for (let i = 0; i < n; i++) s += `item${i};`;
    return s;
  } });
  add({ ...common, id: "string/100k/s = `${s}item${i};`", consume: read, run: (n) => {
    let s = "";
    for (let i = 0; i < n; i++) s = `${s}item${i};`;
    return s;
  } });
  add({ ...common, id: "string/100k/parts.push() + join('')", consume: read, run: (n) => {
    const parts = [];
    for (let i = 0; i < n; i++) parts.push(`item${i};`);
    return parts.join("");
  } });
  add({ ...common, id: "string/100k/s += ... (result never read)", consume: (s) => s.length, run: (n) => {
    let s = "";
    for (let i = 0; i < n; i++) s += `item${i};`;
    return s;
  } });
}

// 6. Object vs Map with string keys — bulk operations on 1,000 and 100,000 keys
for (const [label, N, inner] of [["1k keys", 1_000, 500], ["100k keys", 100_000, 1]]) {
  const setup = () => {
    const keys = makeKeys(N);
    const idx = shuffle(keys.map((_, i) => i));
    return { keys, idx, order: idx.map((i) => keys[i]), missing: makeKeys(N, "nokey") };
  };
  const kinds = {
    "Object {}": () => ({}),
    "Object.create(null)": () => Object.create(null),
    Map: () => new Map(),
  };
  const fillObject = (o, keys) => { for (let i = 0; i < keys.length; i++) o[keys[i]] = i; return o; };
  const fillMap = (m, keys) => { for (let i = 0; i < keys.length; i++) m.set(keys[i], i); return m; };
  const ops = {
    // insert every key into a new, empty container
    insert: (kind, isMap) => ({
      setup,
      run: isMap ? ({ keys }) => fillMap(new Map(), keys) : ({ keys }) => fillObject(kinds[kind](), keys),
      consume: isMap ? (c) => c.get("key99") : (c) => c["key99"],
    }),
    // look up every key (shuffled order) using the same string objects that were inserted
    "lookup hit": (kind, isMap) => ({
      setup: () => { const d = setup(); d.c = isMap ? fillMap(new Map(), d.keys) : fillObject(kinds[kind](), d.keys); return d; },
      run: isMap
        ? ({ c, order }) => { let s = 0; for (let i = 0; i < order.length; i++) s += c.get(order[i]); return s; }
        : ({ c, order }) => { let s = 0; for (let i = 0; i < order.length; i++) s += c[order[i]]; return s; },
    }),
    // look up every key with a freshly built, equal string ("key" + i), as when keys come from input
    "lookup hit, fresh key strings": (kind, isMap) => ({
      setup: () => { const d = setup(); d.c = isMap ? fillMap(new Map(), d.keys) : fillObject(kinds[kind](), d.keys); return d; },
      run: isMap
        ? ({ c, idx }) => { let s = 0; for (let i = 0; i < idx.length; i++) s += c.get("key" + idx[i]); return s; }
        : ({ c, idx }) => { let s = 0; for (let i = 0; i < idx.length; i++) s += c["key" + idx[i]]; return s; },
    }),
    // look up keys that are not present
    "lookup miss": (kind, isMap) => ({
      setup: () => { const d = setup(); d.c = isMap ? fillMap(new Map(), d.keys) : fillObject(kinds[kind](), d.keys); return d; },
      run: isMap
        ? ({ c, missing }) => { let s = 0; for (let i = 0; i < missing.length; i++) if (c.get(missing[i]) === undefined) s++; return s; }
        : ({ c, missing }) => { let s = 0; for (let i = 0; i < missing.length; i++) if (c[missing[i]] === undefined) s++; return s; },
    }),
    // delete every key (shuffled order); the container is filled before each timed repetition
    delete: (kind, isMap) => ({
      setup,
      prepare: (d) => ({ c: isMap ? fillMap(new Map(), d.keys) : fillObject(kinds[kind](), d.keys), order: d.order }),
      run: isMap
        ? ({ c, order }) => { for (let i = 0; i < order.length; i++) c.delete(order[i]); return c.size; }
        : ({ c, order }) => { for (let i = 0; i < order.length; i++) delete c[order[i]]; return Object.keys(c).length; },
    }),
  };
  for (const [op, make] of Object.entries(ops)) {
    for (const kind of Object.keys(kinds)) {
      add({ id: `bulk/${label}/${op}/${kind}`, ops: N, inner, ...make(kind, kind === "Map") });
    }
  }
}

// 7. Object vs Map — steady-state churn: ~1,000 live keys; each step inserts one key,
//    looks up one existing key and deletes the oldest key (1,000,000 steps per run)
{
  const W = 1_000, STEPS = 1_000_000, POOL = 65_536, MASK = POOL - 1;
  const setup = () => makeKeys(POOL);
  add({ id: "churn/1k live/Object {}", ops: STEPS, setup, run: (keys) => {
    const o = {};
    for (let i = 0; i < W; i++) o[keys[i]] = i;
    let s = 0;
    for (let i = 0; i < STEPS; i++) {
      o[keys[(i + W) & MASK]] = i;
      s += o[keys[(i + (W >> 1)) & MASK]];
      delete o[keys[i & MASK]];
    }
    return s;
  } });
  add({ id: "churn/1k live/Object.create(null)", ops: STEPS, setup, run: (keys) => {
    const o = Object.create(null);
    for (let i = 0; i < W; i++) o[keys[i]] = i;
    let s = 0;
    for (let i = 0; i < STEPS; i++) {
      o[keys[(i + W) & MASK]] = i;
      s += o[keys[(i + (W >> 1)) & MASK]];
      delete o[keys[i & MASK]];
    }
    return s;
  } });
  add({ id: "churn/1k live/Map", ops: STEPS, setup, run: (keys) => {
    const m = new Map();
    for (let i = 0; i < W; i++) m.set(keys[i], i);
    let s = 0;
    for (let i = 0; i < STEPS; i++) {
      m.set(keys[(i + W) & MASK], i);
      s += m.get(keys[(i + (W >> 1)) & MASK]);
      m.delete(keys[i & MASK]);
    }
    return s;
  } });
}

// 8. Merging two arrays into a new array
for (const [label, m, inner] of [["2x10", 10, 200_000], ["2x1k", 1_000, 2_000], ["2x100k", 100_000, 20]]) {
  const common = { ops: 2 * m, inner, setup: () => ({ a: makeNumbers(m), b: makeNumbers(m) }), consume: consumeArray };
  add({ ...common, id: `merge/${label}/[...a, ...b]`, run: ({ a, b }) => [...a, ...b] });
  add({ ...common, id: `merge/${label}/a.concat(b)`, run: ({ a, b }) => a.concat(b) });
  add({ ...common, id: `merge/${label}/c = a.slice(); c.push(...b)`, run: ({ a, b }) => {
    const c = a.slice();
    c.push(...b);
    return c;
  } });
  add({ ...common, id: `merge/${label}/c = a.slice(); loop c.push(b[i])`, run: ({ a, b }) => {
    const c = a.slice();
    for (let i = 0; i < b.length; i++) c.push(b[i]);
    return c;
  } });
}

// ---------- measuring one case (child process) ----------
// Runs `reps` repetitions and returns elapsed nanoseconds (prepare() is not timed).
function timeReps(c, input, reps) {
  if (c.prepare) {
    let ns = 0n;
    for (let k = 0; k < reps; k++) {
      const state = c.prepare(input);
      const t0 = process.hrtime.bigint();
      const r = c.run(state);
      ns += process.hrtime.bigint() - t0;
      keep(c.consume(r));
    }
    return Number(ns);
  }
  const t0 = process.hrtime.bigint();
  for (let k = 0; k < reps; k++) keep(c.consume(c.run(input)));
  return Number(process.hrtime.bigint() - t0);
}

function measureCase(c) {
  const input = c.setup();
  // Warm-up, part 1: many single repetitions, so that timeReps() and the code under test
  // are optimized as a whole by V8's top tier (not only through on-stack replacement).
  let t = performance.now();
  while (performance.now() - t < WARMUP_MIN_MS / 2) timeReps(c, input, 1);
  // Warm-up, part 2: full-size runs.
  t = performance.now();
  for (let w = 0; w < WARMUP_RUNS || performance.now() - t < WARMUP_MIN_MS / 2; w++) timeReps(c, input, c.inner);
  const runs = [];
  for (let r = 0; r < MEASURED_RUNS; r++) {
    globalThis.gc?.(); // start every timed run from a freshly collected heap (not timed)
    runs.push(timeReps(c, input, c.inner) / c.inner); // ns per single repetition
  }
  return runs;
}

const median = (xs) => {
  const s = [...xs].sort((a, b) => a - b);
  const m = s.length >> 1;
  return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
};

function fmtTime(ns) {
  if (ns >= 1e6) return (ns / 1e6).toFixed(2) + " ms";
  if (ns >= 1e3) return (ns / 1e3).toFixed(2) + " µs";
  return ns.toFixed(0) + " ns";
}

// ---------- driver ----------
const args = process.argv.slice(2);
if (args[0] === "--case") {
  const c = cases.find((x) => x.id === args[1]);
  const runs = measureCase(c);
  process.stdout.write(JSON.stringify({ id: c.id, ops: c.ops, inner: c.inner, runs, sink }));
} else {
  const jsonIdx = args.indexOf("--json");
  const jsonOut = jsonIdx >= 0 ? args.splice(jsonIdx, 2)[1] : null;
  const extraFlagsIdx = args.indexOf("--node-flags");
  const extraFlags = extraFlagsIdx >= 0 ? args.splice(extraFlagsIdx, 2)[1].split(" ").filter(Boolean) : [];
  const filter = args[0] ?? "";
  const self = fileURLToPath(import.meta.url);
  const env = {
    date: new Date().toISOString(),
    node: process.version,
    v8: process.versions.v8,
    cpu: os.cpus()[0].model,
    cores: os.cpus().length,
    memGB: Math.round(os.totalmem() / 2 ** 30),
    os: `${os.type()} ${os.release()} ${os.arch()}`,
    flags: ["--expose-gc", ...extraFlags].join(" "),
    warmupRuns: WARMUP_RUNS,
    warmupMinMs: WARMUP_MIN_MS,
    measuredRuns: MEASURED_RUNS,
  };
  console.log(JSON.stringify(env));
  const results = [];
  let group = "";
  let groupResults = [];
  const printGroup = () => {
    if (!groupResults.length) return;
    const best = Math.min(...groupResults.map((r) => r.median));
    for (const r of groupResults) {
      const rel = r.median / best;
      console.log(
        `  ${r.name.padEnd(42)} median ${fmtTime(r.median).padStart(10)}  ` +
        `min ${fmtTime(r.min).padStart(10)}  max ${fmtTime(r.max).padStart(10)}  ` +
        `${(r.median / r.ops).toFixed(2).padStart(8)} ns/op  ${rel.toFixed(2)}x`
      );
    }
    groupResults = [];
  };
  for (const c of cases.filter((x) => x.id.includes(filter))) {
    const parts = c.id.split("/");
    const name = parts.pop();
    const g = parts.join("/");
    if (g !== group) { printGroup(); group = g; console.log(`\n${g}`); }
    const out = execFileSync(process.execPath, ["--expose-gc", ...extraFlags, self, "--case", c.id], { encoding: "utf8" });
    const res = JSON.parse(out);
    const r = { group: g, name, ops: res.ops, inner: res.inner, runs: res.runs,
      median: median(res.runs), min: Math.min(...res.runs), max: Math.max(...res.runs) };
    results.push(r);
    groupResults.push(r);
  }
  printGroup();
  if (jsonOut) writeFileSync(jsonOut, JSON.stringify({ env, results }, null, 1));
}

11. Key Points

  • In fully optimized (TurboFan) code, an indexed loop, for...of and reduce summed numbers at about 0.26–0.31 ns per element; forEach with a captured accumulator took 0.65 ns.
  • Which V8 tier runs the code changed the same loop by more than 10×; warm-up length and call counts decide that tier.
  • A single pass over 10 million elements was about 7× slower with for...of, forEach or reduce than with an indexed loop.
  • new Array(n) plus index assignment was the fastest way to build arrays; Array.from({ length: n }, fn) was 18.9× slower at a million elements.
  • += was the fastest way to build a string because V8 defers copying with ropes; template literals cost the same as +.
  • Objects won lookups with reused key strings; Map won deletes, churn and (at 1,000 keys) lookups with freshly built keys.
  • concat merged large arrays fastest; push(...b) threw a RangeError above about 124,691 elements.

12. Final Summary

On Node 24.21 and V8 13.6, the classic JavaScript micro-optimization debates mostly dissolve once code is hot: TurboFan inlines array built-ins and callbacks, and the loop styles converge to within a fraction of a nanosecond per element. What does matter is visible in these measurements: how warm the code is and which compiler tier it reached, whether you preallocate arrays, avoiding the array-like path of Array.from for large arrays, using concat instead of spread or push(...) for big merges, and picking Map or a plain object according to the access pattern rather than by habit. Treat every number here as a measurement of one machine on one day, rerun the script on your own runtime, and let a profiler decide which of these differences is worth acting on in your code.