3

I was writing some code where a user would click a button and the time of the next locally-visible solar eclipse would be computed.

This can be a very time-consuming calculation. So I wrote the calculation code with a couple of nested async functions, breaking all of the computations into bite-size pieces, allowing execution to yield frequently so that the user's web browser wouldn't lock up during the calculation.

Here's a grossly simplified test case of the timing issues involved, without the astronomical complexity (available on CodePen):

window.CP.PenTimer.MAX_TIME_IN_LOOP_WO_EXIT = 15000;

async function workWorkWork(msg) {
  return new Promise(async (resolve) => {
    const start = performance.now();

    while (performance.now() < start + 5000) {
      await new Promise((innerResolve) => {
        let t = 0;

        for (let i = 0; i < 1e7; ++i) t += Math.sin(Math.random());

        console.log(msg || t);
        // innerResolve(); // What I thought should have been sufficient
        setTimeout(innerResolve); // What I had to do instead
      });
    }

    resolve();
  });
}

let busy = false;

function doStart() {
  if (busy)
    return;

  const button = document.getElementById('my-button');

  button.innerText = 'Started';
  button.style.background = 'red';
  busy = true;

  (async () => {
    setTimeout(() => (button.innerText = 'Busy'), 1000);
    workWorkWork('other work');
    await workWorkWork();
    busy = false;
    button.innerText = 'Start';
    button.style.background = 'unset';
  })();
}

After the user clicked the button, I wanted to wait for one second to go by before showing a busy indicator. The busy indicator was activated using a setTimeout called just before await-ing the calculation.

To my surprise, my the UI responded almost as if the async/await was blocking code. The whole calculation finished after several seconds, and then the busy indicator appeared, way too late.

Console output demonstrates that if I start up two calls of workWorkWork simultaneously, their output is interleaved in the web console, so each execution instance is properly yielding time to the other.

But the async function code doesn't yield any time to the setTimeout that was queued up before the function was called. It doesn't even let the DOM update with changes made before the async call and before the setTimeout.

The only way to get the setTimeout callback to run in a timely manner appears to be using other setTimeout calls within my async functions.

My first encounter with this problem was inside Angular code, and I figured I might be seeing an Angular-specific problem with the Angular Zone.js craziness, but I've now replicated the same problem in plain-ol' JavaScript.

Is this the behavior I should have expected? Is this documented anywhere?

Googling various combinations an permutations of async, await, setTimeout, Promise, etc., yields a lot of material which unfortunately has nothing to do with what I've run into here.

Correct behavior after clicking Start:

Button turns red, text changes to "Started".
About one second later, text changes to "Busy"
About four more seconds later, red background disappears and text changes back to "Start".

Bad behavior after clicking Start, without extra setTimeout:

Button makes no immediate change of text or color
About five seconds later, text changes to "Busy", and gets stuck that way.

4
  • Promise resolution is a microtask, timers aren't... (Read up on the event loop and the microtask queue to learn more.) Commented Oct 26, 2022 at 0:21
  • I realize that async functions simply return Promises (it's hard to escape noticing that when you're writing in TypeScript and have to declare return types like Promise<boolean>), and I even tried a variation of this code using then() rather than await. Same results, not surprisingly. Commented Oct 26, 2022 at 0:24
  • 1
    async/await does not magically make your synchronous code asynchronous. The for loop is executed, let's say, "on the foreground" and it takes time to complete (about 350 ms on my computer). The browser is busy running the for loop, it does not do anything else until it completes. setTimeout() is the only thing that introduces asynchronous behaviour but the "async" activity of the browser in this case is to wait for the timeout to complete. Use web workers to run the time consuming calculations in the background. Commented Oct 26, 2022 at 0:37
  • @axiac, oh, I wasn't expecting magical asynchronicity. I definitely and effectively had broken the code up so that it could yield time to other code. It was only the issue (which I just learned of from this post) of microtasks having a higher priority than timer tasks that was the problem. Commented Oct 26, 2022 at 0:47

1 Answer 1

4

Fulfilled promises are served via a microtask queue which gets handled BEFORE timer events. await works off promises so it also goes before timer events.

So, if both a timer event and a promise fulfillment are both waiting to run their handlers, then the promise will get served first.

Here's some more info on the microtask queue or more specifically the PromiseJobs queue.

Sign up to request clarification or add additional context in comments.

2 Comments

Thanks. It seems the critical vocabulary I was missing in my Google searches was "microtask". Unfortunately, until now, nothing I had read or done with async/await or Promises invoked the microtask concept specifically.
@kshetline - Good articles on the overall event loop will usually include a discussion of these microtask queues too, but I can certainly see how it's hard to directly search for if you don't know it exists.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.