Async/await makes asynchronous JavaScript readable, but readability hides real traps: awaiting inside a loop can turn a 50 millisecond operation into a 5 second one, a single missing await can swallow errors silently, and the wrong Promise combinator changes your failure behavior entirely. This guide covers how javascript async await code actually executes, when to run work sequentially versus in parallel, and the error handling patterns that catch failures before they reach production.
Async/await is syntax over Promises, not a new execution model. Every await suspends the function and schedules the rest of it as a microtask. The two decisions that matter in real code are whether operations should run one after another or concurrently, and whether a failure in one of them should cancel the rest or be reported alongside partial results.
What does await actually do at runtime?
An await pauses the current async function, yields control back to the event loop, and resumes the function as a microtask once the awaited Promise settles. It does not block the thread. Other requests, timers, and I/O callbacks continue running while your function waits.
This matters for debugging. Code that looks synchronous is not: anything between two await lines can interleave with other work. If you have worked through how the event loop orders callbacks, microtasks, and macrotasks, async/await is the same machinery with better syntax. For a deeper treatment of that ordering, see the earlier post on the JavaScript event loop and blocking vs non-blocking code.
async function loadProfile(userId) {
// These three lines look sequential but the browser/node
// processes other I/O between each await point.
const user = await getUser(userId);
const prefs = await getPreferences(userId);
const orders = await getOrders(userId);
return { user, prefs, orders };
}Should you await in loops or run requests in parallel?
If the operations are independent, awaiting them inside a for loop is almost always the wrong choice: each iteration waits for the previous request to finish, so total time is the sum of all requests instead of the time of the slowest one. Collect the Promises and pass them to Promise.all instead. Await in loops only when each iteration genuinely depends on the previous result, such as paginating with a cursor returned by the prior call.
// Wrong for independent calls: ~sum of all latencies
for (const id of ids) {
results.push(await fetchUser(id));
}
// Right: ~max of all latencies
const users = await Promise.all(ids.map(fetchUser));
// Correct use of await in a loop: cursor pagination
let cursor = null;
const rows = [];
do {
const page = await fetchPage(cursor); // next cursor depends on this call
rows.push(...page.items);
cursor = page.nextCursor;
} while (cursor);Parallelism has a cost on the other side too: firing 10,000 requests at once will exhaust sockets, connection pools, or rate limits. When the input list is large or unbounded, chunk it and process batches of 20 to 50 concurrently, or use a small concurrency limiter so throughput stays predictable.
Promise.all vs Promise.allSettled vs Promise.any vs Promise.race
The four combinators differ in what they resolve with and when they reject, and picking the wrong one is a common source of bugs. Promise.all fails fast and discards results you may still want. Promise.allSettled never rejects, which makes it the right default for reporting. Promise.any resolves with the first success, useful for hitting redundant endpoints. Promise.race resolves with the first settlement of any kind, success or failure, so it is best for timeouts.
| Combinator | Resolves when | Rejects when | Use for |
|---|---|---|---|
Promise.all | All input Promises fulfill | Any input rejects (immediately) | Operations where all results are required |
Promise.allSettled | All input Promises settle | Never | Batch jobs where you report per-item outcomes |
Promise.any | First input fulfills | All inputs reject (AggregateError) | Redundant mirrors, first-success reads |
Promise.race | First input settles (any outcome) | First input rejects | Timeouts, cancellation-style guards |
An explicit recommendation: default to Promise.all when every result is mandatory, switch to Promise.allSettled the moment partial success is acceptable, and implement timeouts with Promise.race plus an AbortController so the losing request is actually cancelled rather than left running. The MDN Promise documentation documents the exact settlement semantics for each method.
// Timeouts: race against a timer, and abort the real request
async function withTimeout(url, ms) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
const res = await Promise.race([
fetch(url, { signal: controller.signal }),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), ms)
),
]);
return res;
} finally {
clearTimeout(timer);
}
}How should you handle errors in async functions?
Errors thrown inside an async function reject that function's Promise, so try/catch around await is the primary tool for async error handling. The failure mode engineers miss is scope: a try/catch only covers Promises awaited inside it. If you start a Promise without awaiting it, its rejection escapes the block entirely.
async function saveUser(data) {
try {
await validate(data);
// BUG: not awaited, so rejection is not caught below
auditLog.write('user_saved', data.id);
await db.insert(data);
} catch (err) {
logger.error({ err }, 'saveUser failed');
throw err;
}
}Three practices prevent the common failures. First, await everything you intend to handle, and intentionally not-awaiting something should get a .catch(() => {}) or a comment explaining why fire-and-forget is safe. Second, attach a process-level safety net: process.on('unhandledRejection') in Node, or listen for unhandledrejection in the browser, so a stray rejection is logged rather than lost. Third, rethrow after logging at a boundary you control, because catching and stopping the error turns a loud failure into a silent partial write.
// Node safety net: log rejections nothing else caught
process.on('unhandledRejection', (reason) => {
logger.error({ reason }, 'unhandled promise rejection');
});For queued or background work the stakes are higher, because a rejected Promise inside a job handler can crash a worker or leave a job marked complete when it failed. The patterns for retrying and surfacing those failures are covered in the guide to Laravel queue best practices, and the same ideas about idempotency apply to any async job system.
Which mistakes cause real production incidents?
Four mistakes account for most async/await bugs in code review:
- Calling
Array.prototype.forEachwith an async callback.forEachignores the returned Promise, so the loop finishes before any awaited work completes, and rejections become unhandled. Usefor...ofwithawait, orPromise.allover amap. - Returning instead of awaiting inside try blocks.
return fetchX()in atryskips thecatch;return await fetchX()keeps it covered. - Forgetting
awaiton a call whose result is checked later. The value is a Promise, so truthiness checks and property reads behave strangely, and the rejection escapes. - Assuming order. Two awaited calls in different functions can interleave; anything requiring atomicity across awaits needs a lock or a transaction, not await ordering.
// forEach silently drops the Promises
ids.forEach(async (id) => {
await archive(id); // loop does NOT wait; rejections unhandled
});
// Fix A: sequential
for (const id of ids) {
await archive(id);
}
// Fix B: concurrent with per-item errors
const results = await Promise.allSettled(ids.map(archive));
const failures = results.filter(r => r.status === 'rejected');Frequently asked questions
Does async/await block the event loop?
No. In javascript async await execution, awaiting a Promise suspends only the current async function and schedules its continuation as a microtask, leaving the thread free for other I/O. What does block the event loop is synchronous CPU work inside an async function, such as heavy JSON parsing or image resizing. Move that kind of work to a worker thread or a background job rather than wrapping it in an async function.
What happens if I forget await on an async call?
The function runs anyway, but your code does not wait for it and any rejection becomes an unhandled promise rejection. Result values arrive as a Promise object instead of the resolved data, so downstream checks misbehave. Enable lint rules such as no-floating-promises in TypeScript or ESLint's no-return-await companion rules to catch these mechanically.
When should I use Promise.all instead of awaiting sequentially?
Use Promise.all whenever the calls are independent and all results are required, because it finishes in the time of the slowest request instead of the sum of all of them. Ten calls averaging 100 milliseconds take about 100 milliseconds concurrently versus roughly one second sequentially. Switch to allSettled when partial success is acceptable, and to sequential awaits only when each call depends on the previous result.
