Asynchronous JavaScript
|
This section documents modern ECMAScript and core browser JavaScript APIs — it is not tied to any specific framework or library (React, Vue, Angular, etc.). This content was generated with the assistance of AI and should be verified against the current ECMAScript specification and MDN documentation before relying on it in production, since JavaScript language features and browser API support continue to evolve. This section’s bibliography lists the reference material consulted while preparing these pages. |
Most real-world JavaScript is asynchronous: it waits for a timer, a user click, or a network response instead of
running start to finish in one uninterrupted burst. This page walks through the three layers JavaScript offers for
working with that — callbacks, Promises, and async/await — plus async iteration, which extends the
iterator protocol to streams of asynchronous values.
Callback-based async, the historical starting point
The oldest and most fundamental style of asynchronous JavaScript is the callback: a function you write and hand to another function, which invokes ("calls back") your function once some condition or event occurs. Timers are the simplest example:
// Call checkForUpdates once, 60 seconds from now
setTimeout(checkForUpdates, 60000);
// Call it every 60 seconds, until cancelled
const intervalId = setInterval(checkForUpdates, 60000);
clearInterval(intervalId); // stop the repetition
Browser event handling is callback-based too (see Events for the full picture):
document.querySelector('#confirm button.okay')
.addEventListener('click', applyUpdate);
And so is the older XMLHttpRequest API for making HTTP requests, which registers separate onload/onerror
callbacks for the eventual response:
function getCurrentVersion(callback) {
const request = new XMLHttpRequest();
request.open('GET', '/api/version');
request.onload = () => {
if (request.status === 200) callback(null, parseFloat(request.responseText));
else callback(request.statusText, null);
};
request.onerror = request.ontimeout = (e) => callback(e.type, null);
request.send();
}
Callbacks work, but they have two well-known problems: nesting a callback inside a callback inside a callback ("callback hell") becomes hard to read, and a thrown exception inside an asynchronous callback cannot propagate back to the code that started the operation — there is no longer a call stack connecting the two. Promises exist to fix both problems.
Promises
A Promise is an object representing the eventual result of an asynchronous operation. Rather than passing a
callback into a function, a Promise-returning function returns immediately with a Promise object, and the caller
registers callbacks on that Promise:
fetch('/api/user/profile').then(response => {
// called once the response's status/headers are available
});
States and terminology
A Promise is always in exactly one of three states:
-
pending — neither fulfilled nor rejected yet.
-
fulfilled — the operation succeeded; the Promise has a value.
-
rejected — the operation failed; the Promise has a reason (typically an
Error).
Once settled (fulfilled or rejected), a Promise’s outcome never changes.
Chaining, not nesting
Each call to .then() returns a new Promise, which lets sequential async steps be expressed as a flat chain
instead of nested callbacks:
fetch('/api/user/profile')
.then(response => response.json()) // returns a Promise for the parsed body
.then(profile => displayUserProfile(profile));
Nesting the second step inside the first’s callback would also work, but defeats the point — always return the
next Promise from a .then() callback so the chain stays flat.
Error handling: .catch() and .finally()
.catch(fn) is shorthand for .then(null, fn): it registers a handler for rejection. Because a rejection
"trickles down" a Promise chain until it finds a .catch(), the idiomatic pattern ends every chain with one:
fetch('/api/user/profile')
.then(response => {
if (!response.ok) return null; // treat as "logged out", not an error
return response.json();
})
.then(profile => profile ? displayUserProfile(profile) : displayLoggedOutPage())
.catch(error => {
console.error('Failed to load profile:', error);
displayErrorMessage('Something went wrong.');
});
.finally(fn) registers a callback that runs whichever way the Promise settles (with no argument, since it can’t
tell which) — the natural place for cleanup such as hiding a loading spinner.
A .catch() doesn’t have to sit only at the end of a chain — inserted mid-chain, it recovers from an error and
lets the rest of the chain continue with its return value:
startAsyncOperation()
.then(doStageTwo)
.catch(recoverFromStageTwoError) // swallows a stage-two error and continues
.then(doStageThree)
.catch(logStageThreeErrors);
Running Promises in parallel, or racing them
-
Promise.all(promises)— fulfills with an array of every result once all fulfill; rejects immediately if any rejects. -
Promise.allSettled(promises)— never rejects; resolves once every input has settled, with one{status, value}or{status, reason}object per input. -
Promise.race(promises)— settles as soon as the first input settles (fulfilled or rejected). -
Promise.any(promises)— fulfills as soon as the first input fulfills; rejects only if all reject.
const urls = ['/a.json', '/b.json', '/c.json'];
const bodies = await Promise.all(urls.map(url => fetch(url).then(r => r.json())));
Making your own Promises
Given an existing Promise-returning function, you can build another one just by chaining .then():
function getJSON(url) {
return fetch(url).then(response => response.json());
}
To create a Promise from scratch (typically wrapping a callback-based API), use the Promise constructor, which
synchronously invokes your function with resolve/reject callbacks that you call once the async work
completes:
function wait(durationMs) {
return new Promise((resolve, reject) => {
if (durationMs < 0) {
reject(new Error('duration must be non-negative'));
return;
}
setTimeout(resolve, durationMs); // resolves with undefined once the timer fires
});
}
Promise.resolve(value)/Promise.reject(reason) create an already-decided Promise — useful for handling a
synchronous special case inside a function that must always return a Promise.
async/await
async/await (ES2017) is syntax sugar over Promises that lets asynchronous code read like ordinary synchronous
code, without changing what actually happens at runtime — the code is still fully asynchronous.
-
Marking a function
asyncmakes it always return a Promise: a normalreturn valueresolves that Promise tovalue, and a thrown exception rejects it. -
Inside an
asyncfunction (only),await promisepauses that function untilpromisesettles: if it fulfills,awaitevaluates to the fulfillment value; if it rejects,awaitthrows that rejection, which ordinarytry/catchcan handle.
async function getUserProfile() {
try {
const response = await fetch('/api/user/profile');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
console.error('Failed to load profile:', error);
throw error; // still rejects the Promise getUserProfile() returns
}
}
await works with any expression that evaluates to a function call returning a Promise, and can be nested as
deeply as needed — but only inside another async function (or, in supporting environments, at a module’s top
level).
Awaiting multiple Promises concurrently
Sequential `await`s run one after another, even when the operations don’t depend on each other:
// Slow: the second fetch doesn't start until the first finishes
const a = await getJSON(urlA);
const b = await getJSON(urlB);
// Fast: both requests start immediately, and both are awaited together
const [a2, b2] = await Promise.all([getJSON(urlA), getJSON(urlB)]);
Worked example: fetching a REST API two ways
The same request, once with .then() chaining and once with async/await, both handling a failed or non-OK
response. See Networking for the full fetch() API surface (request
options, headers, streaming bodies) — this example focuses purely on the async-control-flow side.
// .then() chaining
function loadUser(id) {
return fetch(`/api/users/${id}`)
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.catch(error => {
console.error('Failed to load user:', error);
throw error;
});
}
// async/await
async function loadUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
console.error('Failed to load user:', error);
throw error;
}
}
Both versions reject/throw for a network failure or a non-2xx status; callers can use .catch() or their own
try/catch around either one identically, since an async function’s thrown errors surface as Promise
rejections either way.
Async iteration
Neither a single Promise nor a plain for…of loop is a great fit for a stream of asynchronous values (chunks
of a file, incoming WebSocket messages). ES2018 adds asynchronous iterators and a for await…of loop that
extend the synchronous iterator protocol to this case.
An asynchronously iterable object implements Symbol.asyncIterator (instead of Symbol.iterator), returning an
iterator whose next() method returns a Promise for a {value, done} result rather than the result directly.
for await…of calls that next(), awaits the returned Promise, and runs the loop body with the resolved value:
async function readAll(stream) {
for await (const chunk of stream) {
process(chunk);
}
}
Async generators
Just as a generator is usually the easiest way to implement a synchronous iterable, an async generator — a
function declared async function* — is usually the easiest way to implement an asynchronous one. It combines
await (to pause for an async operation) with yield (to produce a value), and every yielded value is
automatically wrapped in a Promise:
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function* clock(intervalMs, times = Infinity) {
for (let n = 1; n <= times; n++) {
await delay(intervalMs);
yield n;
}
}
async function run() {
for await (const tick of clock(1000, 5)) {
console.log(tick); // 1, 2, 3, 4, 5 -- one per second
}
}
Conceptually: an async generator is a regular generator function ( covered on the Iterators & Generators page) whose `yield`ed values also carry the ability to wait on a Promise before the next value is ready — the two features compose rather than being separate mechanisms.
Promise resolution and the microtask queue
Understanding when a .then() callback actually runs requires knowing about JavaScript’s event loop, which
schedules three kinds of work: the synchronous call stack, the microtask queue (Promise callbacks,
queueMicrotask()), and the macrotask queue (setTimeout, I/O, UI events). After every synchronous script (or
call-stack frame) finishes, the engine drains the entire microtask queue — including any new microtasks
scheduled while draining it — before running a single macrotask. This is why a Promise.resolve().then(…)
callback always runs before a setTimeout(…, 0) callback, even though both are "asynchronous":
The practical implication: Promise chains always resolve "as soon as possible" relative to timers and I/O
callbacks, which is why await-heavy code can still starve the UI thread if a microtask chain keeps scheduling
more microtasks without ever yielding to a macrotask.