DEV Community

Anatoly Tarnavsky
Anatoly Tarnavsky

Posted on

Mastering Asynchrony in the Browser: A Comprehensive Guide

Acknowledgements

This article was originally written in Russian by Grigory Biziukin and was published on Полное понимание асинхронности в браузере
on 28/02/2023. I would like to thank Grigory Biziukin for creating such informative and insightful content.
I have made every effort to accurately translate the content while maintaining the author's original message and intent. However, any errors in translation are solely my own.
I hope that my translation will allow a wider audience to access and appreciate this valuable content, and I encourage readers to visit the original publication or website to read more of Grigory Biziukin's work.

There are numerous articles, documents, and books written about the asynchrony of JavaScript. However, the information is widely dispersed throughout the internet, making it difficult to quickly and fully understand the various aspects and form a complete picture in your mind. What is lacking is a comprehensive guide. This is precisely the need that I want to address with my article.

Table of Contents

  1. Event Loop
    1. Tasks, Ticks, and Web API
    2. Task Queue
    3. 16.6 milliseconds per task
    4. Handling large tasks
    5. Microtasks
    6. requestAnimationFrame
    7. requestIdleCallback
    8. Comparison of queues
    9. Event loop in Node.js
  2. Callback Functions
    1. Callback Hell
    2. Don't Release Zalgo
    3. Tight Coupling
    4. The Trust Issue
  3. Promises
    1. Promise chains and error propagation
    2. Implicit behavior
      1. Returning a new promise
      2. Hidden try/catch
    3. Thenable objects
    4. Static methods
      1. Promise.all
      2. Promise.race
      3. Promise.any
      4. Promise.allSettled
    5. Promisification
    6. Promises or callback functions?
    7. Coroutines
  4. Async/await
    1. Top-level await and asynchronous modules
    2. Error handling
    3. Not all await is equally useful
  5. Conclusion

    Event Loop

    To run a website, the browser allocates a single thread that must simultaneously
    perform two important tasks: execute code and update the interface. However,
    a single thread can only perform one action at a time. Therefore, the thread performs
    these tasks sequentially to create the illusion of parallel execution.
    This is the event loop.

Event loop, executing code and updating the user interface

The call stack is where code execution takes place. When a function calls another function, its own execution is paused until the called function completes, forming a call stack. Once all the operations in the stack are executed and it becomes empty, the event loop can either add more code to the stack for execution or update the user interface.

Call stack

The browser engine is responsible for updating the user interface. This process usually consists of four steps: style, layout (reflow), paint, and composite. During the style step, the browser recalculates the style changes caused by JavaScript operations and calculates media queries. Layout recalculates the page's geometry, which involves computing layers, calculating the mutual arrangement of elements, and their mutual influence. During the paint step, the engine renders the elements and applies styles that only affect their appearance, such as color, background, etc. Composite applies the remaining specific styles, usually transformations that occur in a separate layer.

To optimize a web page, it can be helpful to understand when the browser performs or skips certain operations. The browser may skip unnecessary operations to improve performance. By understanding when the browser skips or executes specific steps, you can optimize your web page accordingly.

Updating the interface: style, layout (reflow), paint, composite

The first operation in the event loop can be either updating the interface or executing code. If a website uses a synchronous script tag, the engine will most likely execute it before rendering the first user interface frame. However, if we load scripts asynchronously using async or defer, there’s a high probability that the browser will render the user interface before loading JavaScript.

The asynchronous script loading option is more preferable because the initial bundle is usually quite large. Until it is fully executed, the user will see a white screen because the event loop will not be able to render the user interface. Even with asynchronous loading, it is recommended to split JavaScript code into separate bundles and load only the essentials first, because the event loop is very sequential: it fully executes all the code in the call stack and only then moves on to updating the interface. If there is too much code in the call stack, the interface will be updated with a significant delay. The user will have the impression that the site is lagging. If an infinite loop is written, the browser will keep executing the code over and over, and the interface update will never happen, so the page will simply freeze and stop responding to user actions.

The call stack will execute both the developer-written code and the default built-in code responsible for interacting with the page. Thanks to the built-in code, scrolling, selection, animations, and other features work, for which JavaScript might seem unnecessary. The call stack will execute built-in scripts even when JavaScript is disabled in the browser. For example, you can open an empty about:blank page without JavaScript, perform a few clicks, and see that the call stack has executed the code responsible for event handling.

The event loop always has word to do, even when a site written without JavaScript

Tasks, Ticks, and Web API

A task in JavaScript refers to a piece of code that is executed within the call stack. A tick, on the other hand, represents a single iteration of the event loop, during which tasks in the call stack are processed. Web API refers to properties and methods in the global Window object.

Web API methods can work either synchronously or asynchronously: the former will execute in the current tick, while the latter will execute in one of the following ticks.

