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.
requestAnimationFramecompletes, or something similar. Lots ofsetTimeouttasks, even if queued to run asynchronously, will still block the UI once they startsetTimeoutevents andonclickevents?