1

I am trying to make sense of the execution flow of fetch() but it's not consistent with my experimentation.

I found this answer https://stackoverflow.com/a/62566665/15933760

The fetch method will indeed start a network request immediately, which will get processed "in parallel", i.e outside of the event loop. When the the request response will be received (not its body, typically the headers), the browser will queue a new task which will only be responsible of resolving that Promise, and thus of queuing its microtask.

Experimentation

I understand it except for the task/microtask queuing bit. This is what I tried to experiment:

function delay() {
        i=0;
        while(i<1000000000) i++;
}

console.log('start');

setTimeout(()=> {
        console.log('start macrotask');
        delay();
        console.log('end macrotask');
}, 0);

Promise.resolve().then(() => {
        console.log('start promise handler');
        delay();
        console.log('end promise handler');
});

console.log('calling fetch');
fetch('http://127.0.0.1:9999/').then(function(result) {
        console.log('success start fetch handler');
        delay();
        console.log('success end fetch handler');
},
function(error) {
        console.log('error start fetch handler');
        delay();
        console.log('error end fetch handler');
});
console.log('done calling fetch');

delay();
delay();
delay();
delay();
delay();
delay();
delay();
delay();
delay();
delay();
delay();
delay();
delay();
Promise.resolve().then(() => {
        console.log('start 2nd promise handler');
        delay();
        console.log('end 2nd promise handler');
});
setTimeout(()=> {
        console.log('start 2nd macrotask');
        delay();
        console.log('end 2nd macrotask');
}, 0);
delay();

console.log('end');

In my Chrome console this outputs:

start
calling fetch
done calling fetch
end
start promise handler
end promise handler
start 2nd promise handler
end 2nd promise handler
undefined
success start fetch handler
success end fetch handler
start macrotask
end macrotask
start 2nd macrotask
end 2nd macrotask

Looking at my local traffic, a connection and request are made to 127.0.0.1:9999 right when it outputs calling fetch. Furthermore, if I program the socket to not respond immediately, the execution continues and I see done calling fetch right away.

So far this is all consistent and shows that fetch executes in parallel with the main thread.

The Problem

  1. The first main thread is executing so we see start
  2. setTimeout callback gets queued as a macrotask
  3. The first promise's handler gets queued as a microtask
  4. we see calling fetch done calling fetch as fetch() continues to execute in parallel even after that
  5. The main thread waits plenty of time with delay() calls in addition to me looking at traffic to witness the connection forming, request and reply getting sent, and the socket connection closing up cleanly
  6. fetch() completing its job in parallel, I expect it to either
    • Option 1 fulfill the promise and therefore queue its handler as a microtask, or
    • Option 2 queue a task (macrotask) which would then fulfill the promise.

Option 1

  1. The promise handler gets queued as a microtask
  2. Following the rest of the code, it queries another microtask (that last promise)
  3. Followed by queuing a macrotask (last setTimeout call)

This leaves us with queues looking like this:

microtask queue:  first promise handler
                  fetch handler
                  last promise handler

macrotask queue:  first timeout callback
                  last timeout callback

This isn't consistent because the order we see from the output is:

first promise handler
last promise handler
fetch handler
first timeout callback
last timeout callback

Option 2

  1. A macrotask that fulfills the fetch promise gets queued
  2. Following the rest of the code, it queries a microtask (that last promise)
  3. Followed by queuing a macrotask (last setTimeout call)

This leaves us with queues looking like this:

microtask queue:  first promise handler
                  last promise handler

macrotask queue:  first timeout callback
                  fetch task
                  last timeout callback

This again isn't consistent because the order we see from the output is:

first promise handler
last promise handler
fetch handler
first timeout callback
last timeout callback

To be consistent it should execute the promise-resolving task after the first timeout callback task.

actor_deny
  • 11
  • 1
  • Not all macrotasks are scheduled on the same queue, the browser event loop is free to choose the next task from what it considers the most important. – Bergi Sep 02 '22 at 23:48

1 Answers1

3

You're right that Promises create microtasks and setTimeout and fetch create tasks (macrotasks); I think the problem here is that setTimeout with delay 0 does not necessarily get queued immediately or in the same task queue as fetch.

  • MDN's page on setTimeout lists several reasons for late timeouts, including "if the page (or the OS/browser) is busy with other tasks".
  • WHATWG's HTML "running steps after a timer" spec (linked from the setTimeout definition section 8.6) allows for an implementation-defined extra delay (point 5.3) that enables timer resolution reduction for power savings and other reasons.
    • For Chrome, up until Chrome v101, there was a 1ms minimum delay for setTimeout. That was lifted recently, but only a few months before this question was asked, and as of now (September 2022) the open Chromium bug describes that the policy is still controlled for Enterprise installations with group policy settings.
  • WHATWG's HTML spec on the event loop allows the implementer to choose which task queue gets pulled first. setTimeout uses the "timer task source" and fetch uses the "networking task source", so the ordering within those queues is guaranteed but the ordering between them is not:

    Let taskQueue be one such task queue, chosen in an implementation-defined manner.

  • Even browsers implement the spec subtly differently, as Jake Archibald documented in 2015.

    So it's Chrome that gets it right. The bit that was 'news to me' is that microtasks are processed after callbacks (as long as no other JavaScript is mid-execution), I thought it was limited to end-of-task. This rule comes from the HTML spec for calling a callback:

    [quote]

    …and a microtask checkpoint involves going through the microtask queue, unless we're already processing the microtask queue.

As long as it's possible that setTimeout with 0ms delay does not immediately queue a task onto the same task queue as fetch, then the behavior is self-consistent, even if in practice it may vary between browsers.

Jeff Bowman
  • 90,959
  • 16
  • 217
  • 251