Skip to editor content
learningjavascript.orglesson 10 of 25

Async and Promises

JavaScript is single-threaded, meaning it can only do one thing at a time. Yet the web is full of operations that take time: fetching data, waiting for user input, running timers. Asynchronous programming solves this by letting your code start a task and continue running while that task completes in the background. In this lesson you will learn the three main approaches to async code: callbacks, Promises, and async/await.

What You'll Learn

  • Why asynchronous code is necessary in JavaScript
  • How callbacks work and their limitations
  • The Promise constructor, .then(), and .catch()
  • How async/await simplifies asynchronous code
  • Error handling patterns for async operations

The Problem: Blocking Code

Imagine if your entire page froze every time it fetched data from a server. That would be a terrible experience. JavaScript avoids this by using an event loop that processes tasks without blocking. Let's see the difference:

// Synchronous: each line waits for the previous one
console.log("Step 1: Start");
console.log("Step 2: Process");
console.log("Step 3: Done");

console.log("---");

// Asynchronous: setTimeout schedules work for later
console.log("Step 1: Start");
setTimeout(function() {
  console.log("Step 2: This runs after the delay");
}, 1000);
console.log("Step 3: This runs immediately (doesn't wait!)");

Predict

Predict the order these three lines print — before you read on. The setTimeout delay is 1000ms.

console.log("Step 1: Start");
setTimeout(function () {
console.log("Step 2: This runs after the delay");
}, 1000);
console.log("Step 3: This runs immediately (doesn't wait!)");

The synchronous lines (Step 1, Step 3) run first, in order; Step 2 prints last. setTimeout does not pause the program — it hands its callback to the event loop to run after the current code finishes and the delay elapses. That "keep going, come back to it later" behavior is the whole idea of non-blocking async, and every tool in this lesson builds on it.

Callbacks

A callback is a function passed to another function to be called later. Callbacks were the original way to handle async operations in JavaScript:

function fetchUserData(userId, callback) {
  console.log("Fetching user " + userId + "...");

  setTimeout(function() {
    // Simulate data coming back from a server
    const user = { id: userId, name: "Alice", email: "alice@example.com" };
    callback(null, user); // Convention: first arg is error, second is data
  }, 1000);
}

function displayUser(error, user) {
  if (error) {
    console.log("Error:", error.message);
    return;
  }
  console.log("User found:", user.name + " (" + user.email + ")");
}

fetchUserData(1, displayUser);
console.log("Request sent, waiting for response...");

Callback Hell

When you need to perform multiple async operations in sequence, callbacks nest deeper and deeper. This is often called "callback hell" or the "pyramid of doom":

// Simulating sequential async operations
function step1(callback) {
  setTimeout(function() {
    console.log("Step 1 complete");
    callback("data from step 1");
  }, 500);
}

function step2(data, callback) {
  setTimeout(function() {
    console.log("Step 2 complete (received: " + data + ")");
    callback("data from step 2");
  }, 500);
}

function step3(data, callback) {
  setTimeout(function() {
    console.log("Step 3 complete (received: " + data + ")");
    callback("final result");
  }, 500);
}

// Nested callbacks become hard to read
step1(function(result1) {
  step2(result1, function(result2) {
    step3(result2, function(result3) {
      console.log("All done:", result3);
    });
  });
});

Debug

This callback chain should print all three step lines and finally 'Chain complete: all finished'. Instead it stops after Step 2 and never finishes — no error, no crash, it just stalls. Predict what it actually prints, then fix it.

// Watchdog: a dropped callback is otherwise silent, so if the chain never
// reaches the end we flip the exit code to prove the stall happened.
let finished = false;
process.on('exit', function () {
if (!finished) process.exitCode = 1;
});

function step1(callback) {
setTimeout(function () {
  console.log("Step 1 done");
  callback("result 1");
}, 100);
}
function step2(data, callback) {
setTimeout(function () {
  console.log("Step 2 done (got: " + data + ")");
  // something is missing here
}, 100);
}
function step3(data, callback) {
setTimeout(function () {
  console.log("Step 3 done (got: " + data + ")");
  callback("all finished");
}, 100);
}

step1(function (r1) {
step2(r1, function (r2) {
  step3(r2, function (r3) {
    console.log("Chain complete:", r3);
    finished = true;
  });
});
});

Expected output: Step 1 done Step 2 done (got: result 1) Step 3 done (got: result 2) Chain complete: all finished

Promises

Promises give you a flat alternative to nested callbacks. The pattern came from userland libraries first; TC39 standardized it in ES2015 so the language, rather than the DOM specification, would own it. A Promise is an object that represents the eventual completion or failure of an async operation:

// Creating a Promise
function fetchUser(userId) {
  return new Promise(function(resolve, reject) {
    console.log("Fetching user " + userId + "...");

    setTimeout(function() {
      if (userId > 0) {
        resolve({ id: userId, name: "Alice", role: "Developer" });
      } else {
        reject(new Error("Invalid user ID"));
      }
    }, 1000);
  });
}

