JavaScript Intermediate #2 Async Intro — Promise and async/await

6 min read

After #1 Classes, this post covers one of JavaScript’s biggest features — async. Tough on first contact — but once you’re used to it, JavaScript’s real expressiveness opens up.

Sync vs async — what’s the difference? #

JavaScript executes line by line, by default. Top to bottom.

sync — line by line
console.log('1');
console.log('2');
console.log('3');
// 1, 2, 3 (in this order)

The problem is time-consuming work (file reads, network requests, timers, etc.). If JavaScript waited synchronously, everything else would freeze. So JavaScript handles those asynchronously — in the form of “tell me when it’s done.”

async — setTimeout example
console.log('1');
setTimeout(() => console.log('2'), 1000);
console.log('3');
// 1, 3, 2 (in this order)

setTimeout registers a function to run in 1 second and the body keeps going. console.log('3') runs first.

The age of callbacks — and its limits #

Old JavaScript handled async with callback functions.

old callback style
fetchUser(userId, (user) => {
  fetchPosts(user.id, (posts) => {
    fetchComments(posts[0].id, (comments) => {
      console.log(comments);
    });
  });
});

The famous callback hell. Indentation grows deeper, and error handling spreads across every level.

add error handling
fetchUser(userId, (err, user) => {
  if (err) return handleError(err);
  fetchPosts(user.id, (err, posts) => {
    if (err) return handleError(err);
    fetchComments(posts[0].id, (err, comments) => {
      if (err) return handleError(err);
      console.log(comments);
    });
  });
});

ES2015’s Promise solved this.

Promise — an object that represents an async result #

A Promise represents “a value that doesn’t exist yet but will.” It holds one of three states.

  • pending — not finished yet
  • fulfilled — succeeded with a value
  • rejected — errored

Creating a Promise — new Promise #

creating a Promise directly
const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    if (Math.random() > 0.5) {
      resolve('success');
    } else {
      reject(new Error('failure'));
    }
  }, 1000);
});

Receives resolve and reject functions. When the async work finishes, call one of them.

Creating manually isn’t actually common. Most async APIs (fetch, fs.promises, timers) already return Promises.

.then / .catch / .finally #

Methods for handling a Promise’s result.

basic usage
promise
  .then((value) => console.log('success:', value))
  .catch((err) => console.error('failure:', err))
  .finally(() => console.log('done'));
  • .then(callback) — on success
  • .catch(callback) — on failure
  • .finally(callback) — at the end regardless

Chain — pass results forward #

What .then’s callback returns becomes the input to the next .then. If the callback returns another Promise, its result is unwrapped for the next.

chain
fetchUser(userId)
  .then((user) => fetchPosts(user.id))      // takes user, returns a posts Promise
  .then((posts) => fetchComments(posts[0].id))   // takes posts, returns a comments Promise
  .then((comments) => console.log(comments))
  .catch((err) => handleError(err));

Callback hell flattened out. Error handling collected in one final .catch.

async / await — straightening Promises #

Syntax added in ES2017. Layered on top of Promises so you can write as if it’s synchronous code.

async / await — same job, cleaner
async function loadComments(userId) {
  const user = await fetchUser(userId);
  const posts = await fetchPosts(user.id);
  const comments = await fetchComments(posts[0].id);
  return comments;
}

loadComments(userId)
  .then((comments) => console.log(comments))
  .catch((err) => handleError(err));

Rules:

  1. Putting async in front of a function means the function always returns a Promise
  2. You can use await only inside an async function — await unwraps a Promise and returns the value

These two are key. Hitting await, JavaScript pauses briefly at that line to wait for the result, then continues to the next line (other code can run during the wait).

Errors via try/catch #

try/catch with async/await
async function loadComments(userId) {
  try {
    const user = await fetchUser(userId);
    const posts = await fetchPosts(user.id);
    const comments = await fetchComments(posts[0].id);
    return comments;
  } catch (err) {
    handleError(err);
    throw err;
  }
}

Just like sync code, you catch every async error in one place with try/catch. One of async/await’s biggest benefits.

Parallel execution — Promise.all #

A pitfall in the code above — three fetches run sequentially (the second starts after the first ends). Where they could run together, that’s wasted time.

For independent async, run them in parallel with Promise.all.

Promise.all — parallel
async function loadDashboard(userId) {
  const [user, notifications, settings] = await Promise.all([
    fetchUser(userId),
    fetchNotifications(userId),
    fetchSettings(userId),
  ]);
  return { user, notifications, settings };
}

Three requests start at once, and when all finish you get the results in an array. Receive via destructuring (#5 Objects and Arrays).

Pitfall of Promise.all — one failure fails all #

all rejects on the first error
const results = await Promise.all([
  fetchA(),    // success
  fetchB(),    // failure
  fetchC(),    // success
]);
// the whole thing rejects — fetchA, fetchC results are not received

If that’s a burden, use Promise.allSettled.

allSettled — all the way through
const results = await Promise.allSettled([
  fetchA(),
  fetchB(),
  fetchC(),
]);
// results = [
//   { status: 'fulfilled', value: ... },
//   { status: 'rejected', reason: ... },
//   { status: 'fulfilled', value: ... },
// ]

Each result comes as an object — handle success/failure individually.

Other commonly used Promise static methods #

Promise static methods
// fastest result (success or failure)
const fastest = await Promise.race([fetchA(), fetchB()]);

// fastest success only
const firstSuccess = await Promise.any([fetchA(), fetchB(), fetchC()]);

// immediately resolved/rejected
Promise.resolve(42);              // immediately fulfilled
Promise.reject(new Error('!'));    // immediately rejected

Promise.race is used often in timeout patterns.

timeout pattern
function timeout(ms) {
  return new Promise((_, reject) =>
    setTimeout(() => reject(new Error('timeout')), ms)
  );
}

const result = await Promise.race([
  fetch('/api/slow'),
  timeout(5000),
]);

If fetch doesn’t finish in 5 seconds, timeout rejects first and race exits. Modern JavaScript can use AbortController together — covered in #6 fetch API.

Where to put await — pitfalls and guides #

1) await pauses the current line briefly #

sequential vs parallel
// sequential — adds up
const a = await fetchA();
const b = await fetchB();

// parallel — faster of the two
const [a, b] = await Promise.all([fetchA(), fetchB()]);

The reason loadComments was sequential — posts needed user, and comments needed posts, so there were dependencies. Where there’s no dependency, always Promise.all is faster.

2) Beware await in loops #

for...of + await — one at a time
for (const id of userIds) {
  const user = await fetchUser(id);
  console.log(user);
}

For 100 users this is 100 sequential fetches — very slow. If parallel is fine:

parallel fetch
const users = await Promise.all(
  userIds.map((id) => fetchUser(id))
);

Almost 100x faster.

3) Don’t forget await #

forgetting await
async function process() {
  const result = fetchData();        // ✗ the Promise object itself
  console.log(result);                // [object Promise]
  console.log(result.data);           // undefined
}

Forgetting await makes result the Promise object itself. The compiler doesn’t catch it — be mindful (TypeScript + ESLint help).

Top-level await — ES2022 #

In ES Modules, you can use await outside functions.

top-level await
// app.js (ES module)
const data = await fetch('/api/init').then((r) => r.json());

console.log(data);

Doesn’t work in CommonJS modules. Only modern ESM. Available in modern tools like Vite, Next.js, and Bun.

Wrap-up #

What we covered:

  • JavaScript runs line by line, but slow work is async
  • Async handling evolved from callback → Promise → async/await
  • Three Promise states — pending / fulfilled / rejected
  • .then / .catch / .finally and chain
  • async functions return Promises, await unwraps results
  • Run independent async in parallel with Promise.all
  • Promise.allSettled / race / any
  • Beware await in loops; don’t forget await
  • Top-level await in ESM

In the next post (#3 Iterators and Generators) we cover the contract for...of follows, building your own iterables, and using generators to express lazy sequences.

X