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

Source: https://devdocs10.com/articles/javascript/js-performance-benchmarks  
By Murat Bo · Published 2026-10-17 · DevDocs10 (https://devdocs10.com)

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](/articles/javascript/js-maps-sets), closures, and the basic idea of [JIT optimization in V8](/articles/javascript/js-jit-optimizations).

## 1. Test Setup

| Item | Value |
| --- | --- |
| Machine | Apple M6, 24 GB RAM (12 cores; `sysctl` reports 2 &ldquo;Super&rdquo;, 4 &ldquo;Performance&rdquo; and 6 &ldquo;Efficiency&rdquo; cores) |
| Operating system | macOS 27.0.1 (build 26A434) |
| Runtime | Node.js v24.21.0, V8 13.6.233.17-node.53 (from `node --version` and `process.versions.v8`) |
| V8 build | Pointer 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 |
| Timer | `process.hrtime.bigint()` (nanoseconds) |
| Date measured | 2026-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&rsquo;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&rsquo;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

| Variant | Median (ns/op) | Min – max | Relative | Re-run | --no-maglev |
| --- | --- | --- | --- | --- | --- |
| `for (let i = 0; i < a.length; i++)` | 0.28 | 0.28 – 0.28 | 1.00× | 0.29 | 0.27 |
| `for (const x of a)` | 0.31 | 0.31 – 0.32 | 1.14× | 0.32 | 0.31 |
| `a.forEach(x => { s += x; })` | 0.64 | 0.64 – 0.64 | 2.30× | 0.65 | 0.65 |
| `a.reduce((acc, x) => acc + x, 0)` | 2.81 | 2.80 – 2.84 | 10.15× | 2.84 | 0.26 |

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

| Variant | Median (ns/op) | Min – max | Relative | Re-run | --no-maglev |
| --- | --- | --- | --- | --- | --- |
| `for (let i = 0; i < a.length; i++)` | 0.42 | 0.42 – 0.42 | 1.00× | 0.44 | 0.28 |
| `for (const x of a)` | 3.12 | 3.10 – 3.31 | 7.44× | 3.23 | 0.35 |
| `a.forEach(x => { s += x; })` | 3.03 | 3.02 – 3.04 | 7.22× | 3.10 | 3.07 |
| `a.reduce((acc, x) => acc + x, 0)` | 2.89 | 2.88 – 2.95 | 6.88× | 2.85 | 2.85 |

### Why the numbers move so much: V8&rsquo;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&rsquo;s [Maglev announcement](https://v8.dev/blog/maglev)). Functions move up as they get hot. To see how much the tier matters, the hot cases were re-run with V8&rsquo;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):