// Using .then() and .catch()
fetchUser(1)
  .then(function(user) {
    console.log("Success:", user.name + " is a " + user.role);
  })
  .catch(function(error) {
    console.log("Error:", error.message);
  });

// Trigger the error path
fetchUser(-1)
  .then(function(user) {
    console.log("This won't run");
  })
  .catch(function(error) {
    console.log("Caught error:", error.message);
  });

Promise Chaining

Promises shine when you need to perform sequential async operations. Each .then() returns a new Promise, so you can chain them flat instead of nesting:

function delay(ms, value) {
  return new Promise(function(resolve) {
    setTimeout(function() { resolve(value); }, ms);
  });
}

delay(500, "Hello")
  .then(function(result) {
    console.log("Step 1:", result);
    return delay(500, result + " World");
  })
  .then(function(result) {
    console.log("Step 2:", result);
    return delay(500, result + "!");
  })
  .then(function(result) {
    console.log("Step 3:", result);
    console.log("Final result:", result);
  })
  .catch(function(error) {
    console.log("Something went wrong:", error.message);
  });

Promise.all and Promise.race

When you need to run multiple async operations in parallel, Promise.all waits for all of them. Promise.race settles as soon as the first one settles — and it adopts that promise's outcome, whatever it is. A fast rejection wins the race just as readily as a fast success, so a racing promise that rejects first makes the whole race reject:

function fetchData(name, time) {
  return new Promise(function(resolve) {
    setTimeout(function() {
      resolve({ name: name, time: time });
    }, time);
  });
}

// Promise.all: wait for ALL to complete
Promise.all([
  fetchData("Users", 800),
  fetchData("Posts", 500),
  fetchData("Comments", 1200)
]).then(function(results) {
  console.log("All data fetched:");
  results.forEach(function(r) {
    console.log("  " + r.name + " (took " + r.time + "ms)");
  });
});

// Promise.race: the first to SETTLE wins — success or failure
// (both of these resolve, so the fast one's value comes through)
Promise.race([
  fetchData("Fast Server", 300),
  fetchData("Slow Server", 2000)
]).then(function(winner) {
  console.log("Race winner:", winner.name);
});

Recall

Without scrolling up: a callback you pass to setTimeout still reads a variable declared in the surrounding function, even though it runs much later, after that function has already returned. What language feature makes that work, and what is a callback fundamentally?

Async/Await

async/await is syntactic sugar built on top of Promises. It lets you write asynchronous code that looks and reads like synchronous code:

function fetchProduct(id) {
  return new Promise(function(resolve, reject) {
    setTimeout(function() {
      const products = {
        1: { name: "Laptop", price: 999 },
        2: { name: "Phone", price: 699 },
        3: { name: "Tablet", price: 499 }
      };
      if (products[id]) {
        resolve(products[id]);
      } else {
        reject(new Error("Product not found"));
      }
    }, 500);
  });
}

// Using async/await
async function showProduct(id) {
  console.log("Looking up product " + id + "...");

  try {
    const product = await fetchProduct(id);
    console.log("Found: " + product.name + " - $" + product.price);
  } catch (error) {
    console.log("Error: " + error.message);
  }
}

// Sequential async calls that read top-to-bottom
async function showAll() {
  await showProduct(1);
  await showProduct(2);
  await showProduct(3);
  await showProduct(99); // Will trigger error handling
  console.log("All lookups complete");
}

showAll();

Compare when each fetch starts, choose the parallel version, then submit.

Two alternative asynchronous JavaScript functions

Transfer

Two independent user fetches each take about 250 ms. Which version overlaps them instead of taking about 500 ms?

// Version A
async function loadA() {
  const u1 = await fetchUser(1);
  const u2 = await fetchUser(2);
  console.log(u1, u2);
}

// Version B
async function loadB() {
  const [u1, u2] = await Promise.all([fetchUser(1), fetchUser(2)]);
  console.log(u1, u2);
}

Parallel Execution with Async/Await

Using await in a loop runs operations one after another. For parallel execution, combine async/await with Promise.all:

function fetchScore(player) {
  return new Promise(function(resolve) {
    const time = Math.floor(Math.random() * 1000) + 200;
    setTimeout(function() {
      const score = Math.floor(Math.random() * 100);
      resolve({ player: player, score: score });
    }, time);
  });
}

async function getScores() {
  const players = ["Alice", "Bob", "Charlie", "Diana"];

  console.log("Fetching scores in parallel...");
  const startTime = Date.now();

  // All requests fire at once
  const results = await Promise.all(
    players.map(function(player) { return fetchScore(player); })
  );

  const elapsed = Date.now() - startTime;
  console.log("All fetched in ~" + elapsed + "ms\n");

  results
    .sort(function(a, b) { return b.score - a.score; })
    .forEach(function(r, i) {
      console.log((i + 1) + ". " + r.player + ": " + r.score);
    });
}

getScores();

Try It Yourself

Reading about async is not the same as writing it. This is a build task: a small API client that reports its own pass/fail. You are given two Promise-returning backend calls — getUser(id) and getEmail(name) — and three empty async functions to finish. Run it as-is and it fails immediately, telling you which function is still missing. Implement each one until every check passes and it prints All checks passed.

