Question

Why is JavaScript executing callbacks in a for-loop so fast the first time?

The following code with callback argument runs faster in the first loop.

const fn = (length, label, callback) => {
  console.time(label);
  for (let i = 0; i < length; i++) {
    callback && callback(i);
  }
  console.timeEnd(label);
};

const length = 100000000;
fn(length, "1", () => {})  // very few intervals
fn(length, "2", () => {})  // regular
fn(length, "3", () => {})  // regular

and then I removed the third argument callback, and their execution times are very near:

const fn = (length, label, callback) => {
  console.time(label);
  for (let i = 0; i < length; i++) {
    callback && callback(i);
  }
  console.timeEnd(label);
};

const length = 100000000;
fn(length, "1")  // regular
fn(length, "2")  // regular
fn(length, "3")  // regular

Why?

 9  610  9
1 Jan 1970

Solution

 10

In short: it's due to inlining.

When a call such as callback() has seen only one target function being called, and the containing function ("fn" in this case) is optimized, then the optimizing compiler will (usually) decide to inline that call target. So in the fast version, no actual call is performed, instead the empty function is inlined.
When you then call different callbacks, the old optimized code needs to be thrown away ("deoptimized"), because it is now incorrect (if the new callback has different behavior), and upon re-optimization a little while later, the inlining heuristic decides that inlining multiple possible targets probably isn't worth the cost (because inlining, while sometimes enabling great performance benefits, also has certain costs), so it doesn't inline anything. Instead, generated optimized code will now perform actual calls, and you'll see the cost of that.

As @0stone0 observed, when you pass the same callback on the second call to fn, then deoptimization isn't necessary, so the originally generated optimized code (that inlined this callback) can continue to be used. Defining three different callbacks all with the same (empty) source code doesn't count as "the same callback".

FWIW, this effect is most pronounced in microbenchmarks; though sometimes it's also visible in more real-world-ish code. It's certainly a common trap for microbenchmarks to fall into and produce confusing/misleading results.

In the second experiment, when there is no callback, then of course the callback && part of the expression will already bail out, and none of the three calls to fn will call (or inline) any callbacks, because there are no callbacks.

2024-07-25
jmrk