top of page
Writer's pictureJeB

Why does setTimeout(func, 0) work, anyway?


Hourglass sand timer

Every web developer has seen this code at least once in their life:

setTimeout(() => { /*Do some stuff*/ }, 0);

However, a lot of people take it as some magic that should be applied when things don't work as expected (race conditions, slowness etc.). More advanced developers know that it postpones the code execution to the "next tick" (or some variation of it).

Only a few would know how to explain what exactly happens there step by step.


In addition, when talking about breaking down tasks that block the main thread (aka Long Tasks), one of the common techniques is yielding to main, which usually results in code like this:

function yieldToMain() {
    return new Promise(resolve => setTimeout(resolve, 0));
}

async function someAsyncFunction() {
    doSomething();
    await yieldToMain();
    doSomethingElse();
}

Once again, this techniques relies on the same setTimeout trick, only this time it is wrapped in async code.


So what exactly happens there and how does it work? Let's dive right in!


Event Loop and Macro Tasks


We all have heard that browsers are single threaded; While this view is quite simplistic (we do have the means to execute code in parallel in a modern browser), it is truth to a degree.


A lot of major browser related stuff happens on the same thread, called the Main Thread. This includes HTML parsing, layouting, styling, painting to the screen, various events handling (like click or keyboard events), script tags execution and more.


Since executing every such bit the moment it arrives would bring havoc (think of a browser handling a click event and then pausing to execute a script that just arrived), browsers use patterns called Event Loop & Task Queue.


Every time the user clicks on a button, a script tag loads or a timeout elapses, a Macro Task (or just Task) is added to the Task Queue. This can happen at any time.

Every time the browser cycles through the Event Loop it takes the first task available on the Task Queue and executes it. After this task is finished the browser can continue to the rendering phase and then to the next Event Loop cycle.


The browser event loop can be represented (in a very simplistic manner) like this:

A diagram depicting browser event loop with task queue

Since both Rendering and Macro Tasks have to happen on the Main Thread, we can clearly see how a long executing task can block the rendering.


Let's take this code for example:

function onClick() {
    doSomething();
    doSomethingElse();
}

Since onClick is a user input event, it will enter the task queue as a task and will be executed as soon as all the tasks before it are executed.

A diagram depicting browser event loop with task queue containing onClick task in it

Now, if both doSomething and doSomethingElse take a long time to execute (say 50ms each), we are likely to see lagginess in the UI, since the Render phase is delayed by at least 100ms.


But what happens if we apply our beloved setTimeout trick here?

function onClick() {
    doSomething();
    setTimeout(doSomethingElse, 0);
}

onClick still remains a single task, but instead of executing both functions at once it will execute doSomething and then schedule doSomethingElse to execute in 0 seconds.

Such a short timeout of course elapses immediately, but as we learned previously, setTimeout callbacks are macro tasks of their own. Hence, now doSomethingElse will be added as another task to the end of the queue instead of being executed immediately and the browser is free to continue to Render phase after only 50ms.

A diagram depicting browser event loop with task queue, while onClick is executed at the moment and doSomethingElse is added as another task to the end of the queue

* Actually there is a dedicated Scheduler API that is a better fit for scheduling tasks. The only issue with it is that it's not supported in all the browsers yet. However, once it is, you should definitely use it instead of setTimeout - this makes your code much more readable. Oh, and it also supports prioritized tasks.


Now that we have a clear understanding of what's going on when we apply setTimeout(func,0), let's talk about more complicated case.


Micro tasks, Promises and async/await


One would argue that using setTimeout(func, 0) in order to break down long running JavaScript (or solve race conditions etc.) makes the code less readable and harder to debug. They wouldn't be wrong, it does feel a bit like a magic unless you've read this article and know what's going on. That's why the suggested way of dealing with long tasks is yielding to the main thread, which is much more verbose and easy to comprehend than just setTimeout:

async function someAsyncFunction() {
    doSomething();
    await yieldToMain();
    doSomethingElse();
}

There is a clear break point in the code, which has a concise name and one can easily understand what happens when, as well as add more breakpoints using the same methodology. But that's until they look at the implementation of yieldToMain:

function yieldToMain() {
    return new Promise(resolve => setTimeout(resolve, 0));
}

Yes, the name suggests that this code yields the control back to main thread, giving the browser an opportunity to re-prioritize tasks and handle user input.