The three functions reuse exactly what this lesson taught: await-ing one Promise then another (the chained lookups from Async/Await), wrapping an await in try/catch so a rejection returns a safe fallback instead of crashing, and running independent lookups concurrently with Promise.all (from Promise.all and Race). The starter already has the backend, the stubs, and the checks — you write only the logic inside each function.

Build

Finish the build. Three async functions are stubbed out and the checks below them fail until each one resolves to the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement the three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.

const assert = require('assert');

// The simulated backend. Do NOT change these.
const users = { 1: "Alice", 2: "Bob", 3: "Charlie" };
const emails = { Alice: "alice@test.com", Bob: "bob@test.com", Charlie: "charlie@test.com" };

function getUser(id) {
return new Promise(function (resolve, reject) {
	setTimeout(function () {
		if (users[id]) resolve(users[id]);
		else reject(new Error("User " + id + " not found"));
	}, 10);
});
}
function getEmail(name) {
return new Promise(function (resolve, reject) {
	setTimeout(function () {
		if (emails[name]) resolve(emails[name]);
		else reject(new Error("Email not found for " + name));
	}, 10);
});
}

// TODO 1: await the user's name, then await their email, and return the email.
//   lookupEmail(1) -> "alice@test.com"
async function lookupEmail(id) {
// your code here
}

// TODO 2: return lookupEmail(id), but if it rejects (unknown id), return null instead of throwing.
//   safeLookup(1)  -> "alice@test.com"
//   safeLookup(99) -> null
async function safeLookup(id) {
// your code here
}

// TODO 3: look up every id CONCURRENTLY and return the names in order.
//   lookupAll([1, 2, 3]) -> ["Alice", "Bob", "Charlie"]
async function lookupAll(ids) {
// your code here
}

// --- Build checks: these must all pass. Do not edit below this line. ---
async function main() {
assert.strictEqual(
	await lookupEmail(1),
	"alice@test.com",
	"lookupEmail(1) should await the user, then their email, and return it",
);
assert.strictEqual(
	await safeLookup(99),
	null,
	"safeLookup(99) should catch the rejection for a missing user and return null",
);
assert.deepStrictEqual(
	await lookupAll([1, 2, 3]),
	["Alice", "Bob", "Charlie"],
	"lookupAll([1, 2, 3]) should resolve all lookups concurrently and return the names in order",
);

console.log("All checks passed.");
console.log("Email for 1:", await lookupEmail(1));
console.log("Safe lookup of 99:", await safeLookup(99));
console.log("All names:", await lookupAll([1, 2, 3]));
}

main().catch(function (err) {
console.error(err.message);
process.exitCode = 1;
});

Expected output: All checks passed. Email for 1: alice@test.com Safe lookup of 99: null All names: [ 'Alice', 'Bob', 'Charlie' ]

Once it passes, try two variations and predict each before running:

  1. Sequential vs. parallel. Rewrite lookupAll as three separate await getUser(...) statements instead of Promise.all and reason about why the total time roughly triples — the parallel-vs-sequential trade-off from the Transfer question, now in your own code.
  2. One bad id in the batch. Feed lookupAll([1, 99, 3]) and predict what happens — Promise.all rejects as soon as any one promise rejects. Then switch it to Promise.allSettled and compare. Which one does the capstone's storage layer want when loading many saved items, and why?

Capstone milestone

Milestone — the storage layer. The capstone persists tasks through an async storage layer: reads and writes return Promises, and every access is wrapped in try/catch so a failure degrades gracefully instead of crashing. This lesson's API client is that pattern in miniature. Confirm you can build it.

Hint: You don't need the full capstone yet — this confirms the await-plus-try/catch storage skill the capstone's storage layer is built on. There, loadTasks() awaits the store and falls back to an empty list when the read fails.

  • Wrote an async function that awaits a Promise-returning operation and returns its result
  • Wrapped the await in try/catch so a rejection is handled, not thrown to the top
  • Used Promise.all to run independent async operations concurrently instead of one at a time
  • Returned a safe fallback (null or an empty result) from the catch branch instead of crashing

Key Takeaways

  • JavaScript is single-threaded but handles async operations through the event loop
  • Callbacks are functions passed to other functions, but nesting them creates callback hell
  • Promises represent eventual completion or failure and enable flat chaining with .then()
  • Promise.all runs multiple async operations in parallel; Promise.race settles with the first promise to settle — resolving if that one resolved, rejecting if it rejected
  • async/await makes async code look synchronous and is the modern preferred approach
  • Always wrap await calls in try/catch for proper error handling

Next Steps

Asynchronous code often needs to handle things going wrong, from network failures to invalid data. The next lesson dives deep into error handling, giving you the tools to write robust code that fails gracefully.

Pro Tip: A common mistake is forgetting to use await when calling an async function. Without await, you get a Promise object instead of the resolved value. If your code is logging [object Promise], check for a missing await.

Next lesson

Error Handling

Learn JavaScript error handling with try/catch/finally, custom error classes, and defensive programming techniques for robust code.

20 min