Duff’s device in JavaScript

11 Aug, 2026 · 18 min read
Contents

In 1983, Tom Duff needed to copy memory into an output register faster than his compiler could manage, and wrote the most famous abuse of switch in the history of C. I ported his device to JavaScript and raced it against the plainest possible loop — and the verdict changed with the engine, the engine’s version, and the CPU underneath.

Duff’s device in JavaScript

The original

Tom Duff was doing real-time animation at Lucasfilm. The hot spot: copying an array of 16-bit values into a memory-mapped output register — that is why to below never advances, the hardware consumes every write at the same address. The standard cure was loop unrolling: copy eight values per iteration, pay the loop overhead once per eight. But unrolling leaves a remainder, and the textbook answer is a second little loop for the tail — more code, another counter, another branch.

Duff noticed that C does not require the remainder loop. In C, case labels obey the same rules as goto labels: they can be attached to any statement inside the switch body, including statements buried in a nested loop, and switch itself is a computed jump. So he interleaved the two:

send(to, from, count)
register short *to, *from;
register count;
{
    register n = (count + 7) / 8;
    switch (count % 8) {
    case 0: do { *to = *from++;
    case 7:      *to = *from++;
    case 6:      *to = *from++;
    case 5:      *to = *from++;
    case 4:      *to = *from++;
    case 3:      *to = *from++;
    case 2:      *to = *from++;
    case 1:      *to = *from++;
            } while (--n > 0);
    }
}

The switch dispatches into the middle of the unrolled body to dispose of the remainder first; the dowhile then spins through the full eights. One dispatch, no tail loop, and the body stays unrolled. (It assumes count > 0 — passing zero copies eight values that were never asked for.)

Duff announced it with the much-quoted “I feel a combination of pride and revulsion at this discovery”, and on whether fall-through was a good language feature: “This code forms some sort of argument in that debate, but I’m not sure whether it’s for or against.” The original message is preserved and worth reading in full — few optimizations come with this much personality.

Porting it to JavaScript

The literal port does not parse. JavaScript kept C’s switch, its fall-through, even its labels — but a case clause must sit directly inside the switch block. It cannot tag a statement inside a nested loop, so there is no jumping into the middle of one. (Most of C’s descendants closed that door; C and C++ still hold it open.)

What survives is the half of the trick that fall-through provides: a switch without break performs exactly k trailing operations with a single dispatch. So the JavaScript version turns Duff inside out: unrolled blocks first, fall-through remainder last.

The setup is a copy of a 127-element array — the size is deliberately not a multiple of the block size, so the tail has real work to do:

const SIZE = 127;

export const source = new Array(SIZE);
export const target = new Array(source.length);

for (let i = 0; i < source.length; ++i) {
  source[i] = i;
}

Three contenders. Each receives n from the benchmark harness — how many times to repeat the copy per timed batch; the inner loop is the part being measured. The baseline is the loop V8 sees a million times a day:

'simple-loop': n => {
  for (let i = 0; i < n; ++i) {
    for (let j = 0; j < source.length; ++j) {
      target[j] = source[j];
    }
  }
},

Naive unrolling: blocks of ten, with a plain loop for the tail.

'unrolled-loop': n => {
  for (let i = 0; i < n; ++i) {
    let j = 0;
    for (; j + 10 <= source.length; j += 10) {
      target[j] = source[j];
      target[j + 1] = source[j + 1];
      target[j + 2] = source[j + 2];
      target[j + 3] = source[j + 3];
      target[j + 4] = source[j + 4];
      target[j + 5] = source[j + 5];
      target[j + 6] = source[j + 6];
      target[j + 7] = source[j + 7];
      target[j + 8] = source[j + 8];
      target[j + 9] = source[j + 9];
    }
    for (; j < source.length; ++j) {
      target[j] = source[j];
    }
  }
},

And Duff’s device, JavaScript edition: the same unrolled blocks, but the tail is a single switch dispatch falling through exactly as many assignments as remain.