A good example of synchronous calls is the DOM manipulation:

const button = document.createElement('button'); 
document.body.appendChild(button);
button.textContent = 'OK';
Enter fullscreen mode Exit fullscreen mode

Creating an element, inserting it into the DOM, and setting properties are synchronous operations that execute in the current tick. Therefore, it doesn’t matter when we set the text for the button - before inserting it into the DOM or after. The browser will update the interface only after completing all synchronous operations, so the user will immediately see the up-to-date interface state.

When we write asynchronous code, we ensure that the task will be executed in the next tick. It can start either before or after the interface is updated. For example, when the event loop needs to perform the next task, it can either execute it immediately after the previous one or update the interface first and then execute the next task. This distinction is not particularly important for developers. It’s essential to understand that an asynchronous task will be executed at some point in the future.

A good example of an asynchronous call is requesting data from a server. A callback function describes a task that will be executed at some point in the future after receiving the data:

fetch('/url').then((response) => { 
    // will be executed at some point in the future 
})
Enter fullscreen mode Exit fullscreen mode

The browser subsystem responsible for network operations will execute the request in a separate thread, working independently. While the request is being processed in the background, the event loop can update the interface and execute code. Once the data has been successfully loaded, the task we described through the callback function will be ready to execute in one of the following ticks of our main task cycle.

There may be multiple tasks ready for execution after asynchronous calls. Therefore, a special queue exists to transfer them to the call stack for execution.

Task Queue

Tasks enter the queue through the asynchronous browser API. First, an asynchronous operation is performed somewhere in a separate thread, and after its completion, a task ready for execution is added to the call stack.

Task queue

Understanding this concept, one can examine a peculiarity of timers in JavaScript, which are also part of the asynchronous API.

setTimeout(() => { 
    // enqueue a task after 1000 ms 
}, 1000)
Enter fullscreen mode Exit fullscreen mode

When we start a timer, the engine begins counting down in a separate thread, and upon readiness, the task is added to the queue. One might think that the timer will execute after one second, but in reality, this is not the case. In the best-case scenario, it will be added to the task queue after one second, and the code will only be executed after the queue reaches it.

The same principle applies to event handlers. Each time we register an event handler, we attach a task to it, which will be added to the queue after the event occurs:

document.body.addEventListener('click', () => { 
    // enqueue a task when the event occurs 
})
Enter fullscreen mode Exit fullscreen mode

16.6 milliseconds per task

To make websites fast and responsive, the browser needs to create the illusion that it is simultaneously executing user code and updating the interface. However, since the event loop operates strictly sequentially, the browser has to quickly switch between tasks so the user doesn’t notice anything.

Typically, monitors refresh the image at a rate of 60 frames per second, so the event loop tries to execute code and update the interface at the same speed, which means a task takes 16.6 milliseconds to complete. If our code runs faster, the browser will simply update the display more frequently. But if the code runs slowly, the frame rate will start to decrease, and the user will feel like the website is lagging.

For most scenarios, 16.6 milliseconds is quite sufficient. However, sometimes heavy computations are required on the client side, which may take much more time. There are special techniques for such cases.

Handling large tasks

There are two ways to optimize large tasks: either by breaking them down into subtasks and executing them in different ticks, or by moving the computation to a separate thread.

To execute something in the next tick, for example, you can use setTimeout with a minimal delay.

function longTask() {
    toDoSomethingFirst()

    setTimeout(() => {
        toDoSomethingLater()
    }, 0)
}
Enter fullscreen mode Exit fullscreen mode

There is also an experimental function called postTask, which is part of the Scheduling API and currently only available in Chrome and Edge. It allows you not only to execute tasks asynchronously to unload the main thread but also to set priorities for them. You can read more about this in Jeremy Wagner’s article “Optimize long tasks”.

To launch a separate thread, you can use the Web Worker API:

const worker = new Worker('worker.js')

worker.addEventListener('message', () => {
    // receive data
})

// send data
worker.postMessage('any data')
Enter fullscreen mode Exit fullscreen mode

A separate thread is created for the web worker, where calculations will take place independently of the main event loop. Once the calculations are completed, the worker can send data to the main event loop using postMessage, and the task associated with processing the data will be added to the queue and executed in one of the following ticks. However, web workers have limitations. For example, you cannot work with the DOM inside a worker, but computational tasks will work.

If the calculation data is needed within other tabs from the same origin, you can use a SharedWorker instead of a regular worker. Additionally, for some tasks, a ServiceWorker might be useful, but that’s another story. You can read more about workers, for example, here.

Aside from web workers, there is another, less obvious way to create a separate thread – opening a window or frame on a different domain to violate the same-origin policy. Then the window or frame will have its own independent event loop, which can perform some work and interact with the main window, just like a web worker, using the postMessage mechanism. This is quite a specific behavior that may look different in different browsers. You can test it, for example, using a demo from Stack Overflow.

