JavaScript’s Async/Await: Managing Time-Based Code

Web applications regularly need to handle operations that take time—fetching data from an API, loading resources, or processing large amounts of information. JavaScript’s single-threaded nature means it can't simply pause and resume at will. Instead, it uses a system of time-based sequencing to keep code running smoothly while long-running processes complete in the background.

Async/Await is a modern syntax for managing this kind of asynchronous behavior. It's particularly useful when you need to fetch data and then work with the result once it arrives.

Promises: The Building Blocks

Async/Await is built on top of a core JavaScript feature: Promises. A Promise is an object representing a value that may not be available yet. This mirrors how real-world commitments work. When you promise a friend you'll help them move, that promise exists before you actually show up to help—there's an initial agreement, a period of waiting, and then either a fulfilled outcome or a cancellation.

JavaScript promises have three possible states:

  • pending: the initial state, before the operation completes and while its result is still unknown.
  • fulfilled: the operation finished successfully and a value is available.
  • rejected: the operation failed, and an error is available.

Consider a promise called getSomeTacos. You pass it two callbacks: resolve for when everything goes as planned, and reject for when it doesn't. When a promise is fulfilled, you can execute subsequent code. When it's rejected, you can handle the error in a catch block. And if you log the promise itself before it's finished, you'll see it in its pending state—that's because the value hasn't materialized yet at that point in the execution.

> Initial state: Excuse me can I have some tacos
> Order some tacos
> Here are your tacos

From Promises to Async/Await

To get a value out of a promise, you need to use a method like .then() that returns the resolution of the promise. This is necessary because the promise initially exists in the pending state, so you have to wait to capture what it will eventually become.

Async/Await is essentially syntactic sugar over promises. It lets you write asynchronous code that reads more like synchronous code. You mark a function with the async keyword, and then you can use await inside it to pause execution until a promise resolves, making it easier to sequence multiple operations.

async function tacos() {
  return await Promise.resolve("Now and then I get to eat delicious tacos!")
};

tacos().then(console.log)

This is particularly useful for chaining dependent operations together. For example, you might need to make one API call to get parameters, then use those parameters to construct another call, and finally use the second response to display content on a page.

async function getQuote() {
  // get the type of quote from one fetch call, everything else waits for this to finish
  let quoteTypeResponse = await fetch(`https://s3-us-west-2.amazonaws.com/s.cdpn.io/28963/quotes.json`)
  let quoteType = await quoteTypeResponse.json()

    // use what we got from the first call in the second call to an API, everything else waits for this to finish
  let quoteResponse = await fetch("https://programming-quotes-api.herokuapp.com/quotes/" + quoteType.type)
  let quote = await quoteResponse.json()

  // finish up
  console.log('done')
}

You could even refine that code with a more concise syntax:

async function getQuote() {
  // get the type of quote from one fetch call, everything else waits for this to finish
  let quoteType = await fetch(`quotes.json`).then(res => res.json())

    // use what we got from the first call in the second call to an API, everything else waits for this to finish
  let quote = await fetch(`programming-quotes.com/${quoteType.type}`).then(res => res.json())

  // finish up
  console.log('done')
}

getQuote()

Handling Errors with Try/Catch/Finally

Any code that makes network requests needs a plan for handling failures. That's where try, catch, and finally blocks come in. The try block wraps the code that might fail, the catch block handles any errors that are thrown, and the finally block gives you a place for code that should run regardless of the outcome.

async function getQuote() {
  try {
    // get the type of quote from one fetch call, everything else waits for this to finish
    let quoteType = await fetch(`quotes.json`).then(res => res.json())

      // use what we got from the first call in the second call to an API, everything else waits for this to finish
    let quote = await fetch(`programming-quotes.com/${quoteType.type}`).then(res => res.json())

    // finish up
    console.log('done')
  }

  catch(error) {
    console.warn(`We have an error here: ${error}`)
  }
}

getQuote()

The finally block is optional. You might use it when you find yourself duplicating code in both the try and catch blocks—for example, to perform cleanup tasks like closing a connection, so that it happens whether or not the operation was successful.

If you need more sophisticated control, like the ability to cancel an async function, you won't find that built into JavaScript yet. Fortunately, the community has stepped up; Kyle Simpson created the CAF library specifically to address this limitation.

Async in Practice

Ascyn/Await is now well-supported across modern browsers and runtimes, making callbacks less relevant to day-to-day development. Still, understanding the evolution from callbacks to promises to Async/Await can be valuable if you work on older codebases. For those looking to go deeper, there are a number of resources that walk through this history: