Duff’s device, part 2: copying within an array
Contents
Duff’s device in JavaScript raced hand-written loops that copy one array into another. A reader asked the follow-up: how do they compare with Array#copyWithin, the built-in that copies a range inside a single array? That is a different workload, so it needs its own measurement. The short answer: on every Node and Deno we can install today, the built-in runs 47 to 84 times slower than the loop.

Last time
The first post copied 127 elements from one array into another and found the ranking unstable. Duff’s device beat naive unrolling on every V8 we ran. Against a plain loop it won by a fifth on Node 22 and by 2.9% on Node 26. Under Bun both unrolled variants ran 40% faster than the loop, and on a low-power CPU the plain loop won. Engine, engine version and silicon each moved the verdict.
Array#copyWithin was outside that benchmark. It copies a range within one array, and the contenders there worked across two. David Aurelio (@void_0 on X, @daurelio.bsky.social on Bluesky) read the post and asked for the comparison, and the comparison needs a workload of its own. So this post builds it: one array, a source range and a destination range inside it, and the same contenders rewritten to copy in place.
David also wrote the Python implementation of hashids. I am a fan of hashids and have used them in my own projects.
The contenders
One array of 255 small integers. Every contender copies the same 127 elements from the front half into the back half:
const LEN = 127, SRC = 0, DST = 128;
const data = Array.from(
{length: DST + LEN}, (_, i) => i);
The ranges do not overlap: the hand loops copy forwards, so this is the case they can do, and overlapping ranges, which copyWithin handles like memmove, stay out. Each block below is the body of a benchmarked function; the harness loop that repeats it is left out.
The simple loop. The baseline, and the code anybody writes first:
for (let j = 0; j < LEN; ++j) {
data[DST + j] = data[SRC + j];
}
duff-device-1. The classic shape, kept as a reminder of the technique (numbered as in our working bench file; duff-device-2, the first post’s nested device, comes back in the Discussion): a body unrolled by ten, then a switch that falls through the remainder.
let j = 0;
for (; j + 10 <= LEN; j += 10) {
data[DST + j] = data[SRC + j];
data[DST + j + 1] = data[SRC + j + 1];
// ... seven more ...
data[DST + j + 9] = data[SRC + j + 9];
}
switch (LEN - j) {
case 9: data[DST + j + 8] = data[SRC + j + 8];
case 8: data[DST + j + 7] = data[SRC + j + 7];
// ... six more, falling through ...
case 1: data[DST + j] = data[SRC + j];
}
duff-device-3. One loop, and the switch decides how much of the block runs, so the remainder needs no separate code:
for (let j = 0; j < LEN; j += 10) {
switch (LEN - j) {
default: data[DST + j + 9] = data[SRC + j + 9];
case 9: data[DST + j + 8] = data[SRC + j + 8];
// ... seven more, falling through ...
case 1: data[DST + j] = data[SRC + j];
}
}
This one replaces the Math.min of the first post’s nested device with a default: label, so the sequel gets that version.
Array#copyWithin. The built-in, and the reason for the post:
data.copyWithin(DST, SRC, SRC + LEN);
Results
Our nano-bench at its defaults: 100 samples of 50 ms per function, one process per file per runtime, run sequentially and one machine at a time, since the work is CPU-bound. Its Concepts page covers how the intervals and the significance tests are computed. Every pairwise ordering in both tables passed the significance test. Two machines: an i9-11900K desktop and an i3-10110U laptop. Five runtimes: Node 22.23.2 (V8 12.4), Node 24.21.0 (V8 13.6), Node 26.8.2 (V8 14.6), Deno 2.9.6 (V8 15.0) and Bun 1.4.2, which runs JavaScriptCore.
The three benchmark files, the race and the two hole checks, are published as a runnable gist, so npm install && npm run bench repeats the experiment on your own hardware.
Medians, in nanoseconds throughout, the last column included. On the i9-11900K:
| runtime | simple loop | duff-device-1 |
duff-device-3 |
copyWithin |
|---|---|---|---|---|
| Node 22.23.2 | 67 | 69 | 47 | 3,180 |
| Node 24.21.0 | 57 | 66 | 41 | 3,340 |
| Node 26.8.2 | 56 | 64 | 41 | 3,320 |
| Deno 2.9.6 | 51 | 65 | 41 | 3,240 |
| Bun 1.4.2 | 64 | 38 | 80 | 61 |
On the i3-10110U:
| runtime | simple loop | duff-device-1 |
duff-device-3 |
copyWithin |
|---|---|---|---|---|
| Node 22.23.2 | 91 | 142 | 111 | 5,610 |
| Node 24.21.0 | 75 | 104 | 62 | 5,900 |
| Node 26.8.2 | 75 | 106 | 67 | 5,600 |
| Deno 2.9.6 | 64 | 95 | 67 | 5,350 |
| Bun 1.4.2 | 73 | 90 | 175 | 89 |
Read the bold column twice. Those are nanoseconds, in the same unit as the rest of the table: on V8 the built-in costs 47 to 84 times the plain loop. Under Bun it costs about what the loop costs on the i9 and a fifth more on the i3.
Two more measurements, to answer the first suspicion about holes. The same copyWithin call on a packed array and on a holey one with identical contents (built with new Array(n) and then filled, which leaves V8’s elements kind holey) lands within 3% either way, on every runtime and both machines. The simple loop, asked the same question, does pay for holes on V8: 11% to 85% slower, from Node 24 to Deno on the i3.
Discussion
Why copyWithin is slow on V8. The specification defines it as a per-element loop. For every index it performs HasProperty, then either Get plus Set, or DeletePropertyOrThrow on the destination when the source index is absent. That last branch dictates the shape: a copied hole has to delete the destination, not write an undefined into it. V8 implemented the specification algorithm as written. Its array-copywithin.tq carries no fast path in V8 12.4, 13.6, 14.6, 15.0 or 15.1, which covers every runtime in the tables above. So each of the 127 elements takes a trip through the generic property machinery, at roughly 25 ns on the i9 and 44 ns on the i3.
The cost does not come from hole handling. That was our first guess, and the two supporting measurements refute it. copyWithin charges the same for a packed array as for a holey one, while the simple loop is visibly cheaper on packed data. Hole semantics explain why the generic path must go element by element. They do not explain why a packed array of small integers took the same path.
Bun shows what the built-in is worth. JavaScriptCore has had the fast path since April 2025: fastCopyWithin memmoves the array’s storage when it is contiguous and holds no hole, and falls back to the spec loop otherwise. That is the 61 ns cell — the built-in running at the speed of the plain loop, on the same hardware, for the same work. At 127 elements it reaches the plain loop and not the best hand loop; how much a memmove gains at length is unmeasured here. V8 has now done the same thing: a Torque fast path landed on 2026-06-19, and it ships from V8 15.2. It memmoves packed kinds directly, and holey kinds too when the array still has the original Array prototype and the NoElementsProtector is intact, because only then is a hole guaranteed to be a missing element and not a value the prototype chain would supply.
The built-in has been slower before. In 2008 we measured the new array extras against Dojo’s implementations of the same functions. Every Firefox we tested ran the native filter slower than both Dojo implementations. Safari ran it about three times faster than the best of them.
So one column of these tables can change by orders of magnitude with the next engine version, and only one. When a Node release picks up V8 15.2 or later, the built-in should collapse from microseconds to the speed of the loop, as Bun already demonstrates. The hand-written loops have no comparable upside. They are already what the JIT makes of them, and they get faster only when V8 gets faster at compiling ordinary array code. A built-in can gain nearly two orders of magnitude in a single commit. Node now holds each release line to the V8 it launched with, so Node 26 is unlikely to get the fast path, and Node 27 may be the first release with it.
Keep that in mind the next time a benchmark argues for replacing a built-in with a loop. The measurement is true today, and it is true about a version. The same lesson applies to any optimization measured on one machine, one runtime, one workload: re-measure it, and here the next Node major is what would change it.
An unreachable case that cost double. The first draft of duff-device-3 ended its switch with case 0: break;. The loop guard keeps the remainder at one or more, so that case can never run, and a review pass flagged it as a nit. Removing it halved the device’s time on every V8 and both machines. On the two binaries that did not change between the runs, Node 22 went from 242 to 111 ns on the i3 and from 100 to 47 on the i9. Deno went from 136 to 67 and from 91 to 41. JavaScriptCore barely moved. We chased it far enough to rule out the obvious, on the i3 under Node 26. A bare break in the same place costs nothing: 67 ns against 70 without it. A label outside the span of the existing ones costs, with or without a break: case 0 doubles the time and case 10 more than triples it. Both forms compile to a jump table, in Ignition and in TurboFan, and neither deoptimizes. What differs is TurboFan’s layout. The case 0 form is 40% more machine code, and its table entries land on small stubs that jump onward instead of landing in the run of stores. Why the layout changes is a question about V8’s scheduler, not one for this post. The tables above are for the version without the line, and so is the gist.
The same probe corrects the first post. Its nested device used Math.min(LEN - j, 10) as the key, with a case 10: label on top. We blamed the Math.min in the hot path for its running at twice the flat-tail version’s time. On this workload the nested form with its case 10: takes 160 ns on the i3 under Node 26. Swap that label for default: and it takes 62, Math.min and all, against 67 for duff-device-3. The label was the cost; the dispatch cost nothing measurable. Writing this part prompted a revisit of the first. We re-measured its nested device on its own two-array workload with the same result: on the i3 under Node 26, 208 ns with the label against 121 without and 107 for the flat tail; on the i9, 138 against 70 and 69. The first post’s paragraph is rewritten to match, with a note dated 2026-09-13, and its listings and tables stand.
The hand loops. duff-device-3 is the fastest contender on every V8 on the i9, 20% to 30% less time than the plain loop; on the i3 it beats the loop under Node 24 and 26, loses to it under Node 22, and runs 5% behind it under Deno. duff-device-1 trails the loop on V8 everywhere, by 3% to 27% on the i9 and by 1.4 to 1.6 times on the i3. Bun inverts both: duff-device-1 is its fastest contender on the i9, at 1.7 times the loop’s speed, and loses to the loop on the i3, while duff-device-3 is its slowest on both machines. The first post’s verdict, that engine and silicon each move the ranking, repeats here, with one more mover on the list: an unreachable line.
Summary
The built-in that reads best is, on V8 today, 47 to 84 times slower than the loop it replaces. Handling holes is not the cost; a packed array and a holey one pay the same. The cause is a missing fast path, and that path has already been written: JavaScriptCore has shipped it since 2025, and V8 landed it in June. Node 27 may be the first Node release to carry it. One more thing the tables taught us: an unreachable case 0 in a hand-written device cost it a factor of two on V8, and the first post’s nested device paid the same price for a case 10. So keep the loop where a measurement demands it, write the copy the clear way everywhere else, and keep in mind that the measurement depends on the engine underneath and can change with its next version, one way or the other.