One possible explanation for this behavior is the 4ms minimum timeout mentioned by Andrew: https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#reasons_for_delays_longer_than_specified
According to the HTML standard, browsers are required to enforce a minimum timeout of 4 milliseconds once a nested call to setTimeout has been queued 5 times.
await
wraps the code below in a callback function. In the case of a for loop, the remaining iterations are executed within that callback, meeting the conditions of a "nested" setTimeout.
If you change console.log(zero);
to
console.log(new Date().getMilliseconds());
, you will notice that the first 5 logs occur very close together, while the subsequent ones are further apart.
Here's what's happening step by step:
First, we set up an almost infinite loop:
await new Promise((r1)=>setTimeout(r1)); //1
if (stop) {
break;
}
(r)=>setTimeout(r)
is immediately executed, causing r1()
to be pushed to the end of the event queue. After 5 iterations, a timestamp 4ms into the future is attached to this action. When the promise resolves, await
bundles the following code into a callback which also gets queued at the end of the event queue.
Upon a click:
stop = true;
await new Promise((r2)=>setTimeout(r2)); //2
stop = false
stop = true
and (r2)=>setTimeout(r2)
are executed right away, pushing r2()
to the end of the event queue. Again, await
bundles the following code into a callback queued after the promise resolves.
There are six events taking place:
r1
with a 4ms timeout is added to the end of the event queue
r1
reaches the top of the event queue. If 4ms have passed, r1
is executed, and if (stop) break;
is moved to the bottom of the event loop. If not, r1
is pushed back to the end again.
if (stop) break;
is executed when it reaches the top of the event queue. If the loop isn't broken, event 1 is replayed.
- User clicks, triggering
stop = true
and adding r2
to the end of the event queue.
r2
is executed when it reaches the top of the event queue, followed by stop = false
being queued.
stop = false
is then executed.
A key point is that event 3 needs to happen between events 4 and 6 for the loop to break. With immediate actions and a 4ms delay, there is a specific order of events where the loop won't break on a click:
- event 1 (
r1
queued with 4ms delay)
- event 2 (4ms elapse,
if (stop) break;
queued)
- event 3 & 1 (
if (stop) break;
executed, loop remains unbroken, r1
queued w/ 4ms delay)
- event 4 (user click,
stop = true
, r2
queued)
- event 2 (4ms don't pass,
r1
queued again)
- event 5 (
stop = false
queued)
- event 2 (4ms elapse,
if (stop) break;
queued)
- event 6 (
stop = false
executed)
- event 3 & 1 (
if (stop) break;
executed, loop remains unbroken, r1
queued w/ 4ms delay)
This scenario creates a race condition in a single-threaded environment.
To mitigate this issue, synchronizing the two timeouts with intervals greater than 4ms may help resolve the problem
let zeros = new Array(10000).fill(0);
(async () => {
let stop = false;
document.addEventListener('click', async ()=>{
console.log('click');
stop = true;
await new Promise((r)=>setTimeout(r,5)); //2
stop = false;
});
for (let zero of zeros) {
await new Promise((r)=>setTimeout(r,5)); //1
if (stop) {
break;
}
console.log(zero);
}
})();
Nevertheless, there is still a theoretical possibility for the loop to continue uninterrupted. The following sequence of events could allow the loop to proceed:
- event 1 (
r1
queued w/ 5ms delay)
- event 4 (user click,
stop = true
, r2
queued w/ 5ms delay)
- event 2 (5ms haven't passed from event 1,
r1
queued again)
It's plausible that events 1 and 4 occurred rapidly, but events 2 and 5 had some delay between them - this enables event 5 to complete before event 2.
- event 5 (5ms elapsed since event 4,
stop = false
queued)
- event 2 (5ms elapsed since event 1,
if (stop) break;
queued)
- event 6 (
stop = false
executed)
- event 3 & 1 (
if (stop) break;
executed, loop remains unbroken, r1
queued)
Although I have not managed to reproduce this behavior, it's advisable to avoid coding in such a way.
To achieve your desired outcome, consider using setInterval
instead of a loop. This approach cycles the callback to the end of the queue after execution, allowing other events to interject. Unlike while
and for
loops which block additional execution while running.
let stop = false;
document.addEventListener('click', () => (stop = true));
const id = setInterval(() => {
console.log('running');
if (stop) {
console.log('stopped');
clearInterval(id);
}
});