The Complete Guide to Async JavaScript in 2026
A complete guide to asynchronous JavaScript — the event loop, callbacks, promises, async/await, error handling, running tasks in parallel, cancellation and common pitfalls — with examples throughout.
Asynchronous JavaScript is how the language does slow work — network requests, timers, file access — without freezing the page. JavaScript runs on a single thread, so instead of blocking, it schedules that work and continues. This complete guide covers the event loop, callbacks, promises, async/await, error handling and running work in parallel, with the common pitfalls to avoid.
The event loop, briefly
The main thread runs your synchronous code to completion, then the event loop pulls completed async work (a fetched response, an elapsed timer) from a queue and runs its callback. Promises use a higher-priority "microtask" queue, which is why a resolved promise’s .then runs before a setTimeout(…, 0).
console.log("1");
setTimeout(() => console.log("3 (macrotask)"), 0);
Promise.resolve().then(() => console.log("2 (microtask)"));
// prints: 1, 2, 3
Callbacks (and callback hell)
The original async pattern: pass a function to be called when the work finishes. It works, but nesting callbacks for sequential steps quickly becomes "callback hell" — hard to read and to handle errors in. Promises exist to fix this.
// nested callbacks get unwieldy fast
getUser(id, (err, user) => {
if (err) return handle(err);
getOrders(user, (err, orders) => {
if (err) return handle(err);
// ...deeper and deeper
});
});
Promises
A Promise represents a value that will be available later. It’s in one of three states — *pending*, *fulfilled*, or *rejected* — and you attach handlers with .then() (success), .catch() (error) and .finally() (always). Promises chain, flattening the nesting of callbacks.
fetch("/api/user")
.then(res => res.json())
.then(user => console.log(user))
.catch(err => console.error(err))
.finally(() => console.log("done"));
async / await
async/await is syntax over promises that lets you write asynchronous code that *reads* like synchronous code. An async function always returns a promise; await pauses inside it until a promise settles, then gives you the value.
async function loadUser(id) {
const res = await fetch(`/api/users/${id}`);
const user = await res.json();
return user; // the function returns a Promise<user>
}
Error handling
With async/await, wrap awaited calls in try/catch/finally — the same construct you use for synchronous errors. With raw promises, use .catch(). Either way, *always* handle rejections; an unhandled rejection is a bug (and crashes Node).
async function loadUser(id) {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`); // fetch doesn't reject on 404
return await res.json();
} catch (err) {
console.error("Failed to load user:", err);
throw err; // re-throw or return a fallback
}
}
Running tasks in parallel
When async tasks don’t depend on each other, run them at the same time instead of one after another. The Promise combinators do this:
Promise.all([...])— wait for all to fulfill; rejects as soon as any one rejects.Promise.allSettled([...])— wait for all to finish, successes and failures both.Promise.race([...])— settle as soon as the first one settles (useful for timeouts).Promise.any([...])— fulfill as soon as the first one fulfills (ignores rejections until all fail).
// ✅ parallel — all three run at once (~one request time)
const [user, orders, prefs] = await Promise.all([
fetchUser(), fetchOrders(), fetchPrefs(),
]);
// tolerate partial failure
const results = await Promise.allSettled([a(), b(), c()]);
Sequential vs parallel
A classic performance bug: awaiting independent tasks one at a time in a loop, so each waits for the last. If they don’t depend on each other, fire them together with Promise.all.
// ❌ slow — sequential, each awaits the previous
for (const id of ids) {
results.push(await fetchItem(id));
}
// ✅ fast — all requests in flight at once
const results = await Promise.all(ids.map(fetchItem));
Cancellation with AbortController
Long-running async work — a fetch, a stream — can be cancelled with an AbortController. Pass its signal to the operation and call abort() to stop it (and reject the promise). Essential for cleaning up when a component unmounts or a request is superseded.
const controller = new AbortController();
fetch("/api/search?q=react", { signal: controller.signal })
.then(r => r.json())
.catch(err => { if (err.name === "AbortError") return; throw err; });
controller.abort(); // cancels the request
Top-level await
In ES modules you can use await at the top level of a file — no wrapping async function needed. Handy for loading config or initializing before a module’s exports are used. It’s Baseline (widely available) in 2026.
// module scope — no async wrapper needed
const config = await fetch("/config.json").then(r => r.json());
export const apiUrl = config.apiUrl;
Common pitfalls
- Forgetting
await— you get a pending Promise instead of the value; the code "works" then mysteriously hasundefined. - Sequential awaits in a loop for independent work — use
Promise.allinstead. - Unhandled rejections — always
.catch()ortry/catch; they crash Node and hide bugs. - Assuming
fetchrejects on 404 — it doesn’t; checkresponse.ok. asyncinArray.forEach—forEachignores the returned promises; usefor…ofwithawait, orPromise.all(map(...)).
Frequently asked questions
What is the difference between promises and async/await?
async/await is syntax built on promises. .then()/.catch() chains explicitly attach handlers to a promise; async/await lets you write the same logic in a straight-line style with try/catch for errors, which is usually easier to read. An async function always returns a promise, and await unwraps one. Use async/await by default; use combinators like Promise.all for parallelism.Why does my async code run out of order?
await it; if code depends on an async result, put that code after the await or inside the .then().How do I run multiple async tasks in parallel?
Promise.all([...]), which resolves when every task fulfills (or rejects on the first failure). Use Promise.allSettled if you want all results even when some fail. The common mistake is awaiting tasks one at a time in a loop, which runs them sequentially — map the tasks to promises and pass the array to Promise.all instead.Why doesn’t fetch throw an error on a 404?
fetch only rejects its promise on network failures, not on HTTP error statuses. A 404 or 500 is still a "successful" HTTP exchange as far as fetch is concerned, so the promise fulfills. You must check response.ok (or response.status) yourself and throw if it’s not a success, otherwise you’ll try to parse an error response as if it were valid data.Experiment with async code live in XCODX Studio — run it in the browser and watch the order of execution. See also JavaScript best practices, how to speed up JavaScript and the complete guide to TypeScript.