Microtasks

Microtasks are tasks that are stored in a special separate queue.

Microtask queue
Tasks enter this queue when using promises, asynchronous functions, built-in calls to queueMicrotask, or Observer APIs.

Promise.resolve().then(() => {
    // microtask
})

async function() {
    // microtask
}

queueMicrotask(() => {
    // microtask
})

new MutationObserver(() => {
    // microtask
}).observe(document.body, { childList: true, subtree: true })
Enter fullscreen mode Exit fullscreen mode

The microtask queue has a higher priority than the regular task queue, and microtasks are executed before regular tasks. A notable feature of the microtask queue is that the event loop will continue to execute microtasks until the queue is empty. This ensures that all tasks in the queue have access to a consistent DOM state.

This behavior can be clearly seen in the example with promises, where each subsequent handler has access to the same DOM state (at the time of setting the promise):

const div = document.createElement('div')
document.body.appendChild(div)

let height = 0

function changeDOM() {
    height += 1
    div.style.height = `${height}px`
    requestAnimationFrame(changeDOM)
}

requestAnimationFrame(changeDOM)

setTimeout(() => {
    const promise = Promise.resolve()
        .then(() => {
            console.log(div.style.height)
        })
        .then(() => {
            console.log(div.style.height)
        })

    promise
        .then(() => {
            console.log(div.style.height)
        })
}, 1000)

// all console.log will output the same value
Enter fullscreen mode Exit fullscreen mode

There is a wonderful visual site, JavaScript Visualizer 9000, where you can explore in more detail how task queues and microtask queues work.
Additionally, I recommend a good article JavaScript Visualized: Promises & Async/Await that explains promises.

requestAnimationFrame

requestAnimationFrame (or abbreviated as rAF) allows you to execute JavaScript code right before updating the interface. Emulating such behavior with other methods, like timers, is almost impossible.

At the top without rAF, at the bottom with rAF

The main purpose of requestAnimationFrame is to provide smooth JavaScript animations, but it is not often used since animations are easier and more efficient to implement with CSS. Nevertheless, it occupies its own rightful place in the event loop.

There may be multiple tasks that need to be executed before updating the next frame, so requestAnimationFrame has its own separate queue.

requestAnimationFrame

Tasks from the queue are executed once before updating the interface in the order they were added:

requestAnimationFrame(() => {
    console.log('one')
})

requestAnimationFrame(() => {
    console.log('two')
})

requestAnimationFrame(() => {
    console.log('three')
})

// one two three
Enter fullscreen mode Exit fullscreen mode

You can create a recurring task that will execute again and again using a recursive function. Moreover, if you need to cancel the execution for some reason, you can do it using cancelAnimationFrame. However, make sure to pass the current identifier to it, as each rAF call creates a new requestId.

let requestId

function animate() {
    requestId = requestAnimationFrame(animate)
}

requestId = requestAnimationFrame(animate)

setTimeout(() => {
    // cancel animations after some time
    cancelAnimationFrame(requestId)
}, 3000)
Enter fullscreen mode Exit fullscreen mode

There is a small but useful article on the topic of requestAnimationFrame in Flavio Copes’ blog.

requestIdleCallback

requestIdleCallback (or abbreviated as rIC) adds tasks to yet another (fourth) queue, which will be executed during the browser’s idle period when there are no more priority tasks from other queues.

function sendAnalytics() { 
    // send data for analytics 
}

requestIdleCallback(sendAnalytics, { timeout: 2000 });
Enter fullscreen mode Exit fullscreen mode

As a second argument, you can specify a timeout, and if the task is not completed within the specified number of milliseconds, it will be added to the regular queue and then executed in the order of the general queue.

Similar to requestAnimationFrame, to regularly add a task to the queue, you will need to write a recursive function, and to stop it - pass the current identifier to cancelIdleCallback.

requestIdleCallback
In contrast to the other queues discussed earlier, requestIdleCallback is still partly an experimental API, with support missing in Safari. In addition, this function has a number of limitations, making it convenient to use only for small non-priority tasks without interaction with the DOM, for example, for sending analytical data. You can read more about requestIdleCallback in Paul Lewis’s material “Using requestIdleCallback”.

Comparison of queues

The microtask queue is the highest priority queue, with code execution starting from it. The browser continues to work with this queue until there are tasks in it, no matter how long it takes.

From the task queue, the engine usually executes one or several tasks, trying to fit within 16.6 milliseconds. As soon as the allotted time passes, the engine will go to update the interface, even if there are tasks left in the queue. It will return to them on the next loop of the event cycle.

