Asynchronous JavaScript

Frank Stepanski
11 min readOct 8, 2022

Waiting is not a bad thing.

JavaScript is synchronous in nature. This means JavaScript runs code in the sequence they’re written.

Asynchronous code will not be executed immediately. They will only be executed after a certain event occurs.

The common definition usually requires sending a request to a server and waiting for a response to come back. When Asynchronous Javascript was first talked about they referred to it as AJAX.

This was because the responses would come back in an XML form (due to the SOAP protocol). The term AJAX is outdated and inaccurate since we don’t use XML anymore, but that doesn’t prevent people from misusing the term.

Fetching Resources

There are two ways to natively fetch resources.

  1. Using XMLHTTPRequest (XHR for short)
  2. Using the Fetch API

Both XHR and Fetch are well-supported on all browsers.

Most developers prefer to use Fetch over XHR because it uses promises. But this difference doesn’t matter in practice because most of the time developers will use libraries to simplify both Fetch and XHR, and these libraries use Promises anyway.

Note: You will mainly see XHR in existing codebases that have been in production for a long time. It’s quite ancient now.

XHR

const request = new XMLHttpRequest()

request.open('GET', 'https://link-to-resource')

request.send()

request.addEventListener('load', event => {
// do something with response
})

Data that comes back from the response can be found within the event target. If you console.log the event target, you will find a response property. This response property contains the data. The data is also called the payload.

Fetch API

You can send a Fetch request by writing the fetch method. The response comes back in a then method. It looks like this:

fetch('https://link-to-resource').then(response => {
console.log(response)
})

Similar to XHR, You need to treat the response before you can get the payload. In this case, the payload is hidden inside the body property.

In this case, the response also contains JSON. You can convert the response into JSON with response.json. Then you get the payload in the next then method.

fetch('https://api.github.com/users/frankstepanski/repos')
.then(response => response.json())
.then(payload => {
console.log(payload)
})

Commonly used data types

Both XHR and Fetch support many types of data (including but not limited to): array buffers, blobs, JSON, and text. The most important data types to know and understand are Text and JSON.

Most libraries convert the response into valid JavaScript for you, so you don’t have to remember the different types of responses at this point. You only need to know the difference between other response types when you reach a much higher level.

Text

Text data is simply a string of text. This data is normally in an HTML or XML string.

JSON

JSON stands for JavaScript Object Notation. It is similar to a JavaScript object. The difference is:

  1. JSON properties and values must be wrapped with double quotes (like "this").
  2. JSON values must be strings.
  3. You cannot have comments within JSON files.

You can convert a JavaScript object to JSON with JSON.stringify(). You can convert a JSON value back into a JavaScript object with JSON.parse.

Understanding callbacks

Callbacks are used to support both XHR and Fetch. Callbacks are functions that are passed into another function as an argument.

function sayMessage () {
console.log('Hello World')
}

function runProgram (programToRun) {
programToRun()
}

runProgram(sayMessage) // 'Hello World'

In this case, sayMessage is the callback because it is passed into runProgram.

One important thing about callbacks is we don’t invoke the callback when passing it as an argument to another function.

runProgram(sayMessage) // Do this 
runProgram(sayMessage()) // Don't do this

Callbacks in the wild

Some common uses of callbacks include:

  • addEventListener
  • setTimeout
  • The function that is given to a forEach loop
// Examples of callbacks in the wild
button.addEventListener('click', callback)
setTimeout(callback, 1000)
array.forEach(callback)

Callbacks can be anonymous functions

Callbacks don’t have to be named functions. You can create a callback function and pass it as an argument at the same time. The most common example would be an event listener.

// An anonymous callback function being used in addEventListener
button.addEventListener('click', event => {
console.log(event)
})

Most callbacks are synchronous

Callbacks are NOT asynchronous.

They simply allow the possibility of Asynchronous JavaScript to happen.

Most callbacks are synchronous (like the ones used in forEach). Only special functions like addEventListener and setTimeout make callbacks asynchronous.

So addEventListener and setTimeout don’t take in asynchronous callbacks. They make use of the callback in an asynchronous way. But people often call them asynchronous callbacks even though it’s an inaccurate term.

We cannot make asynchronous callbacks ourselves. They have already been built into the language (or the Web APIs). Here’s a list of methods that accept asynchronous callbacks:

  1. addEventListener
  2. setTimeout
  3. setInterval
  4. XMLHttpRequest
  5. IntersectionObserver

Understanding Promises

There is a phenomenon called “Callback Hell” where layers of callbacks are nested within each other. This happens when you need to perform a series of asynchronous requests that depend on the data from a previous request.

XHR Callback Hell: 😈

// Pseudo code:
const request = new XMLHttpRequest()
request.open('GET', 'https://link-to-resource')
request.send()

