Search code examples
javascriptbrowserpromisesettimeoutevent-loop

Optimizing JS execution and browser frame rate. setTimeout(..., 0) and setTimeout(..., 1) behave differently


I'm studying browser rendering mechanics and want to be able to optimize different action handling on same event and understand what impacts repaints in these cases.

Here I try to change the style of a span (action 1) and just do something more expensive by incrementing a value in a loop (action 2)

  1. I don't understand why span's style change is being delayed (most of the time, but not always too) if I put the second action in setTimeout (with 0ms delay set)

  2. If setTimeout's delay is set to 1ms it never blocks style change, why is there a difference for 0 and 1 here?

  3. If we remove setTimeout and keep all the logic in a promise chain it always blocks the span style change, does that mean that chained microtasks that work with synchronous code only are basically same as having a synchronous loop in this case. They are blocking repaints and cannot be used for such optimizations?

P.S. Maybe someone knows a good resource for studying this stuff, mostly optimizing script execution throughout different rendering frames?

document.addEventListener("click", (evt) => {
  if (!evt.target.matches("span")) { return; }
  evt.target.style.color = "red";

  setTimeout(() => {
    const loopTimes = 900000;

    const result = Array(loopTimes)
      .fill()
      .reduce((acc, val, i) => {
        return acc.then((val) => {
          return val + 1;
        });
      }, Promise.resolve(0));

    result.then((endResult) => console.log(endResult));
  }, +evt.target.dataset.timeout);
});
<span data-timeout="0">click for red (0ms)</span><br>
<span data-timeout="1">click for red (1ms)</span>


Solution

  • This is a Chrome specific behavior that certainly appeared very recently when they started deploying the end of the default 1ms timeout for setTimeout.
    Here is a comment I received on a related "bug" I opened:

    Chromium's task scheduling executes task queues in two ways: PostImmediateTaskImpl and PostDelayedTaskImpl [1], setTimeout(fn2, 0); in the test case is treated as post immediate task [...]

    This means that setTimeout(fn, 0) is now with some kind of ultra high priority and will always get executed before the next animation frame (which happens generally right after a click event because mouse events are throttled to the screen refresh rate).

    But from your question I have the feeling that you are actually wondering on an higher level how the rendering happens.
    So a little recap, (native) Events are fired from tasks, which are queued in task queues from which the event-loop will choose the one to execute at each of its iterations.
    Some of these event-loop iterations are special in that the monitor sent a V-Sync signal and the browser knows it can pass new data to it. This is what we call a rendering frame.
    In this rendering frame, the browser will execute a few specific callbacks (like animation frame callbacks, resize or scroll events, CSS animations, a lot of other stuff and more importantly to us, the actual rendering of the page.
    When you do block the event-loop, the browser can't do all that it was supposed to do in the rendering frame, it has to wait for your script to be over (or if it takes too long it may kill it eventually).
    Since the click event is fired close enough to the next painting frame, 1ms timeout is enough to let the browser enter once the rendering frame before your scripts block it.

    Regarding microtasks, they have their own microtask-queue, which is visited every time the JS callstack is emptied, so after each callback (and in some other places of the event-loop). Once this queue is visited, it will exhaust all the microtasks in it before returning back to the event-loop, even the ones that were added during this checkpoint. So indeed, something like function lock() { Promise.resolve().then(lock) } would lock the browser just like a while(1) {} loop.

    But note that setTimeout is almost unrelated to the rendering phase of the event loop.

    If you are interested in the rendering, then you'll want to hook animation frames, and to do so, you can use the requestAnimationFrame() method, which will schedule a callback to fire right before the rendering happens.

    From there, even Chrome's weird setTimeout(fn, 0) will happen only after the rendering:

    document.addEventListener("click", (evt) => {
      // wait the next animation frame
      requestAnimationFrame(() => {
        if (!evt.target.matches("span")) { return; }
        evt.target.style.color = "red";
    
        setTimeout(() => {
          const loopTimes = 900000;
    
          const result = Array(loopTimes)
            .fill()
            .reduce((acc, val, i) => {
              return acc.then((val) => {
                return val + 1;
              });
            }, Promise.resolve(0));
    
          result.then((endResult) => console.log(endResult));
        }, +evt.target.dataset.timeout);
      });
    });
    <span data-timeout="0">click for red (0ms)</span><br>
    <span data-timeout="1">click for red (1ms)</span>