requestAnimation will execute all tasks from its queue because it guarantees code execution before updating the interface. However, if someone adds new tasks to the queue during execution, they will be performed on the next loop.

When the idle time comes and there are no more priority tasks in other queues, one or several requestIdleCallback tasks will be executed. Thus, this queue is somewhat similar to the task queue but with a lower priority.

Interaction with queues occurs through:

  • tasks - timers, events (including postMessage processing);
  • microtasks - promises, asynchronous functions, Observer API, queueMicrotask;
  • requestAnimationFrame, requestIdleCallback - corresponding API calls.

Event loop in Node.js

In Node.js, the event loop works in a similar way: first, a task is executed, then you need to look into the queue for the next one. However, the set of queues is different, and there are no stages related to updating the interface because the code runs on the server. You can read more about event loops in Node.js in a series of articles written by Deepal Jayasekara. For a quick understanding of setImmediate and process.nextTick, there is a good explanation on Stack Overflow.

Translator's notice:
There's a really good video which explains how the event loop works here: What the heck is the event loop anyway? | Philip Roberts | JSConf EU

Callback Functions

This is a convenient and simple way to interact with asynchronous APIs, but if not handled carefully, many problems can arise.

Callback Hell

Callback hell is the most common issue mentioned when discussing the drawbacks of callback functions.

The sequence of asynchronous calls using callback functions becomes similar to the pyramid of doom.

fetchToken(url, (token) => {
    fetchUser(token, (user) => {
        fetchRole(user, (role) => {
            fetchAccess(role, (access) => {
                fetchReport(access, (report) => {
                    fetchContent(report, (content) => {
                        // Welcome to Callback Hell
                    })
                })
            })
        })
    })
})
Enter fullscreen mode Exit fullscreen mode

One might think that this is the main drawback of callback functions, but the problems with them are just beginning.

Don't Release Zalgo

The challenge with callback functions is that it's not always evident whether they will be executed synchronously or asynchronously. This ambiguity can impact the logic of the code and may require developers to examine the function's implementation to be certain of its behavior. Consequently, this adds complexity to the debugging process.

syncOrAsync(() => {
// how is the code executed?
})

// synchronous implementation
function syncOrAsync(callback) {
    callback()
}

// asynchronous implementation
function syncOrAsync(callback) {
    queueMicrotask(callback)
}
Enter fullscreen mode Exit fullscreen mode

In niche circles, this problem is widely known as the Zalgo monster issue, which is better not to release.

Tight Coupling

Tight coupling refers to the issue where one part of the code is heavily dependent on another part, particularly when handling sequential asynchronous operations.

firstStep((error, data) => {
   if (error) {
       // cancel step #1
   }
   secondStep((error, data) => {
      if (error) {
          // cancel step #2, then #1
      }
   })
})
Enter fullscreen mode Exit fullscreen mode

When managing multi-step processes, error handling becomes more complex. If an error occurs in step #1, it can be handled directly. However, if an error occurs in step #2, you may need to cancel both step #2 and step #1 to properly handle the error. As the number of steps increases, error handling becomes increasingly challenging.

The Trust Issue

Inversion of control refers to the practice of passing your code to a library or framework developed by others, allowing them to manage the control flow and execution of your code.

import { thirdPartyCode } from 'third-party-package'

thirdPartyCode(() => {
    // inversion of control
})
Enter fullscreen mode Exit fullscreen mode

We rely on our task being called as it should, but everything may not go as expected. Another library might call the function too early or too late, do it too frequently or rarely, swallow errors and exceptions, pass incorrect arguments, or not call our function at all.

Given these challenges, one might assume that handling asynchrony through callback functions is inherently problematic. To some extent, this is true. However, promises provide a powerful solution to address these concerns and simplify asynchronous programming.

Promises

Promises can be compared to order numbers at a restaurant. When we place an order, we receive an order number instead of the food itself. Two possible scenarios can occur: first, the order will be successfully prepared and served at the pick-up counter after some time; second, something could go wrong, such as running out of ingredients, prompting the restaurant employee to inform us that our order cannot be fulfilled. In this case, they would offer a refund or an alternative.

Promises are created using the Promise constructor, which must be instantiated with the new keyword. The constructor accepts a single argument: a callback function with two parameters, resolve and reject. Within the callback function, an asynchronous operation is carried out. Depending on the outcome of the operation, either the resolve or reject function is called, setting the Promise's state to fulfilled or rejected, respectively.

This is how a Promise can be set to either fulfilled or rejected:

// set Promise to fulfilled
const resolvedPromise = new Promise((resolve, reject) => {
    setTimeout(() => { resolve('^_^') }, 1000)
})

// set Promise to rejected
const rejectedPromise = new Promise((resolve, reject) => {
    setTimeout(() => { reject('O_o') }, 1000)
})
Enter fullscreen mode Exit fullscreen mode