| Loop | Ignition 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.79 | 5.54 | 0.43 | 0.26 | 0.28 |
| `for (const x of a)` | 13.9 | 7.72 | 3.72 | 0.31 | 0.31 |
| `a.forEach(x => { s += x; })` | 8.41 | 4.41 | 0.87 | 0.64 | 0.64 |
| `a.reduce((acc, x) => acc + x, 0)` | 7.14 | 4.05 | 2.87 | 0.26 | 2.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](https://v8.dev/blog/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

| Variant | Median (ns/op) | Min – max | Relative | Re-run | --no-maglev |
| --- | --- | --- | --- | --- | --- |
| `[] + a.push(i * 2)` | 1.41 | 1.40 – 1.42 | 2.27× | 1.41 | 1.41 |
| `new Array(n) + a[i] = i * 2` | 0.62 | 0.62 – 0.64 | 1.00× | 0.60 | 0.62 |
| `Array.from({ length: n }, (_, i) => i * 2)` | 17.1 | 17.0 – 17.1 | 27.63× | 17.3 | 17.2 |
| `new Array(n).fill(0).map((_, i) => i * 2)` | 1.30 | 1.29 – 1.32 | 2.11× | 1.28 | 1.35 |

### n = 1,000,000

| Variant | Median (ns/op) | Min – max | Relative | Re-run | --no-maglev |
| --- | --- | --- | --- | --- | --- |
| `[] + a.push(i * 2)` | 3.06 | 3.03 – 3.10 | 3.33× | 3.09 | 3.05 |
| `new Array(n) + a[i] = i * 2` | 0.92 | 0.91 – 0.93 | 1.00× | 0.98 | 0.92 |
| `Array.from({ length: n }, (_, i) => i * 2)` | 17.3 | 17.3 – 17.5 | 18.87× | 17.6 | 17.5 |
| `new Array(n).fill(0).map((_, i) => i * 2)` | 4.72 | 4.68 – 4.96 | 5.13× | 4.69 | 4.59 |

### Transforming an existing array (doubling every element)

n = 1,000:

| Variant | Median (ns/op) | Min – max | Relative | Re-run | --no-maglev |
| --- | --- | --- | --- | --- | --- |
| `src.map(x => x * 2)` | 0.76 | 0.75 – 0.77 | 1.32× | 0.77 | 0.78 |
| `[] + out.push(src[i] * 2)` | 1.42 | 1.41 – 1.47 | 2.46× | 1.42 | 1.41 |
| `new Array(n) + out[i] = src[i] * 2` | 0.58 | 0.58 – 0.58 | 1.00× | 0.60 | 0.60 |

n = 1,000,000:

| Variant | Median (ns/op) | Min – max | Relative | Re-run | --no-maglev |
| --- | --- | --- | --- | --- | --- |
| `src.map(x => x * 2)` | 3.76 | 3.71 – 3.81 | 2.89× | 3.77 | 3.73 |
| `[] + out.push(src[i] * 2)` | 3.06 | 3.04 – 3.07 | 2.36× | 3.10 | 3.06 |
| `new Array(n) + out[i] = src[i] * 2` | 1.30 | 1.25 – 1.41 | 1.00× | 1.39 | 0.95 |

### Reading back a packed vs a holey array

| Variant | Median (ns/op) | Min – max | Relative | Re-run | --no-maglev |
| --- | --- | --- | --- | --- | --- |
| `packed (built with push)` | 0.28 | 0.28 – 0.28 | 1.05× | 0.28 | 0.27 |
| `holey (built with new Array(n))` | 0.26 | 0.26 – 0.26 | 1.00× | 0.27 | 0.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.
- **&ldquo;Holey&rdquo; 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](https://v8.dev/blog/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&rsquo;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&rsquo;s array-like path](https://tc39.es/ecma262/#sec-array.from) 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 &ldquo;holey forever&rdquo; 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.

| Variant | Median (ns/op) | Min – max | Relative | Re-run | --no-maglev |
| --- | --- | --- | --- | --- | --- |
| `s += "item" + i + ";"` | 16.2 | 16.0 – 16.6 | 1.38× | 16.2 | 14.8 |
| `s += `item${i};`` | 14.7 | 14.2 – 15.3 | 1.25× | 14.9 | 14.9 |
| `s = `${s}item${i};`` | 15.3 | 15.0 – 15.9 | 1.30× | 15.6 | 16.0 |
| `parts.push(`item${i};`) + parts.join("")` | 21.0 | 20.7 – 21.7 | 1.78× | 21.2 | 22.3 |
| `s += `item${i};` (result never read)` | 11.8 | 11.6 – 12.2 | 1.00× | 11.8 | 12.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 &ldquo;result never read&rdquo; 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. &ldquo;Fresh key strings&rdquo; 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 keys | `Object {}` | `Object.create(null)` | `Map` | Fastest |
| --- | --- | --- | --- | --- |
| insert | 14.5 (re-run 15.3) | 11.6 (re-run 11.3) | 11.8 (re-run 11.7) | `Object.create(null)` |
| lookup hit | 2.79 (re-run 2.88) | 2.94 (re-run 2.87) | 4.05 (re-run 4.05) | `Object {}` |
| lookup hit, fresh key strings | 32.9 (re-run 31.2) | 34.2 (re-run 31.5) | 27.8 (re-run 27.8) | `Map` |
| lookup miss | 5.92 (re-run 5.27) | 3.45 (re-run 3.29) | 3.24 (re-run 3.46) | `Map` |
| delete | 10.6 (re-run 10.2) | 10.1 (re-run 10.2) | 8.47 (re-run 8.47) | `Map` |

### 100,000 keys

| Operation, 100k keys | `Object {}` | `Object.create(null)` | `Map` | Fastest |
| --- | --- | --- | --- | --- |
| insert | 36.4 (re-run 36.1) | 33.6 (re-run 34.1) | 32.0 (re-run 32.9) | `Map` |
| lookup hit | 6.82 (re-run 6.88) | 7.06 (re-run 7.01) | 18.3 (re-run 18.9) | `Object {}` |
| lookup hit, fresh key strings | 42.7 (re-run 44.9) | 42.9 (re-run 44.0) | 48.7 (re-run 47.8) | `Object {}` |
| lookup miss | 13.2 (re-run 12.4) | 10.0 (re-run 10.2) | 17.0 (re-run 16.8) | `Object.create(null)` |
| delete | 35.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

| Variant | Median (ns/op) | Min – max | Relative | Re-run | --no-maglev |
| --- | --- | --- | --- | --- | --- |
| `Object {}` | 52.1 | 50.5 – 52.7 | 1.18× | 51.1 | 51.2 |
| `Object.create(null)` | 49.6 | 48.5 – 50.7 | 1.13× | 49.0 | 49.5 |
| `Map` | 44.0 | 43.1 – 44.4 | 1.00× | 42.5 | 42.0 |

Before interpreting these, it helps to know that none of these objects stay in V8&rsquo;s fast-property mode. Checked with V8&rsquo;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&rsquo;s dictionary-mode objects and its `Map` implementation (background: [V8: Fast properties](https://v8.dev/blog/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&rsquo;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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/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

| Variant | Median (ns/op) | Min – max | Relative | Re-run | --no-maglev |
| --- | --- | --- | --- | --- | --- |
| `[...a, ...b]` | 1.23 | 1.22 – 1.25 | 1.24× | 1.20 | 1.22 |
| `a.concat(b)` | 2.35 | 2.32 – 2.41 | 2.35× | 2.39 | 2.43 |
| `c = a.slice(); c.push(...b)` | 1.25 | 1.21 – 1.60 | 1.25× | 1.25 | 1.21 |
| `c = a.slice(); for (...) c.push(b[i])` | 1.00 | 0.98 – 1.02 | 1.00× | 0.99 | 0.99 |

### Two arrays of 1,000 elements

| Variant | Median (ns/op) | Min – max | Relative | Re-run | --no-maglev |
| --- | --- | --- | --- | --- | --- |
| `[...a, ...b]` | 1.03 | 1.02 – 1.04 | 2.08× | 1.03 | 1.03 |
| `a.concat(b)` | 0.50 | 0.49 – 0.51 | 1.00× | 0.50 | 0.50 |
| `c = a.slice(); c.push(...b)` | 0.78 | 0.78 – 0.79 | 1.58× | 0.77 | 0.80 |
| `c = a.slice(); for (...) c.push(b[i])` | 0.97 | 0.96 – 0.99 | 1.96× | 0.97 | 0.96 |

### Two arrays of 100,000 elements

| Variant | Median (ns/op) | Min – max | Relative | Re-run | --no-maglev |
| --- | --- | --- | --- | --- | --- |
| `[...a, ...b]` | 3.48 | 3.47 – 3.53 | 5.51× | 3.53 | 1.71 |
| `a.concat(b)` | 0.63 | 0.63 – 0.64 | 1.00× | 0.64 | 0.65 |
| `c = a.slice(); c.push(...b)` | 2.65 | 2.62 – 2.73 | 4.20× | 2.63 | 2.60 |
| `c = a.slice(); for (...) c.push(b[i])` | 1.67 | 1.66 – 1.71 | 2.64× | 1.67 | 1.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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax).

## 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](/articles/javascript/js-performance-profiling)) and change code only where a profile shows time being spent.

## 8. Caveats

- **One machine, one Node version.** V8&rsquo;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](/articles/javascript/js-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&rsquo;s built-in modules; the numbers in this article come from Node 24.21. The full suite takes about 3–5 minutes.

```bash
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.
