4

When I run this code in Firefox and Chrome, the results are different:

function run() {
  setTimeout(() => console.log("1"), 0);
  setTimeout(() => console.log("2"), 100);

  let start = Date.now();
  while (Date.now() - start < 200) {
    // do nothing
  }
  setTimeout(() => {
    console.log("3");
  }, 0);

  start = Date.now();
  while (Date.now() - start < 200) {
    // do nothing
  }
  setTimeout(() => {
    console.log("4");
  }, 0);
}

run();

In Chrome (and Node.js), this is printed:

1
3
2
4

In Firefox, this is printed:

1
2
3
4

But if I remove the line 2 (setTimeout(() => console.log("1"), 0);), then the same thing is printed on every platform:

2
3
4

How to explain these different results?

Thanks!

Kaiido
  • 123,334
  • 13
  • 219
  • 285
UnitTset
  • 259
  • 1
  • 5
  • 1
    In both chrome and firefox I get the same result: 1, 2, 3, 4 – yaakov Jun 23 '22 at 23:01
  • I updated Chrome and you're right, the results are the same as in Firefox now... but it's different in Node (v16). I guess ShadowRanger is right, it doesn't matter because it's undocumented. Still, it's interesting to know that you can't be sure of the order of execution of these setTimeout callbacks and it can change between different versions of the browser – UnitTset Jun 23 '22 at 23:19

3 Answers3

2

The explanation: It doesn't matter.

The details of when deferred "messages" are added to the event loop message queue are implementation details, not documented guarantees. By the time your function yields control back to the event loop, all of your setTimeout call are eligible to execute (three of them were scheduled to run immediately, one of them was scheduled to run in 100 ms) and you've guaranteed it's been at least 400 ms since you scheduled it.

The difference between the two could be as simple as whether they choose to look for deferred tasks that have become ready (to move from the deferred queue to the main "ready to go" message queue) immediately before or immediately after new items are inserted in the main message queue. Chrome chooses to move immediately after 3 is scheduled (so 3 goes in, then the deferred 2), Firefox immediately before (moving in 2 before it puts 3 in).

Both of them could change in the next release without violating any documented guarantees. Don't rely on it, don't expect it to be stable. While immediately scheduled tasks are guaranteed to execute in FIFO order, there are no guarantees on when deferred tasks get moved onto the "ready-to-go" message queue. The spec seems to requires that 1, 3 and 4 execute in that order (since they were all immediately ready, not deferred), with only the ordering of 2 being flexible, but even that isn't a true guarantee; it can get weird with the various ways in which an "immediate" setTimeout task may not actually be scheduled immediately.

You may be interested in the MDN docs on why setTimeout can take longer than expected; it explains by side-effect a lot of how the event loop works, even as it carefully provides no guarantees on the details you're exploring.

