25

Browser reads and runs a JavaScript file, the synchronous tasks written in the file immediately become in-mid-execution task, setTimeout callbacks become macrotasks, and promise callbacks become microtasks. Everything is good.

I thought I mastered the JavaScript Event Loop until I met requestAnimationFrame.

@T.J. Crowder provided me with the following code snippet.

const messages = [];
setTimeout(() => {
  // Schedule a microtask
  Promise.resolve().then(() => {
    log("microtask");
  });
  
  // Schedule animation frame callback
  requestAnimationFrame(() => {
    log("requestAnimationFrame");
  });
  
  // Schedule a macrotask
  setTimeout(() => {
    log("macrotask");
  }, 0);
  
  // Schedule a callback to dump the messages
  setTimeout(() => {
    messages.forEach(msg => {
      console.log(msg);
    });
  }, 200);

  // Busy-wait for a 10th of a second; the browser will be eager to repaint when this task completes
  const stop = Date.now() + 100;
  while (Date.now() < stop) {
  }
  
}, 100);

function log(msg) {
  messages.push(Date.now() + ": " + msg);
}
  • Chrome: microtask, requestAnimationFrame, macrotask
  • Firefox: microtask, macrotask, requestAnimationFrame

The spec doesn't say whether that can happen between the completion of a macrotask and the processing of its scheduled microtasks, or only between macrotasks. So presumably that can vary browser to browser.

But in both Chrome and Firefox, microtasks are always executed before requestAnimationFrame callbacks. My questions below are based on this observation.


**Q1: **

Even though the browser has no repaint work, will the requestAnimationFrame's callback be executed at the refresh rate (default 60 per second)?


**Q2: **

Below is from https://developers.google.com/web/fundamentals/performance/rendering/debounce-your-input-handlers

The only way to guarantee that your JavaScript will run at the start of a frame is to use requestAnimationFrame.

Too heavy in-mid-execution task will lag the browser, cause the frame interval exceed 16.66ms, block frames from completing.

Does the word 'guarantee' mean that the microtasks will be in-mid-execution immediately the current JS stack becomes empty, hence block the current frame from completing (if the microtask is also too heavy)?

Brian Tompsett - 汤莱恩
  • 5,753
  • 72
  • 57
  • 129
Shuumatsu
  • 592
  • 2
  • 5
  • 12

2 Answers2

31

It's basically its own thing. When the browser is about to repaint the page, which it does typically 60 times/second if not blocked by a running task, it will call all queued requestAnimationFrame callbacks just before doing so, and then do the repaint.

The spec doesn't say whether that can happen between the completion of a task (macrotask) and the processing of its scheduled microtasks, or only between (macro)tasks. So presumably that can vary browser to browser.

The old spec (now obsolete and superceded) described it in (macro)task terms, suggesting it would be between (macro)tasks, but things may have moved on from there.

The spec now says when this happens in the Event Loop Processing Model section. The shortened version with a lot of detail removed is:

  1. Do the oldest (macro) task
  2. Do microtasks
  3. If this is a good time to render:
    1. Do some prep work
    2. Run requestAnimationFrame callbacks
    3. Render

Let's do a test:

const messages = [];
setTimeout(() => {
  // Schedule a microtask
  Promise.resolve().then(() => {
    log("microtask");
  });
  
  // Schedule animation frame callback
  requestAnimationFrame(() => {
    log("requestAnimationFrame");
  });
  
  // Schedule a (macro)task
  setTimeout(() => {
    log("(macro)task");
  }, 0);
  
  // Schedule a callback to dump the messages
  setTimeout(() => {
    messages.forEach(msg => {
      console.log(msg);
    });
  }, 200);

  // Busy-wait for a 10th of a second; the browser will be eager to repaint when this task completes
  const stop = Date.now() + 100;
  while (Date.now() < stop) {
  }
  
}, 100);

function log(msg) {
  messages.push(Date.now() + ": " + msg);
}

Sure enough, the results vary by browser:

  • Chrome: microtask, requestAnimationFrame, (macro)task
  • Firefox: microtask, (macro)task, requestAnimationFrame

