Asynchrony in JS: A Brief Overview

The Task Queue, the Call Stack, and the Event Loop

Anton Paras
6 min readDec 3, 2018

This article briefly covers asynchrony in JavaScript. In this article, we discuss the core components of a JavaScript runtime that orchestrate program flow, both synchronous and asynchronous. Then, we analyze a small, nuanced example of asynchrony using callbacks.

There are many, many details regarding asynchronous programming in JavaScript, so this article only aims to quickly introduce it. For more information, check out Kyle Simpson’s book, Async & Performance, one of the entries in his You Don’t Know JS series.

Asynchrony

Asynchrony is the occurrence & handling of events outside of the main program flow.

Asynchrony is almost always present in nontrivial applications. It occurs in the following scenarios:

  • waiting for a user’s input
  • requesting data from a database
  • reading from a filesystem
  • sending data across a network
  • receiving data from across a network
  • etc.

Asynchrony has been present in JS since its beginning. It’s been handled using callbacks, promises, async/await, and more. However, many developers aren’t familiar with how a JavaScript runtime handles asynchrony in the first place, or even more, how a JavaScript runtime handles program execution at all.

For the rest of this post, we will concisely discuss these topics.

The Task Queue, the Call Stack, and the Event Loop

Asynchrony in JS is mostly handled by 3 mechanisms:

  • Task Queue
  • Call Stack
  • Event Loop

Every JS runtime/host environment has these 3 mechanisms.

Task Queue
The task queue is a queue. It contains “tasks” to be processed. Each task is associated with a function.

When a task is dequeued, its function is called.

Examples of tasks would be callbacks given to

  • setTimeout
  • setInterval
  • setImmediate (Node.js only)
  • requestAnimationFrame (Web browsers only)
  • XMLHttpRequest's event listeners

Call Stack
The call stack is a stack. It contains stack frames, where each stack frame corresponds with a called function.

This is a familiar topic for many devs, including myself, so I won’t discuss it much further.

Event Loop
The event loop is a mechanism that dequeues tasks from the task queue and processes them.

We can model the event loop like so:

const taskQueue = [];while (true) {
if (taskQueue.length > 0) {
const task = taskQueue.shift();
task();
}
}

The event loop only grabs another task if the call stack is empty.

To “process” a task, the event loop calls the task’s associated function. Thus, a stack frame is created for the associated function. This stack frame is pushed onto the empty stack.

During the function’s execution, more functions may be called. Thus, stack frames will be created for those derivative functions. Those new stack frames will be pushed onto the stack.

Eventually, the first task’s function will finish. Eventually, all derivative functions will finish, too. Eventually, the call stack will be completely empty.

At this point, the event loop dequeues another task from the task queue and processes it.

To reiterate, the event loop will only process another task once the call stack is completely empty. This is to enforce run-to-completion for tasks, which we’ll discuss soon.

The task queue, call stack, and event loop are responsible for directing a program’s execution, both synchronous and asynchronous.

Host Environment/Runtime

The task queue and event loop are part of a JS hosting environment/runtime. They’re not part of a JS engine. So, you won’t find documentation on them in the ECMAScript spec.

A JS host environment/runtime is a complete system capable of executing JS programs.

A JS engine is part of a JS host environment. By itself, a JS engine is insufficient for executing JS programs.

Examples of JS host environments: web browsers, Node.js
Examples of JS engines: V8, SpiderMonkey, Rhino

People often say that “JavaScript is single-threaded”. This is mostly true. JS host environments may have multiple threads, but there is always one main thread that’s responsible for handling most business-logic level JS. Other threads may be concerned with managing the logistics of the runtime, such as another thread to manage the event loop.

Run-to-completion

A JS host environment has one main thread. As a consequence, tasks always run-to-completion.

Every task is processed completely before another task is processed.

This is different from other languages that use a multi-threaded approach to concurrency. In these languages, if a function’s running in a thread, it may be paused while another thread runs instead.

On the other hand, run-to-completion makes it easier to easier to reason about your programs, because you know that usually (there are exceptions I’d rather not discuss right now) functions will complete one-by-one. They won’t be interrupted in the middle of their work, which would otherwise cause race conditions.

There is a downside to this property. If you have a long-running task, it will occupy the main thread and prevent it from processing any other tasks. This is especially troublesome for applications with user interfaces. If a task is occupying the main thread for too long, the user won’t be able to interact with the UI, which will yield an unacceptable user experience.

Callbacks

Now that we’ve discussed the 3 main entities governing program flow and asynchrony in JS, we can discuss some techniques regarding asynchronous programming. Let’s checkout the callback.

Consider this example:

for (let i = 0; i < 5; i++) {
setTimeout(() => console.log('apple'), 0);
}
console.log('orange');

What do you think this code does? Initially, you might think that this code prints 'apple' 5 times, then 'orange' once. However, that’s not the case.

It prints 'orange' first, then prints 'apple' 5 times. This might seem odd, because you would think that setTimeout(..., x) would execute the given callback after x milliseconds. Therefore setTimeout(..., 0) would execute the callback after 0 milliseconds — AKA immediately.

However, setTimeout(..., x) doesn’t execute the callback after x milliseconds. Rather, it enqueues the callback into the task queue after x milliseconds.

Let’s analyze the entire code snippet using the 3 previously covered mechanisms: the task queue, the call stack, and the event loop.

When a snippet is executed, a task is created for it. This task’s associated function is an anonymous, main function that contains the snippet. This task is then enqueued into the task queue.

Then, the event loop consumes this task and calls the associated function. This creates a stack frame for the anonymous, main function, and pushes it onto the currently empty stack. During the function’s execution setTimeout(() => console.log('apple'), 0); executes 5 times. This adds 5 tasks to the task queue. Each task’s function is () => console.log('apple'). However, these tasks cannot be processed until the current task is finished processing (i.e. when the call stack is empty).

After pushing these 5 tasks onto the task queue, the current, initial task runs console.log('orange'). This creates a new stack frame for console.log('orange'). That stack frame is pushed onto the call stack, right above the anonymous, main function’s stack frame. When console.log('orange') executes, that causes 'orange' to be logged to the console. Then, the stack frame for console.log('orange') is popped off of the stack.

At this point, there’s nothing left for the anonymous function to do. So, its stack frame is popped off of the call stack. The call stack is empty, so the event loop grabs the next task and processes it. The next task is the first of the 5
() => console.log('apple') tasks. These 5 tasks are processed, causing 'apple' to be logged 5 times.

So, with this explanation, hopefully that code snippet makes sense now.

In a future article, we’ll cover more about asynchrony and other methods of asynchronous programming. Stay tuned!

--

--