Jamena McInteer

A Brief Overview of Async JavaScript and the Event Loop

With ES6, Async / Await has been introduced as an easy, fuss-free way to execute async JavaScript and make it look more synchronous. With so much of the concepts behind async JavaScript being abstracted away with this handy syntax, it will be easier than ever to not have to learn how async JavaScript actually works. I wanted to give an overview of these concepts that may help those new to JavaScript understand what’s going on behind the scenes.

The JavaScript engine runs in a hosting environment, such as the browser (client-side JavaScript, like React), or the server (server-side JavaScript, like Node.js), or even IoT devices and robots.

JavaScript is single-threaded, as in only one thing happens at a time. Every thing that happens must be scheduled in the Event Loop. If you have a long-running blocking process like infinite loops that don’t hand control back over to the Event Loop in a timely manner, the thread will be blocked and that’s how you get issues with the browser freezing up (Oops!). However, most JavaScript operations are non-blocking. Async calls that take a long time to resolve don’t block, as the Event Loop can continue running while waiting for a request to return a response.

In the case of a browser, every tab has its own thread and Event Loop, and Web Workers also get their own Event Loop. But for the purposes of this discussion, you can think of there being one thread and one Event Loop for your code, as your code doesn’t really care about what’s happening in the other browser tabs.

Whenever an event is scheduled, it is placed in the event queue. You can think of the event queue as a To Do list, but where every item is executed in the order it appears. When a new event is scheduled, such as a function being called, that event is added to the end of the list and waits for the all the other events to be executed before it has its turn.

The running of the Event Loop as it executes events in the queue in order

Whenever there are events to run, the event loop runs until the queue is empty. Each iteration of the event loop is a tick.

You Don’t Know JS: Async & Performance by Kyle Simpson

setTimeout(…)

How does setTimeout(…) work? A common mistake is to think that setTimeout(…) is added to the event queue immediately but just waits to execute, or that the everything inside setTimeout will execute as soon as the time delay has passed.

For example, let’s take the following:

console.log('A');
setTimeout(function() {
    console.log('B');
}, 1000);
console.log('C');

How would the event queue look conceptually, and how does the Event Loop execute this? If you try the above code, you should see the following in the console:

A
C
B

Conceptually, here is how it happens: console.log(‘A’) is added to the event queue first, since it comes first. Then, the hosting environment (such as the browser), schedules a 1 second timer. Nothing goes into the event queue yet. console.log(‘C’) is added to the event queue. After 1 second from when the timer was scheduled, console.log(‘B’) is added to the event queue.

Meanwhile, the event loop executes the events in the queue one at a time: A > C > B. Note, however, that the setTimeout(…) timer schedules when the event should be added to the queue, not when the event should be executed. So if there are a lot of events between when the timer is scheduled and when the event is added at the end of the list, console.log(‘B’) will just have to wait until all the events in front of it are executed; console.log(‘B’) won’t happen in exactly 1 second.

The event queue when there is a 1 second timer

You may have seen developers use the following setTimeout(…) and wondered why:

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

This forces doSomething() to move to the end of the event loop. Because the timer delay is 0, doSomething() is added to the end of the event queue right away but it is still pushed to the end of the list because of the nature of the event loop.

Run-to-Completion and Race Conditions

Another thing to know about the event loop, is that once a function starts executing, it will run-to-completion before the next function starts executing. A function cannot interrupt the running of another function.

var x = 1;
function doSomething() {
  x + 3;
});
function doSomethingElse() {
  x * 5;
});
doSomething();
doSomethingElse();
console.log(x); // 20

In the above code, doSomething() will be executed and will complete before doSomething() executes and completes. What if we were to switch which one gets executed first?

var x = 1;
function doSomething() {
  x + 3;
});
function doSomethingElse() {
  x * 5;
});
doSomethingElse();
doSomething();
console.log(x); // 8

Now, what if the execution of these functions depend on an async function? For example, let’s say we use the Fetch API to get some data from an external service, and after it completes, we wish to execute one of the functions. We’ll use the callback method of making async requests for this example.

var x = 1;
function doSomething() {
  x + 3;
});
function doSomethingElse() {
  x * 5;
});
fetch('./someApi.json', doSomething());
fetch('./someApi2.json', doSomethingElse());
console.log(x); // 8 or 20, depending on which async request finishes first

In this case, doSomething() or doSomethingElse() may be executed first, depending on which async request finishes first. Whichever request finishes first will have its call to one of the functions added to the event loop first, and then that function will execute and run to completion before the other function will have a chance to execute. The two async requests are “racing” against each other, so this is called a race condition. Race conditions lead to unpredictable results.

Async Functions and Promises

Async functions are those that happen later, instead of now.