(I reliably get the same results in repeated tests on those browsers. I don't have Edge handy...)

Now Chrome (and so presumably Chromium, Brave, and the new Edge), Firefox, iOS Safari, and Legacy Edge all do the same thing, which matches the spec: microtask, requestAnimationFrame, (macro)task.

Here's a version with the busy-wait up front, instead of at the end, in case it changes something:

const messages = [];
setTimeout(() => {
  // Busy-wait for a 10th of a second; the browser will be eager to repaint when this task completes
  const stop = Date.now() + 100;
  while (Date.now() < stop) {
  }
  
  // Schedule a microtask
  Promise.resolve().then(() => {
    log("microtask");
  });
  
  // Schedule animation frame callback
  requestAnimationFrame(() => {
    log("requestAnimationFrame");
  });
  
  // Schedule a (macro)task
  setTimeout(() => {
    log("(macro)task");
  }, 0);
  
  // Schedule a callback to dump the messages
  setTimeout(() => {
    messages.forEach(msg => {
      console.log(msg);
    });
  }, 200);

}, 100);

function log(msg) {
  messages.push(Date.now() + ": " + msg);
}

I reliably get microtask, requestAnimationFrame, (macro)task on both Chrome and Firefox with that change. I get the same results now as with the earlier snippet.


**Q1: **

Even though the browser has no repaint work, the requestAnimationFrame's callback will be excuted at the refresh rate (default 60 per second).

Provided nothing is blocking.

**Q2: **

That sentence means exactly, and only, what it says: Your callback will be called (along with any requestAnimationFrame callbacks that are queued up) immediately prior to drawing a frame. It doesn't mean that a frame is necessarily drawn every 60th of a second — because the thread may be busy doing other things.

Those callbacks will not interrupt other tasks. Again: If other tasks have the main UI thread busy, it's busy, and the framerate suffers.

T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875
  • Is it possible that the results depend on how far in the future the actual animation frame is? I mean, the timer is always (conceptually) relative to the point in time when it was requested, but the animation frames happen when they happen. (But I guess then the results wouldn't be so consistent ...) – Pointy Mar 27 '17 at 16:23
  • @Pointy: That's why I have the busy-wait in there, so that the browser's eager to draw the next frame (in theory). – T.J. Crowder Mar 27 '17 at 16:25
  • @Pointy: Your point about the timer being relative to when it was scheduled inspired me to add a variant above with the busy-wait up-front instead of at at the end. And it changes the behavior in Firefox! – T.J. Crowder Mar 27 '17 at 16:29
  • I'm kind of in a mess now. I have some questions apart from this. Too much sync task will cause the current frame be a long frame ( longer than 16.66ms), but how about microtask? is every frame at least 16ms unless there's a forced synchronous layouts? – Shuumatsu Mar 27 '17 at 16:35
  • 2
    @Vanis: The *interval* between frames is typically 16.67ms provided the browser is drawing 60 frames/second, which it may or may not be -- inactive tabs tend to get slowed or even stopped (it's fun to start [this fiddle](https://jsfiddle.net/mosdxrhk/) and then make the tab inactive and then come back to it), or some task could be hogging the UI thread. And of course, absent any pending `requestAnimationFrame` callbacks, the browser may not need to repaint anything (but at that point, we don't really care). – T.J. Crowder Mar 27 '17 at 16:52
  • I just installed firefox 51, and test the version with the busy-wait up front. But I got microtask, macrotask, requestAnimationFrame...different from yours, [screen shot is here](https://drive.google.com/file/d/0B58RTDaLOkjMT0E0Mi1oazdSN1U/view). I rewrite my question, hope you can reread it. – Shuumatsu Mar 27 '17 at 19:06
  • @T.J.Crowder What about animations that run on the GPU, Like rendering which only require compositing (opacity, transform, etc)? Do they also get affected if the main thread is already occupied or there are tasks pending in the event queue? – darKnight Jun 23 '22 at 19:56
  • @darKnight - I don't have any specific knowledge there. But if the animation involves `requestAnimationFrame`, then **that** part at least has to run on the main UI thread that's running the JavaScript code for the page. – T.J. Crowder Jun 24 '22 at 06:24
0

@T.J.Crowder's answer is excellent, but I wanted to provide a detailed breakdown so I could understand the event loop myself. Below is a breakdown of how I read this code line by line. Note that I'm going to ignore the Date.now() logs and instead focus on the order of invocation for the different callbacks.


Begin parsing main script at L1:

Macrotasks:     [main(L1)]

Microtasks:     []

Animation:      []

messages:       []

Reach L2 and schedule a timeout with a timer of 100ms, pushing it to the macrotask queue:

Macrotasks:     [main(L1), timeout(L2,100ms)]

Microtasks:     []

Animation:      []

messages:       []

Main task finishes. Per the HTML spec, we would now process the microtask queue, but it is empty. After that, we would do any rendering, but there are no rendering opportunities, so we continue processing the macrotask queue. We see that there is a timeout callback with a timer of 100ms, so we schedule that to fire when its clock goes off:

Macrotasks:     [timeout(L2,100ms)]

Microtasks:     []

Animation:      []

messages:       []

Timeout (L2) runs after ~100ms elapses. We reach Line 4 in that callback and schedule a microtask (promise callbacks are microtasks):

Macrotasks:     [timeout(L2)]

Microtasks:     [promise(L4)]

Animation:      []

messages:       []

We're still in the timeout callback. L9 schedules an animation frame callback at the next rendering opportunity:

Macrotasks:     [timeout(L2,100ms)]

Microtasks:     [promise(L4)]

Animation:      [animation(L9)]

messages:       []

We don't yet have a rendering opportunity because the timeout callback from L2 is still running, so move on to L14 and schedule yet another macrotask (another timeout callback):

Macrotasks:     [timeout(L2,100ms), timeout(L14,0ms)]

Microtasks:     [promise(L4)]

Animation:      [animation(L9)]

messages:       []

Move on to L19 and schedule another task:

Macrotasks:     [timeout(L2,100ms), timeout(L14,0ms), timeout(L19,200ms)]

Microtasks:     [promise(L4)]

Animation:      [animation(L9)]

messages:       []

We reach L30 and the timeout finishes. The event loop now looks like this:

Macrotasks:     [timeout(L14,0ms), timeout(L19,200ms)]

Microtasks:     [promise(L4)]

Animation:      [animation(L9)]

messages:       []

We are again at a micro-task checkpoint per the HTML spec. The microtask queue is non-empty, so we run the promise callback on L4. That pushes "microtask" to the messages array:

Macrotasks:     [timeout(L14,0ms), timeout(L19,200ms)]

Microtasks:     [promise(L4)]

Animation:      [animation(L9)]

messages:       ["microtask"]

Promise callback finishes, so the micro-task queue is empty. We are now at a potential rendering opportunity:

Macrotasks:     [timeout(L14,0ms), timeout(L19,200ms)]

Microtasks:     []

Animation:      [animation(L9)]

messages:       ["microtask"]

We see that there is a queued animation, so run L9, pushing "requestAnimationFrame" to the messages array:

Macrotasks:     [timeout(L14,0ms), timeout(L19,200ms)]

Microtasks:     []

Animation:      [animation(L9)]

messages:       ["microtask", "requestAnimationFrame"]

The animation finishes:

Macrotasks:     [timeout(L14,0ms), timeout(L19,200ms)]

Microtasks:     []

Animation:      []

messages:       ["microtask", "requestAnimationFrame"]

We now return to the macro-task queue. We schedule L14's timeout for execution in 0ms. The clock fires and the browser executes its callback. Now the messages array looks like this:

Macrotasks:     [timeout(L14,0ms), timeout(L19,200ms)]

Microtasks:     []

Animation:      []

messages:       ["microtask", "requestAnimationFrame", "macrotask"]

That task finishes:

Macrotasks:     [timeout(L19,200ms)]

Microtasks:     []

Animation:      []

messages:       ["microtask", "requestAnimationFrame", "macrotask"]

Now we check for micro-tasks and rendering opportunities. We have neither of those two things, so continue processing the macrotask queue. We schedule the timeout callback on L19 for execution once a timer fires in 200ms. It fires and we log each message to the console.

Macrotasks:     [timeout(L19,200ms)]

Microtasks:     []

Animation:      []

messages:       ["microtask", "requestAnimationFrame", "macrotask"]

Done:

Macrotasks:     []

Microtasks:     []

Animation:      []

messages:       ["microtask", "requestAnimationFrame", "macrotask"]