After a Promise is set, the result can be obtained through the then method:

resolvedPromise.then((value) => {
    console.log(value) // ^_^
})
Enter fullscreen mode Exit fullscreen mode

A rejection can be handled either through the second parameter in then or through catch:

rejectedPromise.then(
   (value) => {
       // ... ignored
   },
   (error) => {
       console.log(error) // O_o
   }
)

rejectedPromise.catch((error) => {
    console.log(error) // O_o
})
Enter fullscreen mode Exit fullscreen mode

The value of a Promise is set once and cannot be changed:

const promise = new Promise((resolve, reject) => {
   resolve('^_^')
   reject('O_o') // will not affect the state of the Promise
})

promise.then((value) => {
    console.log(value) // ^_^
})

promise.then((value) => {
    console.log(value) // ^_^
})
Enter fullscreen mode Exit fullscreen mode

For convenience, you can use the static methods, the Promise.resolve and Promise.reject constructor functions, which create an already-set Promise:

Promise.resolve('^^').then((value) => {
    console.log(value) // ^^
})

Promise.reject('O_o').catch((error) => {
    console.log(error) // O_o
})
Enter fullscreen mode Exit fullscreen mode

Promises also have a finally method, which is executed regardless of the promise's success or failure. This can be compared to baking a dish in an oven: whether the dish is cooked perfectly or gets burnt, the oven still needs to be turned off afterward.

Promise.resolve('^^').finally(() => {
    // do something
}).then((value) => {
    console.log(value) // ^^
})

Promise.reject('O_o').finally(() => {
    // do something
}).catch((error) => {
    console.log(error) // O_o
})
Enter fullscreen mode Exit fullscreen mode

Promise chains and error propagation

The main benefit of promises is that we can build chains of asynchronous operations with them:

Promise.resolve('^')
    .then((value) => {
        return value + '_'
    })
    .then((value) => {
        return value + '^'
    })
    .then((value) => {
        console.log(value) // ^_^
    })
Enter fullscreen mode Exit fullscreen mode

If an error occurs somewhere, the rejection will skip the fulfillment handlers and reach the nearest rejection handler, after which the chain will continue to operate normally:

Promise.resolve()
   .then(() => {
       return Promise.reject('O_o')
   })
   .then(() => {
       // all fulfillment handlers will be skipped
   })
   .catch((error) => {
       console.log(error) // O_o
   })
   .then(() => {
       // continue to execute the chain normally
   })
Enter fullscreen mode Exit fullscreen mode

You can also return a value inside catch, and it will be processed in the chain just the same:

Promise.reject('O_o')
    .catch((error) => {
        console.log(error) // O_o
        return '^_^'
    })
    .then((value) => {
        console.log(value) // ^_^
    })
Enter fullscreen mode Exit fullscreen mode

If the chain ends with the error still unhandled, the unhandledrejection event will be triggered. You can subscribe to this event to track unhandled errors inside promises:

window.addEventListener('unhandledrejection', (event) => {
   console.log('Unhandled Promise error. Shame on you!')
   console.log(event) // PromiseRejectionEvent
   console.log(event.reason) // O_o
})
Enter fullscreen mode Exit fullscreen mode

It's crucial to recognize that error handling functions properly only when the promise chain remains uninterrupted. If the return statement is omitted and a rejected promise is created, the following catch block will be unable to handle the error:

Promise.resolve()
   .then(() => {
       Promise.reject('O_o')
   })
   .catch(() => {
      // will be skipped because return is not specified
      // UnhandledPromiseRejection will be thrown
   })
Enter fullscreen mode Exit fullscreen mode

Implicit behavior

Promises have two implicit features. First, the then and catch methods always return a new promise. Second, they internally catch any errors and, if something goes wrong, return a promise set to reject with the reason for the error.

Returning a new promise

Each call to then or catch creates a new promise, the value of which is either undefined or explicitly set via return.

Thanks to this, instead of creating temporary variables, you can immediately make a convenient chain of calls:

// you can do it like this:
const one = Promise.resolve('^')

const two = one.then((value) => {
    return value + '_'
})

const three = two.then((value) => {
    return value + '^'
})

three.then((value) => {
    console.log(value) // ^_^
})

// but this is much better:
Promise.resolve('^')
   .then((value) => {
       return value + ''
   })
   .then((value) => {
       return value + '^'
   })
   .then((value) => {
       console.log(value) // ^^
   })
Enter fullscreen mode Exit fullscreen mode

At the same time, if you return a promise, its value will be unwrapped, and everything will work exactly the same.

Promise.resolve()
   .then(() => {
       return Promise.resolve('^^')
   })
   .then((value) => {
       console.log(value) // ^^
   })
Enter fullscreen mode Exit fullscreen mode