setTimeout(…) is an example of an async function: it is scheduled now, but executed later. Fetch calls to an API are async: the request is made now, but the event loop continues to run until we get something back from the API, and then some work is added to the end of the event queue.

Promises are a “promise” to return a future value. They are executed now, and then when they resolve, we execute the code we want with the value that the Promise returned. Async / await is a beautiful syntax for making promises easier to work with, but it’s still good to understand how Promises work, and there may be cases where you would still want to use Promise syntax. Here is an example of Promise syntax:

const someAsyncFunction = async () => {
  fetch('./someApi.json')
    .then((response) {
      // All code here runs asynchronously
      console.log(response.data); // When the first Promise resolves
      // If we want to chain two Promises, we can chain with .then(...) or use Promise.all(...)
      fetch('./someApi2.json')
        .then((response2) {
          console.log(response2.data); // After the second Promise resolves
          doSomething(); // This completes after both Promises resolve
        }).catch((error){...});
    })
    .catch((error) {
      console.log(error);
    })
    .finally(() {
      doSomethingEitherWay();
    });
  console.log('Code here runs synchronously');
}
someAsyncFunction();

In general, Promise.resolve maps to .then() and Promise.reject maps to .catch(); .finally() happens regardless of what comes back from the resolution or rejection of a Promise. There is a lot more to Promises, but this is a really quick summary of how Promises are commonly used.

Async functions return a Promise, while await blocks the code execution within the async function, so code after the await statement doesn’t run until the Promise resolves (but it only blocks the code within the async function, not all code). Try / catch is used for error handling, and instead of chaining multiple promises with .then(..) or using Promise.all(…), multiple await statements can live within a single async function, but each await is for a single Promise. Here is an example of async / await syntax:

const someAsyncFunction = async () => {
  // All code here runs asynchronously
  try {
    const response = await fetch('./someApi.json');
    const response2 = await fetch('./someApi2.json');
    // After both of the above complete, we can do something with the responses
    console.log(response.data);
    console.log(response2.data);
    doSomething();
  }
  catch (error) {
    console.log(error);
  }
  finally {
    doSomethingEitherWay();
  }
}
console.log('Code out here runs synchronously');
someAsyncFunction();

An example of where synchronous / asynchronous differences can trip people up is in for loops. Let’s say you have the following:

const newArray = [];
for (let i=0; i<someArray.length; i++) {
  (async function someAsyncFunction() { // a self-executing function called and IIFE
    const response = await fetch('./someApi.json');
    someArray[i].someProperty = response.data;
  })();
  for (let j=0; i<someArray[i].someProperty.length; j++) {
    const newArray.push({
      property1: someArray[i].someProperty[j].someGrandchildProperty,
      property2: someArray[i].someOtherProperty
    });
  }
}
console.log(newArray);

What is the above code going to print to the console and why? We have a self-executing function someAsyncFunction() (this is called an IIFE) that is async, and then a nested for loop within our first for loop. The async function will fetch some JSON, and then when it resolves with some data, the code after the await statement will be added to the event queue and run by the event loop when it gets to it.

However, the second for loop runs synchronously since it is outside of the async function. It will start executing immediately, and then at some point the response from the API will return and the code within the async function will run. Depending on whether the second for loop has finished, the execution of the code in the async function after await could happen before the second for loop is finished. This will lead to unpredictable results, and certainly not what we want from this code. If the async function takes longer to resolve than the second for loop, we may get something like this in our console:

[
  { property1: null, property2: 'someOtherProperty value' },
  { property1: null, property2: 'someOtherProperty value' },
  { property1: null, property2: 'someOtherProperty value' },
  ...
]

We’ll want to re-write this to move everything into the async function:

const newArray = [];
(async function someAsyncFunction() {
  for (let i=0; i<someArray.length; i++) {
    const response = await fetch('./someApi.json');
    someArray[i].someProperty = response.data;
    for (let j=0; i<someArray[i].someProperty.length; j++) {
      const newArray.push({
        property1: someArray[i].someProperty[j].someGrandchildProperty,
        property2: someArray[i].someOtherProperty
      });
    }
  }
  console.log(newArray);
})();

Now the second for-loop won’t run until the await statement before it has resolved. The output should look something like this:

[
  { property1: 'someGrandchildProperty value', property2: 'someOtherProperty value' },
  { property1: 'someGrandchildProperty value', property2: 'someOtherProperty value' },
  { property1: 'someGrandchildProperty value', property2: 'someOtherProperty value' },
  ...
]

Conclusion

This is a quick overview of asynchronicity in JavaScript and how the Event Loop works. I highly recommend You Don’t Know JS: Async & Performance by Kyle Simpson if you want to deep dive into these concepts. Asynchronicity and the Event Loop in JavaScript is not always an easy thing to grasp, but hopefully this overview helps someone.

Share

Think others might enjoy this post? Share it!