JavaScript Event Loop

JavaScript is a generally single threaded programming language. It is possible to achieve some level of concurrency with working threads. But anyway, to run them whole new runtime will be risen. This way any long processing will block execution of whole program. But some long running tasks do not require CPU computation. In their count for example IO operations and timeouts or handling events. While waiting for a database response, program could continue some of it's processes.

Event loop in JS allows to perform such operations asynchronously. In simple words program can start such long running task, which does not require CPU computation give it to the environment and immediately continue to work on further instructions not waiting for completion of this task. Environment takes care of completing task and once it happened return the result to main program through event loop. Tasks taken by environment are executed in separate threads, but outside of main program runtime. This way program single threaded runtime called main thread.

JS environment may differ from the context of execution. It may be a browser api for the frontend or libuv in node.js.

Basically event loop is an infinite loop, which waits until main thread stack is empty, then trying to take a task from the task queue. It takes oldest task, places it into the main thread stack and the process loops. If there is no tasks in the queue, event loop simply waits until they appear. Any user or system event ocurred and created new task in the queue.

While waiting for tasks to appear, event loop is very efficient in terms of CPU usage.

How It Works In Details

Simplest example:

console.log('1');

setTimeout(() => {
    console.log('2');
}, 0);

console.log('3');

Here console.log('1'); is a synchronous function call, which will be executed immediately and print 1 into the console. Next instruction setTimeout is asynchronous function call. It will create a task in the environment, which should wait for a given period of time(in this case 0 seconds) and run code inside of a given callback function.

As setTimeout is an async function, it's only job is to create a task in the JS environment. Our program will do only this on it's way and will go to the next instruction.

And it does not matter that, timeout is 0, the way is always the same - create task and go further.

Next instruction is console.log('3');, which is also synchronous and will print 3 to the console.

Now we have following output in our console:

1
3

But program didn't finish it's execution. We still have task, created by setTimeout. It is running in parallel to the main program inside of JS environment. And while timeout was 0 it is already completed. But to print 2 to the console it should go through couple of steps in event loop.

Event loop is kind of a FIFO queue. When async task is finished, environment places result callback to the end of the event loop. When current call stack is empty, our program checks, if something appeared in the event loop. If it is not empty first or in another words oldest item will be taken and placed to the call stack.

On this step, when two console.log instructions have already been executed and printed 1 and 3, call stack of our program is empty. So it checks if there is something in the event loop. And luckily there is a task to call function:

() => {
    console.log('2');
}

Which was provided to setTimeout when main program executed it and then, after async task has been completed placed to the event loop by the environment. Now main program moves it to the stack and executes console.log('2'); instruction, which will print 2 to the console.

Events

When some events happening, they don't go directly to call stack. Same as with async tasks all events go through event loop queue. For example, if onclick event risen after button click it is queued in event loop. Which means that it will be handled only after current call stack will be cleared. This may be a reason of delays of event handling if we are blocking main thread.

Browser Rendering

Browser renders displaying picture also as a part of an event loop.

Same as for events applies to rendering. There are several steps browser makes to render a picture on a display. But these steps are also a part of the event loop. So if we run blocking code in the stack, rendering also could stuck.

Rendering steps are:

  • style - calculate colors, sizes, fonts and other css and assign them to elements
  • layout - apply position to all elements
  • paint - using graphics library, create precise image

Result of these steps is Frame - array of pixels, which will be directly displayed on the user screen. If the DOM has been changed, browser has to execute all 3 steps and regenerate Frame.

But while rendering steps are part of event loop, they have higher priority than other tasks. Event if event queue already contains some scheduled events, rendering steps will be performed before them.

Ideally when there is no blocking tasks, render steps are executed 60 times per second.

Rendering With requestAnimationFrame

If you need to make a changes related to rendering to the display it there is a special method for this - requestAnimationFrame. It is called immediately before rendering steps. The reason, why it is better to use this method, instead of queuing a task - it is predictable. It runs regularly with target to create 60 frames per second. Task can be executed in event loop in any time causing uncontrollable behavior of a picture. Tasks may cause rerendering with a random frame rate.

For example, using setTimeout for animation we may schedule several update per single frame, which is a waste of a computational power. Moreover, as this is not designed for animation event if we trying to schedule tasks to recalculate picture once per frame, there still could be some misses, e.g. garbage collection started and such task will not hit necessary frame, but on the next frame we will have two calculations.

Microtasks

The main difference of microtask from regular task is in queue consumption. While main task queue executes task one by one and allows page rendering stages between them, microtask queue when started execution, blocks the main thread until all microtask queue will not be empty. This is useful, when we need to schedule next task and ensure it will be handled before next rerender phase.

Promises internally implemented using microtasks.

setTimeout(() => alert("1"));
Promise.resolve().then(() => alert("2"));
console.log("3");

this example will print

3
2
1

to the console. This is happening because setTimeout schedules regular task and Promise creates a microtask, which has priority over macrotasks.

If we need to ensure, that application state will not be changed after we schedule a task, we can use queueMicrotask function. It will create a new task in microtask queue, which will have a priority over all other tasks, such as event listeners and macrotasks.

Performance Tips

When there is a need to perform some heavy calculation, which may potentially block the main thread it may be a good idea to split it in time. For example using setTimeout with 0 delay. This will reschedule some part of heavy code into the event loop task queue, which will introduce possibility for rerendering the content of the page.

Such separation does not make a big difference in overall heavy calculation time, but increases page responsiveness.

Another option is separating heavy calculation to the working thread. Such a way we can introduce some kind of multithreading. Our main thread will execute only light tasks and rendering, while blocking calculation in working thread will not affect page responsiveness.

References