request.addEventListener('load', event => {
const data = JSON.parse(event.target.response)

// Needing to send a second request
const request2 = new XMLHttpRequest()
request2.open('GET', `https://${data.url}` )
request2.send()

request2.addEventListener('load', event2 => {
const data2 = JSON.parse(event2.target.response)

// Send a third request if needed…
}
})
request()

Promises make things much simpler in a then call: 😇

// Pseudo code
fetch('https://link-to-resource')
.then(response => response.json())
.then(data => {
return fetch(https://${data.url})
.then(response => response.json())
})
.then(data2 => {
// Do something with data2
})

The Promise Concept

A JavaScript Promise is an if-else statement for the future. We don’t know whether the statement will flow into if or else until the future arrives. So promises have three states:

  • Pending
  • Resolved (or fulfilled)
  • Rejected

Then and Catch

All promises have the then and catch methods.

  • We act on resolved promises in then.
  • We can act on rejected promises in catch.
youBuyCake().then(cake => eatCake)

But what if you didn’t turn up with the cake? We need a backup plan. In this case, say we choose to go and buy a cake together.

youBuyCake()
.then(cake => eatCake)
.catch(error => goAndBuyCake) // Promise rejected.

Chained then statements

If you return a value from a then call, you can use that value in the next then call. response.json in the Fetch API uses this feature.

fetch('https://link-to-resource')
.then(response => response.json())
.then(data => {/* do something with data */}

One catch at the end

If an error occurs in a promise chain, the error will be passed directly into the next catch call. It will skip any then calls along the way.

This means we can handle all errors with a single catch call.

promise
.then(face => new Error('🙁')) // Throws an error
.then(face => console.log(face)) // Skips this line
.catch(face => console.log(face)) // Jumps to this line. 🙁

The finally method

All promises have a finally method. This method will be called after all then and catch calls.

promise
.then(/*…*/)
.catch(/*…*/)
.finally(/*…*/)

Creating a promise

Promises can be created with a Promise constructor. Each Promise constructor contains a callback with two arguments — resolve and reject.

const promise = new Promise((resolve, reject) => {
// Do something with resolve and reject
})

We need to determine how to resolve or reject a promise. The easiest way is to create anif/else condition inside the promise.

  • If the condition returns true, we resolve the promise.
  • If the condition returns false, we reject the promise.
const promise = new Promise((resolve, reject) => {
if (condition) {
resolve()
} else {
reject()
}
})

Both resolve and reject take in a single argument. This argument goes into the then or catch call.

  • Values passed into resolve goes into the next then call.
  • Values passed into reject goes into the next catch call.
const promise = new Promise((resolve, reject) => {
if (condition) return resolve('😄')
reject('🙁')
})

promise
.then(face => console.log(face)) // 😄
.catch(face => console.log(face)) // 🙁

Understanding the Event Loop

We need to understand the Event Loop to understand how asynchronous callbacks work.

Let’s begin by understanding the four layers in the Event Loop:

  • The Call Stack
  • Web and Node APIs
  • The Task Queue (or Macrotask Queue)
  • The Microtask Queue

The Call Stack

The Call Stack is where we run JavaScript code. Each part that needs to be evaluated goes into the call stack when it’s read by JavaScript. When the evaluation is complete, it is removed from the Call stack.

console.log('one')
console.log('two')

Here’s what happens in this simple example:

  1. console.log('one') will be added to the call stack.
  2. JavaScript will run console.log('one') immediately.
  3. console.log('one') is removed from the call stack since it is complete now.
  4. Repeat the three steps above for console.log('two')

The console.log statement shows up at the bottom of the call stack because the Call Stack works in a LIFO (Last-in, First-out) way.

Web and Node APIs

Functions that provide you with asynchronous behavior are normally not built into JavaScript directly. They’re built into browsers or Node instead. Depending on where you use them, you call them Web or Node APIs respectively.

  • In browsers: Web API
  • In Node: Node API

setTimeout is an example of such a function. Let’s see how setTimeout interacts with the call stack.

setTimeout(callback, 3000)

Here’s what will happen:

  1. setTimeout appears in the call stack
  2. setTimeout is removed from the call stack since it’s done
  3. 3 seconds later, the callback appears in the call stack.

Functions like addEventListener and setTimeout rely on the browser’s environment to keep track of events and timers. We don’t know how they work because we can’t access the browser’s internals. (Ditto for Node if you’re in a Node environment).

One possible theory is the browser spins up multiple threads — one for each asynchronous action — to keep track of the events going on. I’m not sure whether this is the truth because I haven’t found evidence to prove or disprove this hypothesis.

The best thing we can do is treat the Web (or Node) APIs like black boxes that magically know when it is appropriate to call the callback.

Task Queue and Microtask Queue

JavaScript has a Task queue and a Microtask Queue.

These queues are similar to an actual queue in real life. They use a FIFO (First-in, First-out) structure, which means the callback that comes into the queue first will be handled first.

When an API detects it should fire a callback, the callback gets added to the respective queues. When the call stack is empty, JavaScript will check the queues and invoke the callback.

Tasks are scheduled so the browser can get from its internals into JavaScript/DOM land and ensure these actions happen sequentially. Between tasks, the browser may render updates. Getting from a mouse click to an event callback requires scheduling a task, as does parsing HTML, and in the above example, setTimeout.

Microtasks are usually scheduled for things that should happen straight after the currently executing script, such as reacting to a batch of actions or making something async without taking the penalty of a whole new task.

Again, this is somewhat of a “black box” of exactly what is stored on these queues but we can estimate based on the code we write and the order it is executed.

Async / Await

The purpose of async / await is to make complicated asynchronous code easier to understand. It does this by making asynchronous code look synchronous.

You declare asynchronous functions with an async keyword. The async keyword must be placed before the function keyword.

async function functionName (arguments) {
// Do something asynchronous
}

You can also create asynchronous functions with the arrow function syntax. In this case, you write async before the arguments.

const functionName = async (arguments) => {
// Do something asynchronous
}

Using asynchronous functions

You call asynchronous functions the same way you call normal functions.

// Calling an async function
anAsyncFunction()

Asynchronous functions use promise underneath the hood so they always return a promise.

async function getOne () {
return 1
}

const promise = getOne()
console.log(promise) // Promise

Usually our asynchronous functions are returning a promise from a fetch.

Waiting for promises with the await keyword

The await keyword is where the magic happens. It lets you wait for a promise to resolve (or reject) before proceeding to the next line in the code.

The await keyword must be written inside an asynchronous function.

async function getSomething () {
await fetch('https://resource-link')
}

You can use a try / catch block to check whether the promise resolvesor reject. This is very similar to then and catch calls within a promise.

async function getSomething () {
try {
await fetch('https://resource-link')
// Promise resolves.
} catch (error) {
// Promise rejects.
}
}

You don’t have to nest all await calls inside a try / catch block. Most of the time, we simply write the await call in the async function. When we call the async function, we can attach a catch block to handle all errors.

async function getSomething () {
const response = await fetch('https://resource-link')
const data = await response.json()
// Do something with data
}

getSomething()
.catch(error => { /* Handle the error */})

No need to return await

There is no need to return an awaited value inside an asynchronous function. It’s an extra step since the awaited value will be converted back into a promise again.

Just return the value.

async function getSomething () {
const response = await fetch('https://resource-link')

// Don't do this
// return await response.json()

// Do this
return response.json()
}

Using Libraries

Developers don’t normally use raw XHR or Fetch requests in projects because project requirements are usually more complex.

For example, if you are unsure whether the response is going to return a Text or JSON response, you cannot simply use response.json. You need to check the response type.

There are also many other factors to consider that make a raw XHR or Fetch request much harder to use in practice.

Popular libraries to abstract over the native APIs are Axios and ky.

Axios is based on XMLHttpRequests, while ky is based on Fetch.

// Axios
const response = await axios('https://api.github.com/users/frankstepanski/repos')
const repos = response.body
console.log(repos)

// Fetch
const response = await fetch('https://api.github.com/users/frankstepanski/repos')
const repos = await response.json()
console.log(repos)

Best Practices

There is only one mandate when it comes to asynchronous JavaScript — to reduce the wait time as much as possible.

This mandate creates two best practices:

  1. Consolidate requests when you can
  2. Optimistic UI

Consolidate requests when you can

When you send a request, you need to wait for a response from the server. How fast the response comes back depends on your internet speed and how far the server is away from you. Most of the time you will get a noticeable lag time between a request and a response.

This means you should always batch requests together whenever possible.

How to bath requests

If you send one request after another using await, you will notice a lag time to get both responses.

const one = await request('one')
console.log(one)

const two = await request('two')
console.log(two)

It gets faster if we batch the requests with Promise.all.

Promise.all takes in an array of promises. When all promises are fulfilled, you handle the next steps in a then call. The resolved values from these promises are passed into the then call as an array.

await Promise.all([request('one'), request('two')])
.then(values => {
const [valueOne, valueTwo] = values
console.log(valueOne)
console.log(valueTwo)
})
.catch(error => {
/* Handle error */
});

Any error from any promise within Promise.all will send the code into a catch call. You can then handle the error like a normal promise.

const one = Promise.all([promise1, promise2])
.catch(error => {
/* Handle error */
})

Optimistic UI

A common UI pattern when sending asynchronous requests:

  1. Send a request
  2. Wait for a response
  3. Update the UI

These steps are useful in the initial request since you need to fetch resources before showing users the interface.

But if you do the same steps in later requests, you may force the user to wait for you to save the data. This can be frustrating from a UX point of view (depending on the wait time), so an alternative pattern emerged.

Optimistic UI assumes that the request will be successful, which means you can update the interface immediately. This can be ok because requests are generally going to be successful. 🙏

If the request fails, you revert the UI back to the original state before the changes were made.

This pattern may not always be appropriate or even possible but it is something to consider when the UX is of primary concern.

--

--

Frank Stepanski
Frank Stepanski

Written by Frank Stepanski

Engineer, instructor, mentor, amateur photographer, curious traveler, timid runner, and occasional race car driver.

No responses yet