But why does it work? Previously we explicitly passed doSomethingElse as a callback to setTimeout, and, combined with our knowledge of how the event loop works, we could put it all together and see why doSomethingElse is executed as a separate task.


Now doSomethingElse is just executed after a seemingly unrelated Promise which resolves in 0 seconds. What? Well, essentially it's still the same old setTimeout but in a nice packaging. In order to understand it we need to talk about two things. The first one is Micro Tasks. Unlike user input events or setTimeout callbacks (which are Macro Tasks), Promise callbacks are Micro Tasks. Similarly to macro tasks, micro tasks are stored in a queue and are executed within an event loop cycle. But that's about it when it comes to similarity. Micro tasks queue is a separate queue, which exclusively contains micro tasks. Unlike macro tasks queue, which is executed by the event loop, micro tasks queue is executed every time the JavaScript execution stack empties, but before passing control back to the event loop. Moreover, when starts executing, micro tasks queue continues its execution until it is completely empty. This is unlike macro tasks queue that executes one task per event loop cycle. To make it even more complicated: this includes micro tasks that were scheduled during a micro task that is currently being executed (yes, you can go into infinite loop with micro tasks).


Going back to our lovely diagram, it can be depicted like this:


A diagram depicting browser event loop with task  and micro tasks queue

Let's see what it has to do with yieldToMain function:

function yieldToMain() {
    return new Promise(resolve => setTimeout(resolve, 0));
}
A diagram depicting browser event loop with task  and micro tasks queue, while yieldToMain is the task being executed and resolve function is added to the end of the task queue
  • When yieldToMain is running, it creates a new Promise, which immediately calls to setTimeout.

  • As we learned before, setTimeout callback (Promise.resolve function) is put as a separate task to the end of the task queue.

A diagram depicting browser event loop with task and micro tasks queue, while resolve function is being executed and resolve callback is added to the micro tasks queue
  • When this task is executed, the Promise resolve callback is put into the Micro Tasks queue.

  • The execution stack is empty (since setTimeout callback has finished execution), hence Micro Tasks queue is executed.

  • Promise resolve callback is executed.

But that alone still doesn't explain how yieldToMain breaks down the code, there is the last piece of the puzzle that is missing. And this piece is the async/await syntax. Essentially all we need to know is that async/await is nothing else but a syntactic sugar around Promise.then syntax. In fact, every async function can be written as chain of Promises and our case is no exception.

async function someAsyncFunction() {
    doSomething();
    await yieldToMain();
    doSomethingElse();
}

This code is completely equivalent to the following Promise based code:

function someAsyncFunction() {
    doSomething();
    return yieldToMain().then(doSomethingElse)
} 

Now we can see that doSomethingElse serves as a resolve callback for a Promise returned from yieldToMain function, which, in turn, is nothing else but a Promise that is resolved from setTimeout.

Let's break it down again, now having all the information:

A diagram depicting browser event loop with task  and micro tasks queue, while doSomething and yieldToMain are executed and resolve function is added to the task queue
  • someAsyncFunction starts execution (as part of some task)

  • doSomething is executed.

  • yieldToMain is executed, creating a new Promise and scheduling a task to resolve it with setTimeout(resolve, 0).

  • doSomethingElse is not executed, since it's a resolve callback and it hasn't been invoked yet.

  • The event loop cycle can proceed to the rendering phase.

A diagram depicting browser event loop with task  and micro tasks queue, while resolve function is executed and doSomethingElse is added to the micro tasks queue
  • setTimeout callback (Promise.resolve function) is executed, putting doSomethingElse (the resolve callback) into the micro task queue.

  • The execution stack empties and the micro task queue kicks in.

  • doSomethingElse is executed.

That's all, no magic, only a clear, concise algorithm, just as we like 😁.


Finishing Words


In our rapidly moving world and especially in web development we're often tempted to skip the fundamental knowledge in favor of delivery of new features, copy pasting some working code from Stack Overflow or a similar code base. It works, so who cares, right? However, I believe that looking under the hood and understanding the fundamentals is what distinguishes a great software engineer from a good one.


I hope that this article gave you bit more understanding about magic in your code base and inspired you to ask more"why" and "how" questions, rather than "when" and "what". Thanks for reading, if you liked this article or have any questions, please comment here, send me a message or just follow me on Twitter. Cheers!

2,002 views0 comments

Recent Posts

See All

コメント


bottom of page