ShadowRanger
  • 143,180
  • 12
  • 188
  • 271
  • 1
    No this is completely wrong. The specs ask that any two task queued on the same task source are executed in queuing order. Here the issue is about what clock time the timer represents. – Kaiido Jun 23 '22 at 23:20
  • @Kaiido: Hmm... You're right, at least to some extent. [The MDN docs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop#queue) note that "At some point during the event loop, the runtime starts handling the messages on the queue, starting with the oldest one." That said, [they also say, regarding `setTimeout`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop#adding_messages) "The time value represents the (minimum) delay after which the message will be pushed into the queue." So the delayed `setTimeout` isn't *in* the queue initially, and the rules for when... – ShadowRanger Jun 23 '22 at 23:28
  • 1
    ...it moves are not well-specified AFAICT. – ShadowRanger Jun 23 '22 at 23:28
  • @Kaiido: I've updated the answer. I think, from the spec, that it *kinda* guarantees that it must go `1`, `3`, `4`, with `2`'s order within that ordering being flexible; it must be after `1`, that's all (because no guarantees are given regarding when a deferred task moves to the message queue for processing). But the security/performance/privacy quirks that `setTimeout` allows for (which can make `delay == 0` tasks turn into `delay > 0` tasks) might break even that guarantee (that's not the event loop's fault though, it's a quirk specific to `setTimeout`). – ShadowRanger Jun 23 '22 at 23:37
  • Timers ordering is also guaranteed by the [run steps after timeout](https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#run-steps-after-a-timeout) algorithm. – Kaiido Jun 23 '22 at 23:47
1

I can't give you an full detailed explanation, but the second paramter of setTimeoput and setInterval doesn't mean, it will exactly execute it at that time. They will put it in a queue, so the background can execute it.

The browser has a lifecycle when to execute specific steps to update the data and the styles.
I can only send you this youtube link, that helped me to learn more about it:

https://www.youtube.com/watch?v=MCi6AZMkxcU

Sysix
  • 1,572
  • 1
  • 16
  • 23
1

1, 2, 3, 4 is the behavior that is expected.

The specs ask to

Wait until any invocations of this algorithm that had the same global and orderingIdentifier, that started before this one, and whose milliseconds is equal to or less than this one's, have completed.

So any call to setTimeout that were both made before, and had their milliseconds set to a lower value should be called first.

Firefox, Safari, and the current stable channel of Chrome all do this.

So when the event loop gains control again, it sees that all the timers are ready to be called, and it queues tasks for each, in this scheduled called order:

"1": scheduled-time = t=0 + 0   = 0
"2": scheduled-time = t=0 + 100 = 100
"3": scheduled-time = t=200 + 0 = 300
"4": scheduled-time = t=400 + 0 = 400

But, what Chrome apparently used to do and still does in its other branches is that they only do look at the milliseconds param to do the ordering and ignore the first "that started before this one" condition.
So in there we've got,

"1": milliseconds = 0
"3": milliseconds = 0
"4": milliseconds = 0
"2": milliseconds = 100

Below is a rewrite of this logic:

// We use a MessageChannel to hook on each iteration of the event loop
function postTask(cb) {
  const channel = postTask.channel ??= new MessageChannel();
  const { port1, port2 } = channel;
  port1.addEventListener("message", (evt) => { cb() }, { once: true });
  port1.start();
  port2.postMessage("");
}
const timers = new Set();
let ended = false; // So we can stop our loop after some time
function timeoutChecker() {
  const now    = performance.now();
  const toCall = Array.from(timers)
                  .filter(({ startTime, millis }) => startTime + millis <= now)
                  .sort((a, b) => a.millis - b.millis);
  while(toCall.length) {
    const timer = toCall.shift();
    timers.delete(timer);
    timer.callback();
  }
  if (!ended) {
    postTask(timeoutChecker);
  }
}
function myTimeout(callback, millis) {
  const startTime = performance.now();
  timers.add({ startTime, millis, callback });
}
// Begin our loop
postTask(timeoutChecker);

// OP's code
function run() {
  myTimeout(() => console.log("1"), 0);
  myTimeout(() => console.log("2"), 100);

  let start = Date.now();
  while (Date.now() - start < 200) {
    // do nothing
  }
  myTimeout(() => {
    console.log("3");
  }, 0);

  start = Date.now();
  while (Date.now() - start < 200) {
    // do nothing
  }
  myTimeout(() => {
    console.log("4");
  }, 0);
}

run();

// all should be done after 1s
setTimeout(() => ended = true, 1000);

As for why you sometimes may see "2" before "4" in Chrome and node.js, it's because they do clamp 0ms timeout to 1ms (thought they're working on removing this in Chrome). So when the event loop gains control at t=400, this log("4") timeout may not have met the timer condition yet.


Finally about Chrome's branch thing, I must admit I'm not sure at all what happens there. Running a bisect (against Canary branch) I couldn't find a single revision where the current stable branch behavior happens, so this must be a branch settings thing.

Kaiido
  • 123,334
  • 13
  • 219
  • 285