Asynchronous JavaScript: promises, async/await and fetch without the mystery
Why JavaScript needs asynchronous code, what a promise actually is, how async/await maps onto it, and a fetch() helper that handles the three ways a request can fail.
JavaScript runs on one thread. If a network request blocked that thread for 300 milliseconds, the page would freeze for 300 milliseconds: no scrolling, no clicks, no animation. So anything slow is asynchronous: you start it, hand over a function to run when it finishes, and carry on. Promises and async/await are the syntax that makes this bearable. This tutorial explains the model, then uses it for the thing you will do most, fetching JSON from a server, with error handling that actually covers the failure cases.
The event loop in one paragraph
The engine runs your code to completion, then looks at a queue of pending callbacks (a timer fired, a response arrived, the user clicked) and runs the next one, and so on forever. A callback never interrupts running code; it waits its turn. That is why setTimeout(fn, 0) runs after the current code finishes, and why a while loop that waits for a flag to change will never see it change: the code that would change it is stuck in the queue behind the loop.
console.log('one');
setTimeout(() => console.log('three'), 0);
console.log('two');
// one, two, threeWhat a promise is
A promise is an object that represents a value that is not there yet. It is in one of three states: pending, then either fulfilled with a value or rejected with a reason (usually an Error). Once settled it never changes. You attach handlers with .then() and .catch(), and each of those returns a new promise, which is what allows chaining:
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
wait(500)
.then(() => { console.log('half a second'); return 42; })
.then(value => console.log(value)) // 42
.catch(err => console.error(err))
.finally(() => console.log('done either way'));Two rules of chaining: a value returned from a then handler becomes the next promise's value; a promise returned from a handler is waited for and its result flows on. An exception thrown in any handler rejects the chain, and the nearest catch below it receives it. Promises you never catch produce an "unhandled rejection" warning in the console, and in Node.js a crash.
async and await
async/await is syntax over the same promises. An async function always returns a promise. Inside it, await pauses the function (not the thread; other code runs) until a promise settles, then gives you its value or throws its rejection. The chain above becomes ordinary-looking code:
async function demo() {
try {
await wait(500);
console.log('half a second');
const value = 42;
console.log(value);
} catch (err) {
console.error(err);
} finally {
console.log('done either way');
}
}
demo();try/catch works because await turns a rejection into a thrown exception. That is the whole reason to prefer async/await: error handling, loops and conditionals use the normal keywords instead of callback nesting.
fetch, and the three ways it fails
fetch(url) returns a promise for a Response. Reading the body is a second asynchronous step: response.json() returns another promise. Most tutorials show:
const data = await (await fetch('/api/tasks')).json();and that line hides three distinct failures:
- The request never completed. No network, DNS failure, CORS blocked, the user navigated away.
fetchrejects with aTypeError. This is the only case where the promise rejects. - The server answered with an error status. 404, 500, 403.
fetchfulfils: a response arrived.response.okis false (it is true only for 200 to 299). Code that does not check it will try to parse an HTML error page as JSON. - The body is not what you expected.
response.json()rejects with aSyntaxErrorif the body is not valid JSON, for example an empty body from a 204 or a proxy's HTML interstitial.
A helper that handles all three, and that every project ends up writing:
async function getJSON(url, { timeoutMs = 10000, ...options } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { ...options, signal: controller.signal });
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText} for ${url}`);
}
return await response.json();
} finally {
clearTimeout(timer);
}
}
// use
try {
const tasks = await getJSON('/api/tasks');
render(tasks);
} catch (err) {
if (err.name === 'AbortError') showMessage('The server took too long.');
else showMessage(`Could not load tasks: ${err.message}`);
}Notes on the helper. AbortController is how you cancel a fetch; without a timeout a request on a bad connection can hang for minutes. The return await inside the try is deliberate: with a plain return response.json(), a parse failure would happen after the function had already left the try block. finally clears the timer whether the request succeeded or not. Sending JSON is the same shape with a body:
await getJSON('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: 'Buy milk' }),
});Doing several things at once
Awaiting in sequence when the requests do not depend on each other wastes time. Start them all, then wait:
// slow: 3 round trips one after another
const a = await getJSON('/api/a');
const b = await getJSON('/api/b');
// fast: 1 round trip's worth of waiting
const [a, b] = await Promise.all([getJSON('/api/a'), getJSON('/api/b')]);
// tolerate failures individually
const results = await Promise.allSettled([getJSON('/api/a'), getJSON('/api/b')]);
for (const r of results) {
if (r.status === 'fulfilled') use(r.value); else console.warn(r.reason);
}Promise.all rejects as soon as any input rejects; allSettled waits for all and reports each. Promise.race settles with the first to settle, useful for timeouts if you cannot use AbortController. A for loop with await inside runs sequentially, which is sometimes what you want (rate limits) and sometimes a bug; array.forEach(async ...) does not wait at all, so use for...of or Promise.all(array.map(...)).
Pitfalls
- Forgetting
await.const data = getJSON(url)gives you a promise object, anddata.lengthisundefined. Editors can warn about unused promises; turn that on. - Not checking
response.ok. Failure 2 above. Everyone does it once. - Catching too broadly.
catch (e) {}that swallows everything hides bugs. At least log; ideally show the user something. - Race conditions in the UI. The user types "a", then "ab"; the "a" response arrives last and overwrites the "ab" results. Keep a request counter or abort the previous request when a new one starts.
- Top-level await works in ES modules (
<script type="module">) and in the console, but not in classic scripts; wrap in an async function there.
Exercise
Point getJSON at a public JSON address such as https://api.github.com/repos/nodejs/node and show the repository's description and star count on a page. Then break it on purpose, three ways: a wrong hostname, a URL that returns 404 (/repos/nodejs/does-not-exist), and a URL that returns HTML (https://github.com). Confirm each produces a different, readable message. Finally, set the timeout to 1ms and confirm the abort path works.
Summary
- One thread, a queue of callbacks; slow things are started and resumed later.
- A promise is a settled-once container;
awaitunwraps it and turns rejection into a throw. fetchrejects only for network failure; checkresponse.ok, and expect.json()to fail.- Run independent requests with
Promise.all; add a timeout withAbortController.