Modern Promises with async and await

Imagine your JavaScript code is a kitchen, and you have multiple tasks cooking at once. Promises made asynchronous programming more manageable, letting you “start the pasta” and then “marinate the chicken” without blocking. Yet, chaining .then can still get bulky.

Enter async and await. In 2017, JavaScript introduced a way to write asynchronous code that looks synchronous. In essence, you can pause a function until a promise returns, making your code more intuitive, compact, and readable.

By the end of this lesson, you’ll be able to:


Classic Promise Example

Let’s review how we normally handle promises. Suppose you have walkTheDog() returning a promise that resolves to "happy dog" after one second. Then, doChores() is your “main” function:

function walkTheDog() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('happy dog');
    }, 1000);
  });
}

function doChores() {
  console.log('before walking the dog');
  walkTheDog()
    .then(res => {
      console.log(res);
      console.log('after walking the dog');
    });
  return 'done';
}

console.log(doChores());

// prints:
// before walking the dog
// done
// happy dog
// after walking the dog

Notice that doChores() logs “done” before the dog walk promise resolves. That’s because the promise is asynchronous. If you need to do something after the dog walk completes, you chain .then. This works, but chaining multiple promises can become bulky, especially for more complex tasks.


async Function Declarations

Marking a function with async automatically wraps its return value in a promise. For example:

async function doChores() {
  return 'done';
}

console.log(doChores());
// prints:
// Promise { 'done' }

So, doChores() doesn’t just return “done”—it returns a promise that’s already resolved with the value “done.” This alone isn’t too exciting, but it allows us to use await inside that function.


awaiting a Promise

The await keyword pauses execution within an async function until the promise fulfills (or rejects). You can’t use await at the top level of your code—only inside an async function. Let’s rewrite the “walk the dog” scenario using async and await:

function walkTheDog() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('happy dog');
    }, 1000);
  });
}

async function doChores() {
  console.log('before walking the dog');
  const res = await walkTheDog();
  console.log(res);
  console.log('after walking the dog');
}

doChores();

// prints:
// before walking the dog
// happy dog
// after walking the dog

Observe how much more natural it reads: we “pause” on await walkTheDog() until the promise fulfills, then log “happy dog.” Meanwhile, JavaScript still behind the scenes handles everything asynchronously.

Remember that doChores() itself returns an implicit promise. Once all its statements finish, that promise resolves. You can handle it with .then:

async function doChores() {
  console.log('before walking the dog');
  const res = await walkTheDog();
  console.log('after walking the dog');
  return res.toUpperCase();
}

doChores().then(result => console.log(result));

// prints:
// before walking the dog
// after walking the dog
// HAPPY DOG

The then call sees res.toUpperCase() as the fulfilled value of doChores(). We can’t just await doChores() at the top level in older versions of JavaScript runtime—only inside another async function.


Using an async Wrapper

If you want to await doChores(), you can do so within another async function, like this:

function walkTheDog() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('2');
      resolve('happy dog');
    }, 1000);
  });
}

async function doChores() {
  console.log('1');
  const res = await walkTheDog();
  console.log('3');
  return res.toUpperCase();
}

async function wrapper() {
  console.log('0');
  const finalResult = await doChores();
  console.log('4');
  console.log(finalResult + '!!!');
}

wrapper();

// prints:
// 0
// 1
// 2
// 3
// 4
// HAPPY DOG!!!

Execution order:

  1. “0” logs immediately from wrapper().
  2. “1” logs when doChores() starts.
  3. After 1 second, walkTheDog() resolves (“2”).
  4. “3” logs once await walkTheDog() is done.
  5. Back in wrapper(), after await doChores(), we log “4” and then the result.

Refactoring a Promise Chain

Suppose you have a typical promise chain that prints resolved values in sequence:

function wrapper() {
  promise1
    .then(res1 => {
      console.log(res1);
      return promise2;
    })
    .then(res2 => {
      console.log(res2);
      return promise3;
    })
    .then(res3 => {
      console.log(res3);
    });
}

Refactoring to async/await is straightforward:

async function wrapper() {
  console.log(await promise1);
  console.log(await promise2);
  console.log(await promise3);
}

The difference in readability is striking. It’s simpler to follow the sequence top to bottom without nesting.


Error Handling

Since async/await appears more synchronous, you can handle errors with a normal try...catch:

function action() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject('uh-oh'); // rejected
    }, 3000);
  });
}

async function handlePromise() {
  try {
    const res = await action();
    console.log('resolved with', res);
  } catch (err) {
    console.log('rejected because of', err);
  }
}

handlePromise();

// prints:
// rejected because of uh-oh

No more separate .then() + .catch()—it feels like a normal synchronous try/catch but is still asynchronous under the hood.


What You Learned

In this lesson, you discovered how JavaScript’s async and await keywords enable you to write code that looks synchronous yet remains asynchronous. In essence:

  1. Mark a function with async, and JavaScript automatically wraps its return value in a promise.
  2. Use await inside an async function to pause execution until a promise fulfills (or rejects).
  3. Errors can be handled with try...catch, just like you would do synchronously.

The result is clean, linear code that’s easier to maintain. By leveraging async/await, you’ll transform your promise-heavy logic into something that reads like a normal sequence of steps—without sacrificing concurrency or the non-blocking nature of JavaScript.