JavaScript by Example

Promises and async/await

Async control flow with Promise combinators and async/await - plus the concurrency trap that knocks over databases.

A Promise represents a value that will be available in the future. async/await is syntactic sugar over promises - every async function returns a Promise and await unwraps one.

async/await lets you write asynchronous code that reads like synchronous code. Wrap await calls in try/catch to handle rejections - unhandled rejections terminate the process in Node 15+.

async function fetchUser(id) {
  const res = await fetch(`https://api.example.com/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}
 
async function main() {
  try {
    const user = await fetchUser(1);
    console.log(user.name);
  } catch (err) {
    console.error("Failed:", err.message);
  }
}
 
main();

Promise.all runs promises in parallel and resolves when all fulfill - or rejects immediately on the first rejection. Promise.allSettled waits for every promise regardless of outcome, which is safer when you need all results and partial failure is acceptable.

async function parallel() {
  // All three fetch concurrently - total time ≈ slowest individual request
  const [users, posts, tags] = await Promise.all([
    fetch("/api/users").then((r) => r.json()),
    fetch("/api/posts").then((r) => r.json()),
    fetch("/api/tags").then((r) => r.json()),
  ]);
  console.log(users.length, posts.length, tags.length);
}
 
// allSettled: never short-circuits, gives you {status, value/reason} per promise
async function safeParallel(ids) {
  const results = await Promise.allSettled(ids.map((id) => fetchUser(id)));
  const succeeded = results
    .filter((r) => r.status === "fulfilled")
    .map((r) => r.value);
  const failed = results.filter((r) => r.status === "rejected");
  console.log(`${succeeded.length} ok, ${failed.length} failed`);
}

await inside a for-of loop runs iterations serially - each waits for the previous to finish. Promise.all with .map runs them in parallel. Know which you want.

const ids = [1, 2, 3, 4, 5];
 
// SERIAL - each request waits for the previous one
// Total time ≈ sum of all request times
async function serial() {
  for (const id of ids) {
    const user = await fetchUser(id);
    console.log(user.name);
  }
}
 
// PARALLEL - all requests in flight at once
// Total time ≈ slowest single request
async function parallel() {
  const users = await Promise.all(ids.map((id) => fetchUser(id)));
  users.forEach((u) => console.log(u.name));
}
 
// forEach CANNOT be awaited - the async callbacks fire and are ignored
async function broken() {
  ids.forEach(async (id) => {
    const user = await fetchUser(id); // awaited inside, but forEach doesn't wait
    console.log(user.name);
  });
  // this line runs before any user is fetched
  console.log("done?"); // prints immediately
}

Promise.race resolves or rejects as soon as any promise settles. It's commonly used to implement timeouts. Promise.any resolves on the first fulfilled promise (ignoring rejections), useful for racing redundant requests.

function withTimeout(promise, ms) {
  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms)
  );
  return Promise.race([promise, timeout]);
}
 
async function main() {
  try {
    const user = await withTimeout(fetchUser(1), 3000);
    console.log(user.name);
  } catch (err) {
    console.error(err.message); // "Timed out after 3000ms" if slow
  }
}

In production

Promise.all with an unbounded array is parallelism-by-accident. A service that maps over 500 IDs and fires all requests at once can exhaust a database connection pool or trigger rate limiting before the first response lands. The fix is bounded concurrency: libraries like p-limit let you specify a max-in-flight count, or you can implement a manual semaphore. The same applies to writes - a batch import that fans out 1 000 parallel inserts is a self-inflicted DoS. Always ask whether you need serial, bounded-parallel, or fully-parallel, and set an explicit limit for the latter two. See also the idempotency key pattern when retrying failed async operations is in scope.

Enjoyed this? Get more essays on software craft delivered to your inbox.

Subscribe free