'duff-device': n => {
  for (let i = 0; i < n; ++i) {
    let j = 0;
    for (; j + 10 <= source.length; j += 10) {
      target[j] = source[j];
      target[j + 1] = source[j + 1];
      target[j + 2] = source[j + 2];
      target[j + 3] = source[j + 3];
      target[j + 4] = source[j + 4];
      target[j + 5] = source[j + 5];
      target[j + 6] = source[j + 6];
      target[j + 7] = source[j + 7];
      target[j + 8] = source[j + 8];
      target[j + 9] = source[j + 9];
    }
    switch (source.length - j) {
      case 9: target[j + 8] = source[j + 8];
      case 8: target[j + 7] = source[j + 7];
      case 7: target[j + 6] = source[j + 6];
      case 6: target[j + 5] = source[j + 5];
      case 5: target[j + 4] = source[j + 4];
      case 4: target[j + 3] = source[j + 3];
      case 3: target[j + 2] = source[j + 2];
      case 2: target[j + 1] = source[j + 1];
      case 1: target[j] = source[j];
    }
  }
}

Anyone who knows the original will object that the switch could stay inside the loop, which is closer to how Duff wrote it:

'duff-device-nested': n => {
  for (let i = 0; i < n; ++i) {
    for (let j = 0; j < source.length; j += 10) {
      switch (Math.min(source.length - j, 10)) {
        case 10: target[j + 9] = source[j + 9];
        case 9: target[j + 8] = source[j + 8];
        // ... and so on down to ...
        case 1: target[j] = source[j];
      }
    }
  }
}

That reads closer to the original and runs markedly slower — a spot check on a third box, an i3-10110U under Node 26.7.0, prices it at roughly twice the time of the flat-tail version — and the reason is the whole point. Duff’s switch executes once: it jumps into the middle of the loop, and every pass after that is straight-line copying. Nesting the switch moves the dispatch into the hot path, where it runs along with a Math.min — paying on every block the cost the original pays once. The shape survives the port; what the shape bought does not.

(Blocks of four were measured as well; they lost to ten across the board, so ten is what races here.)

Before the numbers, a confession. The first version of that guard read j + 10 < source.length — strict, not <=. For an array whose length is an exact multiple of ten, it exits the main loop with ten elements still to go — and switch (10) matches no case, so those ten elements are silently never copied. The benchmark’s 127 dodged it, so the bug sat there through every measurement, looking correct, and surfaced only while this post was being written. The fix is one character, and for a length of 127 it changes nothing — same iterations, same numbers. But the episode argues for the simple loop better than the benchmark does: hand-optimized code grows corners for mistakes to hide in, and a test with a lucky length proves nothing. The simple loop has no such corner.

The numbers

The harness is my nano-benchmark package (AKA nano-bench on GitHub). It runs each contender in timed batches, reports the median with a bootstrapped 95% confidence interval, and only declares an ordering when the difference is statistically significant; every percentage below cleared that bar. The bar is a rank test across the hundred per-batch samples (Kruskal–Wallis with pairwise post-hoc), not an eyeball of CI overlap — a wide interval on the median and a significant sub-percent gap can coexist; the method notes are in the harness’s wiki .

This run: Node v26.3.0 on a low-power Intel Celeron N4120 — the slowest box I own, which is fine: the ordering is the signal, not the absolute nanoseconds. It is also the box where this post started: an April run on it put the simple loop ahead outright — on a Node build I failed to record.

nano-bench 1.0.15: Benchmark and compare code.

Confidence interval: 95%, samples: 100, bootstrap samples: 1,000
Measuring 50ms per sample (~10s per function)

╭───────────────┬────────────────────────────┬──────┬───────╮
│               │            time            │      │       │
│ name          ├─────────┬─────────┬────────┤ op/s │ batch │
│               │ median  │    +    │   −    │      │       │
╞═══════════════╪═════════╪═════════╪════════╪══════╪═══════╡
│ simple-loop   │ 283.8ns │ +21.7ns │ −3.7ns │   4M │  200k │
├───────────────┼─────────┼─────────┼────────┼──────┼───────┤
│ unrolled-loop │ 298.3ns │  +7.8ns │ −2.3ns │   3M │  200k │
├───────────────┼─────────┼─────────┼────────┼──────┼───────┤
│ duff-device   │ 282.1ns │  +3.1ns │ −2.7ns │   4M │  200k │
╰───────────────┴─────────┴─────────┴────────┴──────┴───────╯

