0

My Javascript app needs to do some heavy calculations on the UI thread (let's ignore web workers for the scope of this question).
Luckily, the work to be done is embarrassingly parallel and can be cut into thousands of small async functions.

What I would like to do is: Queue up all these async functions on the event loop, but prioritize UI events.
If this works, the impact on UI responsiveness should be very low. Each of the functions takes only a few milliseconds.

The problem is that Javascript eagerly executes async code. Until you run into something like await IO it simply executes all the code as if it was synchronous.

I would like to break this up: When the execution encounters an async call, it should put it on the event loop but before executing it, it should check if there's a UI event waiting to be processed.
Basically, I'm looking for a way to immediately yield from my worker functions, without returning a result.

I've experimented and found a workaround. Instead of calling the async function directly, it can be wrapped in a timeout. This is pretty much exactly what I wanted: The async call is not executed but queued up on the event loop.
Here is a simple example, based on react. After clicking, the button will update immediately, instead of waiting for all those calculations to finish.
(The example probably looks like horrible code, I'm putting heavy calculations inside the render function. But that's just to keep it short. It really doesn't matter where the async functions are kicked off, they always block the single UI thread):

// timed it, on my system it takes a few milliseconds
async function expensiveCalc(n: number) {
  for (let i = 0; i < 100000; i++) {
    let a = i * i;
  }
  console.log(n);
}

const App: React.FC = () => {
  const [id, setId] = useState("click me");

  // many inexpensive calls that add up
  for (let i = 0; i < 1000; i++) {
    setTimeout(() => expensiveCalc(i), 1);
  }

  // this needs to be pushed out as fast as possible
  return (
    <IonApp>
      <IonButton
        onClick={() => setId(v4())}>
        {id}
      </IonButton>
    </IonApp>
  );
};

Now, this looks neat. The synchronous execution of the render function is never interrupted. All the calculation functions are queued up and done after render is finished, they don't even have to be async.

What I don't understand is: This works even if you click on the button many times! My expectation would be that all calls on the event loop are treated equally. So the first time the render should be instant, the second time it needs to wait for all the timeouts to be executed first.
But what I see is:

  • click on the button -> text updated
  • immediately click on the button again -> text updated
  • look at console logs: it counts up to 1000, then starts back at 0 and counts up to 1000 again.

So the event loop does not execute in-order. And somehow UI events are prioritized. That's awesome. But how does it work? And can I prioritize events myself?

Here's a sandbox where you can play around with the example.

3
  • Breaking it up is easy enough, but I'm doubtful there's a way to "yield" out to UI / the browser without hard-coding a certain number of tasks to run after a requestAnimationFrame completes, or something similar. Lots of setTimeout tasks, even if queued to run asynchronously, will still block the UI once they start Commented May 6, 2020 at 10:24
  • that's the weird thing and the content of my question: The setTimeout tasks don't block the UI. I can click the buttons hundreds of times and still have them respond immediately. But I see how the timeouts stack up Commented May 6, 2020 at 10:36
  • So your question is about the prioritisation between setTimeout events and onclick events? Commented May 6, 2020 at 12:43

2 Answers 2

3

Bergi's answer has a good explanation on what happens (there are task prioritizations in browser's event-loop).

Note that we may in a near future1 have an API to deal with this ourselves as web-devs.

But I must note that what you are willing to do initially seems to be the exact job requestIdleCallback has been designed for: execute a callback only when nothing else happens in the event-loop.

btn.onclick = (e) => {
  for (let i = 0; i<100000; i++) {
    requestIdleCallback( () => {
      log.textContent = 'executed #' + i;
    });
  }
  console.log( 'new batch' );
}
<pre id="log"></pre>
<button id="btn">click me</button>

This way you are not relying on undefined behavior which are up to implementations, here the specs ask that behavior.



1. This API can already be tested in Chrome by switching on chrome://flags/#enable-experimental-web-platform-features

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

Comments

1

So the event loop does not execute in-order. And somehow UI events are prioritized. That's awesome. But how does it work?

The browser event loop is a complex beast. It does contain multiple "task queues" for different "task sources", and browsers are free to optimise the scheduling between them. However, each queue by itself does follow the order.

And can I prioritize events myself?

No - not without writing your own scheduler and running that inside the event loop.

1 Comment

That is, until this comes to play ;-)

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.