TL;DR
Master asynchronous JavaScript with callbacks, Promises, and async/await. Learn to write non-blocking code and handle async errors.
Key concepts
- JavaScript promises
- async await JavaScript
- JavaScript asynchronous
- JS callbacks
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
By the end of this lesson you will write the capstone's storage layer in miniature: async functions that await a Promise-returning read, run independent reads concurrently instead of one after another, and return a safe fallback when a read fails instead of crashing the app. You arrive able to pass functions as arguments (16-array-methods) and to write a function that closes over the data it needs to resume later (07-closures-and-scope); you leave able to write code that waits without blocking.
This lesson is required for the final project. The Task Manager's storage layer — loadTasks() and saveTasks(), which return Promises, are awaited inside a try/catch, and fall back to an empty list when the read fails — is exactly the await-plus-fallback shape you build here, and you will assemble it in 15-capstone-project.
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. (setTimeout itself comes from the host — the browser, or Node — not from the JavaScript language: in Node the global is literally the timers module's setTimeout, and nothing in the ECMAScript specification defines a timer. What the language owns is Promise and the microtask queue its callbacks go on — the event loop that drains those queues belongs to the host too.)
"Later" is not one queue, though, and the difference decides the order of the two async tools in this lesson. A setTimeout callback is a macrotask: the event loop runs at most one of them per turn. A Promise callback — anything you pass to .then — is a microtask, and the event loop drains the entire microtask queue after the current synchronous code finishes, before it takes the next macrotask. So a .then scheduled with no delay at all still runs before a setTimeout(..., 0) that was scheduled first:
console.log("A: synchronous");
setTimeout(function () { console.log("C: setTimeout 0 (macrotask)"); }, 0);
Promise.resolve().then(function () { console.log("B: promise then (microtask)"); });
console.log("A2: synchronous");
// A: synchronous
// A2: synchronous
// B: promise then (microtask)
// C: setTimeout 0 (macrotask)
Both callbacks wait for the synchronous lines. Between the two of them, the microtask wins regardless of who was scheduled first.
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);
});
Here is the frame the next exercise asks you to fill. It does not run as written — the four links are missing, and putting them back is the exercise:
function pipeline(start) {
return Promise.resolve(start)
// the four .then links go here, in some order
}
pipeline(2).then((message) => console.log(message));
Promise.resolve(start) is the receiver: it wraps the number in an already-fulfilled promise so there is something to chain onto. Each link you add receives whatever the link above it returned, and the value that reaches the last one is what pipeline(2) resolves to — which is what the driver line at the bottom prints.
Arrange the code
Reassemble a .then chain. pipeline(start) takes a number, triples it, adds one, doubles that, and finally labels the answer — note that the labelling link is the one carrying the semicolon that ends the statement. Called with 2, exactly one arrangement prints 'result: 14'. Of the other 23, most do not run at all and the rest run cleanly and print a different number. Why must these four links run in this order and no other?
.then((value) => value * 3).then((value) => value * 2).then((value) => value + 1).then((value) => "result: " + value);
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?
A Three-Sentence try/catch Primer
The next section, and the build task at the end of this lesson, both need one piece of syntax you have not met yet: try/catch. Wrapping statements in try { ... } catch (error) { ... } means "run these statements; if any of them throws, stop there and run the catch block instead, with the thrown value bound to error" — so the program keeps going rather than crashing, and error.message tells you what went wrong. That is all you need here: 09-error-handling, the next lesson, goes properly into which errors to catch, when to re-throw, and how to build your own error types.
function parseCount(text) {
try {
const value = JSON.parse(text);
return value.count;
} catch (error) {
console.log("Falling back, because: " + error.message);
return 0;
}
}
console.log(parseCount('{"count": 7}'));
console.log(parseCount("not json at all"));
The reason it appears here and not only in the next lesson is that await is exactly where things go wrong in practice: an awaited Promise that rejects throws at the await, and try/catch is how you handle that without letting one failed read take down the whole app.
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();
Debug
This should print 'title: Task 7'. It runs, prints a line, and the line is wrong — the assert then reports what it actually got. Form a hypothesis about WHY before you change anything: at the moment the string is built, what kind of value is being concatenated?
const assert = require('assert');
function fetchTitle(id) {
return new Promise(function (resolve) {
setTimeout(function () { resolve("Task " + id); }, 10);
});
}
// Intent: build a one-line summary of the task.
async function describe(id) {
const title = fetchTitle(id);
return "title: " + title;
}
describe(7).then(function (line) {
console.log(line);
assert.strictEqual(line, "title: Task 7", 'got ' + line);
});Expected output:
title: Task 7
Transfer
Every async function in this lesson returns a Promise, and what that Promise resolves to is something you can only learn by reading the function body — lookupEmail resolves to a string, lookupAll to an array of strings, and safeLookup to a string OR null. New setting: TypeScript, where a Promise carries its resolved type with it as Promise<string>. Which statement carries the SAME idea across?
async function lookupEmail(id) { /* resolves to a string */ }
async function safeLookup(id) { /* resolves to a string, or null on failure */ }
async function lookupAll(ids) { /* resolves to an array of strings */ }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:
- Sequential vs. parallel. Rewrite
lookupAllas three separateawait getUser(...)statements instead ofPromise.alland reason about why the total time roughly triples — the parallel-vs-sequential trade-off from the Transfer question, now in your own code. - One bad id in the batch. Feed
lookupAll([1, 99, 3])and predict what happens —Promise.allrejects as soon as any one promise rejects. Then switch it toPromise.allSettledand 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.allruns multiple async operations in parallel;Promise.racesettles with the first promise to settle — resolving if that one resolved, rejecting if it rejectedasync/awaitmakes async code look synchronous and is the modern preferred approach- Always wrap
awaitcalls intry/catchfor 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
awaitwhen calling an async function. Withoutawait, you get a Promise object instead of the resolved value. If your code is logging[object Promise], check for a missingawait.
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.