The difference is statistically significant:

╭────┬───┬───────────────┬──────────────┬──────────────┬───────────────╮
│    │ # │ name          │      1       │      2       │       3       │
╞════╪═══╪═══════════════╪══════════════╪══════════════╪═══════════════╡
│    │ 1 │ simple-loop   │              │ 5.1% faster  │ 0.616% slower │
├────┼───┼───────────────┼──────────────┼──────────────┼───────────────┤
│ 🐢 │ 2 │ unrolled-loop │ 4.85% slower │              │ 5.44% slower  │
├────┼───┼───────────────┼──────────────┼──────────────┼───────────────┤
│ 🐇 │ 3 │ duff-device   │ 0.62% faster │ 5.75% faster │               │
╰────┴───┴───────────────┴──────────────┴──────────────┴───────────────╯

More data points, all on the same desktop — an i9-11900K: four Node majors (22.22.3, 24.16.0, 25.9.0, 26.3.0), then Deno 2.8.3 (the same V8 lineage, version 14.9), then Bun 1.3.14 (JavaScriptCore instead of V8). Same machine, same source file; only the engine differs. The Celeron contributes a second version pair: Node 25.9.0 next to the 26.3.0 run from above. Time relative to each run’s simple loop:

run simple loop unrolled loop Duff’s device
i9-11900K, Node 22.22.3 63.97ns −9.7% −19.5%
i9-11900K, Node 24.16.0 54.31ns +5.2% −5.4%
i9-11900K, Node 25.9.0 53.94ns +5.7% −4.8%
i9-11900K, Node 26.3.0 70.84ns +3.8% −2.9%
i9-11900K, Deno 2.8.3 (V8 14.9) 70.95ns +3.2% −4.0%
i9-11900K, Bun 1.3.14 (JavaScriptCore) 53.97ns −41.2% −40.2%
Celeron N4120, Node 25.9.0 173.2ns +24.2% +7.6%
Celeron N4120, Node 26.3.0 283.8ns +5.1% −0.6%

The Celeron’s Node 25 row settles that April run — months later I could no longer say which Node had produced it. Installing 25.9.0 and re-running reproduced the April numbers within a few nanoseconds (168.1/210/181.6ns then; 173.2/215.1/186.4ns now). The lesson about recording the environment stands; the third verdict turned out to be real.

Look at that pair of Node 25 rows again, though, because they are the strangest fact in the table: the identical 25.9.0 build ranks Duff’s device 4.8% ahead of the simple loop on the i9 and 7.6% behind it on the Celeron. Same engine, same file, opposite verdicts — the generated code still has to meet the silicon. The plausible mechanism: a low-power core with small caches and modest branch machinery prices these shapes differently than a big one. (Under 26.3.0 the two boxes happen to agree.)

Putting the eight runs together:

  • Across every V8 run — Deno’s included — one result held: Duff’s device beats naive unrolling, by 5–13% — the fall-through tail is cheaper than a second loop. JavaScriptCore voids even that: under Bun the plain-tail unrolling edges out the switch, by 1.7%.
  • On the i9 the Node sequence tells a tidy story: Duff’s lead over the simple loop shrinks monotonically — 19.5% (22), 5.4% (24), 4.8% (25), 2.9% (26) — and the absolute times name the mechanism. Across 22, 24, and 25, both hand-unrolled variants clock unchanging times — Duff’s at ~51.4ns, naive unrolling at ~57ns — while the plain loop is what V8 keeps reworking: 64.0 → 54.3 → 53.9ns. Explicit code leaves the JIT little to decide; the gap closed because the baseline caught up.
  • Node 26 then slows everything by roughly 30%, hand-unrolled code included; the Celeron repeats the slowdown louder (173.2ns → 283.8ns on the plain loop, 64% slower). Same file, same silicon — a regression that deserves its own investigation, out of scope here.
  • Deno, shipping the same V8 generation as Node 26, reproduces its verdict almost number for number — 70.95ns next to 70.84ns on the plain loop. For this kernel the wrapper around the engine decides nothing; the engine decides everything.
  • Bun is the warning against the tidy story: both hand-unrolled variants run 40% faster than the simple loop, and Bun’s unrolled copy, at 31.72ns, beats the best any Node build managed on the i9 by a factor of 1.6. At their best the engines nearly agree on what the plain loop is worth — Bun’s 53.97ns next to Node 24’s 54.31ns — and disagree by 40% on what hand-unrolling is worth.