Because of this, you can avoid nested promises and always write code with a single level of nesting:

// you can do it like this:
Promise.resolve('^')
    .then((value) => {
        return Promise.resolve(value + '_')
            .then((value) => {
                return Promise.resolve(value + '^')
                    .then((value) => {
                        console.log(value) // ^_^
                    })
            })
    })

// but this is much better:
Promise.resolve('^')
    .then((value) => {
        return Promise.resolve(value + '_')
    })
    .then((value) => {
        return Promise.resolve(value + '^')
    })
    .then((value) => {
        console.log(value) // ^_^
    })
Enter fullscreen mode Exit fullscreen mode

Hidden try/catch

Another feature of promises is related to error handling. Callback functions passed to promise methods are wrapped in try/catch. If something goes wrong, the try/catch will catch the error and set it as the reason for the promise's rejection:

Promise.resolve()
.then(() => {
    undefined.toString()
})
.catch((error) => {
    console.log(error) // TypeError: Cannot read property 'toString' of undefined
})
Enter fullscreen mode Exit fullscreen mode

This is the same as manually writing this code:

Promise.resolve()
    .then(() => {
        try {
            undefined.toString()
        } catch (error) {
            return Promise.reject(error)
        }
    })
    .catch((error) => {
        console.log(error) // TypeError: Cannot read property 'toString' of undefined
    })
Enter fullscreen mode Exit fullscreen mode

Thus, within promises, you can do without try/catch because promises will do it for us. The main thing is to properly handle the reason for rejection in catch.

Thenable objects

These are objects that have a then method:

const thenable = {
    then (fulfill) {
        fulfill('@_@')
    }
}
Enter fullscreen mode Exit fullscreen mode

Most likely, these are promise polyfills before ES6. Promises will unwrap such objects and then wrap them in full-fledged ES6 promises. This is how resolve, Promise.resolve, then, and catch work.

Promise.resolve(thenable)
    .then((value) => {
        console.log(value) // @_@
    })

new Promise((resolve) => {
    resolve(thenable)
}).then((value) => {
    console.log(value) // @_@
})

Promise.resolve()
    .then(() => {
        return thenable
    })
    .then((value) => {
        console.log(value) // @_@
    })
Enter fullscreen mode Exit fullscreen mode

Thanks to this, compatibility with code written before ES6 is ensured:

const awesomeES6Promise = Promise.resolve(thenable)
awesomeES6Promise.then((value) => {
    console.log(value) // @_@
})
Enter fullscreen mode Exit fullscreen mode

There is one peculiarity: if you pass a regular promise to Promise.resolve, it will not create a new promise and will return the original promise unchanged. However, when using the resolve callback inside a Promise constructor or the then and catch methods on a promise, they will always create and return a new promise that depends on the resolution of the previous promise.

const thenable = {
    then (fulfill) {
        fulfill('@_@')
    }
}

const promise = Promise.resolve('@_@')

const resolvedThenable = Promise.resolve(thenable)
const resolvedPromise = Promise.resolve(promise)

console.log(thenable === resolvedThenable) // false
console.log(promise === resolvedPromise) // true
Enter fullscreen mode Exit fullscreen mode

But the most interesting part is the behavior of reject and Promise.reject, which work completely differently. If you pass any object to them, including a promise, they will simply return it as the reason for rejection:

const promise = Promise.resolve('@_@')

Promise.reject(promise)
    .catch((value) => {
        console.log(value) // Promise {<fulfilled>: "@_@"}
    })
Enter fullscreen mode Exit fullscreen mode

Static methods

Promises have six useful static methods. We have already covered two of them - Promise.resolve and Promise.reject. Let's take a look at the other four.

For clarity, let's write a function that will help us get a settled promise after a certain time:

const setPromise = (value, ms, isRejected = false) =>
    new Promise((resolve, reject) =>
        setTimeout(() => isRejected ? reject(value) : resolve(value), ms))
Enter fullscreen mode Exit fullscreen mode

All four methods we will cover below accept an array of values. However, each of these methods works differently, and they return different results.

Promise.all

This call returns an array of values or the first rejection:

Promise.all([
    setPromise('^_^', 400),
    setPromise('^_^', 200),
]).then((result) => {
    console.log(result) // [ "^_^", "^_^" ]
})
Enter fullscreen mode Exit fullscreen mode

If at least one promise fails, instead of an array of values, the reason for rejection will be sent to catch:

Promise.all([
    setPromise('^_^', 400),
    setPromise('O_o', 200, true),
]).catch((error) => {
    console.log(error) // O_o
})
Enter fullscreen mode Exit fullscreen mode

For an empty array, an empty result is returned immediately:

Promise.all([])
    .then((result) => {
        console.log(result) // []
    })
Enter fullscreen mode Exit fullscreen mode

