Live tracking means sensor pings arrive out of order, some fail entirely, and a ranger might cancel a search mid-flight. JavaScript was built for this chaos - it has a single-threaded event loop that never blocks, and Promises are the currency of async work.
By the end of this module you should be able to:
- Chain promises and centralize errors with
try/catch+async/await. - Use
Promise.all,allSettled, andracefor realistic concurrency. - Model timeouts and cancellation with
AbortControllerandAbortSignal. - Picture the event loop: microtasks vs macrotasks, and why order matters.
node module-06-async/demo/01-promise-fundamentalsA Promise represents a value that doesn't exist yet. It lives in one of three states:
- pending - the operation is in flight.
- fulfilled - it completed successfully, and the result is available.
- rejected - it failed, and there's a reason (usually an
Error).
function delay(ms) {
return new Promise((resolve) => {
setTimeout(() => resolve(ms), ms);
});
}
delay(1000).then((ms) => console.log(`Waited ${ms}ms`));resolve fulfills the promise. If you call reject(new Error('...')) instead, it moves to rejected state.
.then() returns a new promise. Chains read top-to-bottom like synchronous code - but each step waits for the previous one:
fetchZone('paddock-a')
.then((zone) => fetchDinos(zone.id))
.then((dinos) => dinos.filter((d) => d.danger > 5))
.then((dangerous) => console.log(dangerous))
.catch((err) => console.error('Something failed:', err));.catch() at the end handles any rejection from any step in the chain. Errors propagate automatically.
async/await is syntactic sugar over .then() chains:
async function getDangerousDinos(zoneId) {
try {
const zone = await fetchZone(zoneId);
const dinos = await fetchDinos(zone.id);
return dinos.filter((d) => d.danger > 5);
} catch (err) {
console.error('Failed:', err.message);
return [];
}
}await pauses execution inside the async function (but not the thread - the event loop keeps running other work). async functions always return a promise, even if you return a plain value.
node module-06-async/demo/02-concurrency-patternsSometimes you need to run multiple async operations at once. JavaScript gives you four combinators.
const [zoneA, zoneB, zoneC] = await Promise.all([
fetchZone('a'),
fetchZone('b'),
fetchZone('c'),
]);All three requests run in parallel. Promise.all resolves with an array of results when all succeed. If any promise rejects, the whole thing rejects immediately - fail fast.
const results = await Promise.allSettled([
fetchSensor('temp-1'),
fetchSensor('temp-2'), // this one fails
fetchSensor('temp-3'),
]);
// results: [
// { status: 'fulfilled', value: 22.5 },
// { status: 'rejected', reason: Error('timeout') },
// { status: 'fulfilled', value: 19.1 },
// ]You get the outcome of every promise, regardless of failures. Perfect for dashboards, batch imports, or any situation where partial success is useful.
async function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), ms),
);
return Promise.race([promise, timeout]);
}
const data = await withTimeout(fetchZone('paddock-a'), 3000);Promise.race resolves or rejects with whichever promise settles first. Combined with a timeout promise, you get a deadline pattern.
const controller = new AbortController();
async function longTask(signal) {
for (const item of items) {
if (signal.aborted) throw new Error('Aborted');
await processItem(item);
}
}
longTask(controller.signal);
// Later, from somewhere else:
controller.abort();AbortController creates a signal that you pass into async work. When you call .abort(), anything checking signal.aborted can bail out cleanly.
node module-06-async/demo/03-event-loopJavaScript is single-threaded - there's one call stack. Async work doesn't run on a second thread; it's scheduled via the event loop.
console.log('1 - sync');
setTimeout(() => console.log('2 - macrotask (setTimeout)'), 0);
Promise.resolve().then(() => console.log('3 - microtask (promise)'));
console.log('4 - sync');Output: 1, 4, 3, 2.
Why? After the synchronous code finishes, the engine drains the microtask queue (promise callbacks) before picking up the next macrotask (setTimeout callbacks). Microtasks always jump the queue.
The mental model:
- Run all synchronous code on the call stack.
- Drain the microtask queue (promise
.then/.catch,queueMicrotask). - Pick one macrotask (
setTimeout,setInterval, I/O callbacks). - Go to step 2.
You rarely need to think about this in daily code, but it explains why setTimeout(fn, 0) doesn't run "immediately" - it waits for all microtasks first.
| # | Folder | What you'll practice |
|---|---|---|
| 1 | exercises/01-retry-timeout |
Build withTimeout and runWithRetry - deadline + exponential backoff. |
| 2 | exercises/02-promise-allsettled |
Aggregate sensor results into a uniform summary using Promise.allSettled. |
| 3 | exercises/03-fetch-combine |
Fetch users & posts from a public API in parallel, combine into summaries. |
Run all module tests:
cd module-06-async/exercises/<exercise>/starter && pnpm install && pnpm testTeaching deck: from repo root run pnpm slides:06, or cd slides && pnpm dev.