One pairing deserves a second look, because at first glance it is strange: the two unrolled variants share everything but the tail — twelve identical blocks of ten cover 120 of the 127 elements — yet on current V8 they straddle the simple loop, one a few percent ahead, the other a few percent behind. A decomposition dissolves the paradox — inferred from the timings, not read from hardware counters, but consistent with every V8 row. The unrolled main body buys nothing over the plain loop — the engine’s own code is at least as good per element, and the second loop adds its own setup and branching — so naive unrolling lands behind on that overhead alone. The fall-through tail is a genuine saving on top: the seven tail elements are 5.5% of the count, but each of them pays a loop check that the unrolled elements skip, so replacing the checked mini-loop with one switch dispatch is consistently worth 5–13% of the whole copy. Two small, independent effects with opposite signs; Duff’s trick is the one that still pays.

So what is the most famous abuse of switch in C history worth in JavaScript? Forty percent, twenty percent, five, a statistical hair, less than nothing — pick a runtime, pick a version, pick a CPU.

Why the verdict keeps moving

In 1983 the compiler did roughly what it was told. Duff declared his variables register by hand; every branch he eliminated was an instruction the machine genuinely never executed. Hand-unrolling paid because nobody else was going to do it.

JavaScript in 2026 is a different negotiation. The source is not the program the machine runs; it is input to an optimizing JIT, and the JIT’s cost model is rebuilt release after release. That is what the i9 column recorded: under Node 22 the 1983 playbook still collects, exactly as Duff would expect; by Node 24 most of the win is gone — not because V8 punished the clever code, but because it learned to compile the plain loop better; by 26 the whole suite is a near-tie. JavaScriptCore reads the same file and reaches a conclusion none of the V8 builds hint at, paying hand-unrolling like it is 1983 again. And the same build read differently on different silicon. Nothing in the file changed; the program underneath it changed with every engine — and the machine got the last word.

That last word deserves spelling out: there are two optimizers stacked between the source and the result, each with its own cost model. V8 turns the JavaScript into the machine code it believes is cheap; the CPU then runs that code through its own machinery — out-of-order execution, branch prediction, prefetching — and re-decides what is actually expensive. A well-predicted branch is nearly free on a big desktop core, so the simple loop’s per-element check costs the i9 almost nothing; the Celeron’s modest core pays it in full, and the same machine code yields the opposite ranking.

The irony: a JIT is the one compiler positioned to merge the two layers. Unlike an ahead-of-time build shipped to unknown machines, it knows the exact core it stands on, and could in principle tailor instruction choice and timing to it — an automatic -mtune=native. In practice the engines spend that knowledge sparingly. V8 probes the CPU thoroughly — instruction sets, vendor and model, cache line sizes, even an Atom-class flag — but uses the answers mostly to gate which instructions may be emitted, AVX where present, not to reshape loops per core; JavaScriptCore is similar . Their adaptive budget goes to a different dimension, type feedback and tiering, where JavaScript’s wins are far larger. The pair of Node 25 rows above is what the unclaimed hardware dimension looks like in numbers.

Duff negotiated with one layer he could see. We negotiate with two we cannot — which is why the only reliable instrument left is the measurement itself.