Promise.race

This method returns the first value or the first rejection:

Promise.race([
    setPromise('^_^', 100),
    setPromise('O_o', 200, true),
]).then((result) => {
    console.log(result) // ^_^
})
Enter fullscreen mode Exit fullscreen mode

If the rejection occurs first, then race will be set to rejection:

Promise.race([
    setPromise('^_^', 400),
    setPromise('O_o', 200, true),
]).catch((error) => {
    console.log(error) // O_o
})
Enter fullscreen mode Exit fullscreen mode

If you pass an empty array to Promise.race, the promise will be stuck in a pending state and will not be set to either fulfillment or rejection:

Promise.race([])
    .then(() => {
        console.log('resolve will never be executed')
    }).catch(() => {
        console.log('reject neither')
    })
Enter fullscreen mode Exit fullscreen mode

Promise.any

The call returns the first value or an array of rejection reasons if none of the promises were successful:

Promise.any([
    setPromise('^_^', 400),
    setPromise('O_o', 200, true),
]).then((result) => {
    console.log(result) // ^_^
})
Enter fullscreen mode Exit fullscreen mode

When all promises are set to rejection, any will return an error object, in which you can extract information about the rejections from the errors field:

Promise.any([
    setPromise('O_o', 400, true),
    setPromise('O_o', 200, true),
]).catch((result) => {
    console.log(result.message) // All promises were rejected
    console.log(result.errors) // [ "O_o", "O_o" ]
})
Enter fullscreen mode Exit fullscreen mode

For an empty array, an error will be returned:

Promise.any([])
    .catch((error) => {
        console.log(error.message) // All promises were rejected
    })
Enter fullscreen mode Exit fullscreen mode

Promise.allSettled

The method will wait for all promises to be completed and return an array of special objects:

Promise.allSettled([
    setPromise('^_^', 400),
    setPromise('O_o', 200, true),
]).then(([resolved, rejected]) => {
    console.log(resolved) // { status: "fulfilled", value: "^_^" }
    console.log(rejected) // { status: "rejected", reason: "O_o" }
})
Enter fullscreen mode Exit fullscreen mode

For an empty array, an empty result is:

Promise.allSettled([])
    .then((result) => {
        console.log(result) // []
    })
Enter fullscreen mode Exit fullscreen mode

Promisification

When you need to transition from callback functions to promises, promisification comes to the rescue - a special helper function that turns a function working with a callback into a function that returns a promise:

function promisify (fn) {
    return function (...args) {
        return new Promise((resolve, reject) => {
            function callback(error, result) {
                return error ? reject(error) : resolve(result)
            }

            fn(...args, callback)
        })
    }
}

function asyncApi (url, callback) {
    // ... perform asynchronous operation
    callback(null, '^_^')
}

promisify(asyncApi)('/url')
    .then((result) => {
        console.log(result) // ^_^
    })
Enter fullscreen mode Exit fullscreen mode

The operation of promisification depends on the signature of the functions in the code, because it requires considering the order of arguments, as well as the parameters of the callback. In the example above, it is assumed that the callback function is passed as the last argument, and it first takes an error as a parameter, followed by the result.

Promises or callback functions?

Promises help to address the drawbacks of callback functions. They are always asynchronous, single-use, and promote more linear code, reducing tight coupling and mitigating callback hell.

But what if a promise gets stuck and is not being fulfilled or rejected? In this case, you can use Promise.race to interrupt the execution of a stuck or very long request by a timeout:

Promise.race([
    fetchLongRequest(),
    new Promise((_, reject) => setTimeout(reject, 3000)),
]).then((result) => {
    // received data
}).catch((error) => {
    //  or rejection due to timeout
})
Enter fullscreen mode Exit fullscreen mode

In any case, it's important to understand: despite the many advantages of promises, you will still need to use callback functions in some situations, and that's okay. Event handlers and many asynchronous API methods, such as setTimeout, work with callbacks, so in such cases, there's no point in promisifying and it's more convenient to use callback functions. After all, we will also need them to create a promise. The main thing to remember is that if there is a chain of sequential calls somewhere, promises should be used to improve code readability and error handling.

Coroutines

Promises are the foundation for working with asynchrony, but there is a very convenient async/await extension built on top of this foundation, which is implemented thanks to coroutines.

A coroutine (cooperative concurrently executed routine) is a co-program or, in simpler terms, a special function that can pause its work, remember its state, and has multiple entry and exit points.

In JavaScript, generator functions act as coroutines, returning an iterator. The iterator can pause its work, remember its current state, and interact with external code through .next and .throw.

Thanks to these capabilities of coroutines, you can write a special function like this:

function async (generator) {
    const iterator = generator()

    function handle({ done, value }) {
        return done ? value : Promise.resolve(value)
            .then((x) => handle(iterator.next(x)))
            .catch((e) => handle(iterator.throw(e)))
    }

    return handle(iterator.next())
}
Enter fullscreen mode Exit fullscreen mode

And then use it to sequentially call asynchronous operations:

async(function* () {
    const response = yield fetch('example.com')
    const json = yield response.json()

    // process json
})
Enter fullscreen mode Exit fullscreen mode

This turned out to be so convenient that later, JavaScript added async/await constructs.

Async/await

This is syntactic sugar implemented through promises and coroutines, which makes working with asynchronous operations more convenient.

The async modifier is placed before a function and makes it asynchronous:

async function asyncFunction () {
    return '^_^'
}
Enter fullscreen mode Exit fullscreen mode

The result of an asynchronous function is always a promise. For convenience, you can think of an asynchronous function as a regular one that wraps its result in a promise:

function asyncFunction () {
    return Promise.resovle('^_^')
}
Enter fullscreen mode Exit fullscreen mode

The result of an asynchronous function is extracted through then or await:

asyncFunction().then((value) => {
    console.log(value) // ^_^
})

(async () => {
    const value = await asyncFunction()
    console.log(value) // ^_^
})()
Enter fullscreen mode Exit fullscreen mode

Await is slightly more convenient than promises, but it has a serious limitation - it can only be called within an asynchronous function. With this, asynchrony becomes "sticky" - once you write an asynchronous call, there's no way back to the synchronous world. For a while, nothing could be done about this, but then top-level await appeared.

Top-level await and asynchronous modules

Top-level await allows you to use this operator outside of asynchronous functions:

const connection = await dbConnector()
const jQuery = await import('http://cdn.com/jquery')
Enter fullscreen mode Exit fullscreen mode

But it can only be used either inside ES6 modules or in DevTools. This limitation is due to the fact that await is syntactic sugar that works through modules.

For example, a module with top-level await looks like this for a developer:

// module.mjs
const value =
    await Promise.resolve('^_^')

export { value }

// main.mjs
import {
    value
} from './module.mjs'

console.log(value) // ^_^
Enter fullscreen mode Exit fullscreen mode

The original code does not contain any asynchronous functions within the module or the main program, yet they are required for await to work. So, how does this code function?

The answer lies in the JavaScript engine's ability to handle this task by wrapping the await within an asynchronous function behind the scenes. Without the syntactic sugar, the top-level await would appear as follows:

// module.mjs
export let value
export const promise = (async () => {
    value = await Promise.resolve('^_^')
})()

export { value, promise }

// main.mjs
import {
    value,
    promise
} from './module.mjs'

(async () => {
    await promise
    console.log(value) // ^_^
})()
Enter fullscreen mode Exit fullscreen mode

No magic. Just syntactic sugar.

Error handling

There are two ways to handle errors within asynchronous functions. The first is to add a catch after calling the function, and the second is to wrap the await call in a try/catch block.

Since asynchronous functions are promises, you can add a catch after the call and handle the error.

async function asyncFunction () {
    await Promise.reject('O_o')
}

asyncFunction()
    .then((value) => {
        // what if there's an error?
    })
    .catch((error) => {
        // then catch will capture the error
    })
Enter fullscreen mode Exit fullscreen mode

This method will work, but try/catch is likely to be more suitable because it allows you to handle exceptions directly within the function body:

async function asyncFunction () {
    try {
        await Promise.reject('O_o')
    } catch (error) {
        // catch the error
    }
}

asyncFunction().then((value) => {
    // we're safe
})
Enter fullscreen mode Exit fullscreen mode

Another important advantage: unlike catch, the try/catch block can handle top-level await.

Not all await is equally useful

The await operator suspends task execution until the asynchronous operation is complete. If you mindlessly add await before each asynchronous operation, this may lead to suboptimal code performance due to sequential loading of unrelated data.

For example, you could load a list of unrelated articles and pictures using await like this:

const articles = await fetchArticles()
const pictures = await fetchPictures()

// ... some actions with articles and pictures
Enter fullscreen mode Exit fullscreen mode

In this case, data that could be fetched in parallel will be requested sequentially. As long as the first part of the data is not fully loaded, work with the second part will not start. Because of this, the task will take longer than it could. Suppose each request takes two seconds; then, it will take four seconds to fully load the data. However, if you load the data in parallel using Promise.all, all the information will load twice as fast:

const [articles, pictures] = await Promise.all([
    fetchArticles(),
    fetchPictures(),
])
Enter fullscreen mode Exit fullscreen mode

Conclusion

That's everything you probably wanted to know about asynchrony in the browser. I hope you now have a good understanding of how the event loop works, can escape from callback hell, easily work with promises, and skillfully use async/await. If I forgot something, please remind me in the comments.

Top comments (0)