Folklore fails in both directions here. The 1983 folklore says clever beats naive — Node 25 on the Celeron refutes it. The modern folklore says the JIT always wins and hand-tuning is pointless — Node 22 refutes that, and Bun’s forty percent refutes it loudly. What survives is narrower and more useful: micro-level performance belongs to the engine build, so the only durable property of the source is its shape. Simple, flat, predictable control flow — the same discipline as code linearization — reads best, hides the fewest bugs, and gives whichever optimizer shows up the cleanest input. Departing from it is sometimes worth the rent; the rent is re-measurement, on every machine and after every upgrade.

When unrolling might still matter

The benchmark covers exactly one situation: dense numeric arrays, two engines, a handful of builds, two CPUs. Honest caveats:

  • Other engines. Bun’s JavaScriptCore is in the table, disagreeing with every V8 column; Deno is too, agreeing with Node 26 exactly as their shared V8 predicts. Firefox’s SpiderMonkey remains unmeasured. The four loops above are the complete benchmark, published as a runnable gistnpm install && npm run bench repeats the experiment.
  • Other hardware. The same engine build returned opposite verdicts on a big core and a little one. A micro-optimization blessed on the dev machine can invert on the deployment target.
  • Non-monomorphic data. Mix element types and V8 abandons the specialized fast path; every assumption above needs re-measuring.
  • Different pipelines. WASM and asm.js-style numeric kernels go through different optimization paths with different rules.

The default stands: write the simple loop, and let a profiler — not folklore from either decade — argue otherwise. When a measured hot loop genuinely needs what the plain loop will not give — under Bun that was forty percent of the running time — unrolling is there to take: re-measured on the deployment target, and re-measured again after every upgrade.

Beyond the numbers

Set the stopwatch aside — the tail trick stands on its own. Wherever a loop is unrolled by hand, the count will eventually fail to divide by the block size, and the fall-through switch disposes of the remainder with less machinery than the textbook answer: one dispatch instead of a second loop with its own counter and branch. That much works on any engine; whether it is also faster changes with the engine build and the silicon underneath, as the whole table above just demonstrated.

The shape also completes a symmetry. The labeled block of break/continue is the new goto is linear code with several early exits: any break done leaves past the rest. The fall-through switch is its mirror image: linear code with several entries — the dispatch picks where to begin, and execution runs straight through to the one end. Named ways out of straight-line code, named ways in — two halves of the same structured-jump family.

And the entry does not have to be a loop remainder. Any process whose steps form a fixed linear order can be dispatched the same way. Take an object that may arrive partially prepared — fresh from a constructor, already loaded from the database, or decoded by an earlier pass. Its state names how far along it is, and the work left is exactly the trailing steps:

switch (doc.state) {
  case 'created':
    doc.raw = await db.load(doc.id);
  case 'loaded':
    doc.data = decode(doc.raw);
  case 'decoded':
    await linkRelated(doc);
  case 'linked':
    // ready to use
}

One dispatch and the object catches up from wherever it stood — the remainder tail again, with the state picking the entry instead of the count.

Summary

  • Duff’s device was real engineering, not a party trick: against a compiler that left optimization to the programmer, folding the remainder into the unrolled body was a legitimate win.
  • It ports to JavaScript in spirit — fall-through disposes of the tail in one dispatch — but not in letter: case labels cannot reach into a nested loop, so there is no jumping into the middle.
  • As a performance device it is neither retired nor reliable: across eight runs, four Node majors, two engines, and two CPUs, the verdict ran from a 40% rout (Bun’s JavaScriptCore) and a 19.5% win (Node 22) through small wins and near-parity down to an outright loss — with the same 25.9.0 build delivering a win on one CPU and the loss on the other. Even the one V8-stable fact — the fall-through tail beats the plain tail loop — flips under JavaScriptCore, by a hair, the other way.
  • Fall-through itself is still worth using — as a clarity tool in the structured-jump family, alongside the labeled break and early continue of break/continue is the new goto .
  • The durable lesson has nothing to do with switch: measure on the target that matters, and re-measure after engine upgrades — the verdict of this very post kept changing while it was being written.

Tom Duff reported “a combination of pride and revulsion”. Forty-three years later his device still wins benchmarks — on the right engine build — and still gets to keep both.