Programming Guides
Writing Clean Async JavaScript with async/await
async/await makes asynchronous code read like synchronous code — which is exactly why it hides easy mistakes. Patterns for flow, errors, and concurrency.

async/await is one of the best ergonomics JavaScript has added. It also makes it trivially easy to write sequential code where you wanted concurrent code, to swallow errors you meant to surface, and to leak promises you never awaited. Here are the patterns that keep async code honest.
The sequential trap
The most common mistake is awaiting in a loop when you meant to run things in parallel:
// Sequential — slow
const results = [];
for (const id of ids) {
results.push(await fetchItem(id));
}
// Concurrent — fast
const results = await Promise.all(ids.map(id => fetchItem(id)));Promise.all starts every fetch at once and resolves when all finish. The sequential version waits for each before starting the next. For ten network calls, the difference is often an order of magnitude.
The rule of thumb: if the operations do not depend on each other, do not await them one at a time.
When you do need sequencing
Sometimes order genuinely matters — for example, each call depends on the previous result. Then a loop is correct, and await in the body is right, not a mistake. The trap is applying "use Promise.all" as an unthinking rule.
Handling errors without swallowing them
A bare try/catch around an await is fine, but two anti-patterns recur:
- Catching and logging only. If you catch an error and do nothing but
console.log, callers have no idea anything failed. Either rethrow, return a typed failure, or handle it genuinely. - Catching too broadly. Wrapping an entire function body in one
tryhides which step failed.
A cleaner pattern for orchestration:
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: Error };
async function safe<T>(p: Promise<T>): Promise<Result<T>> {
try {
return { ok: true, value: await p };
} catch (e) {
return { ok: false, error: e instanceof Error ? e : new Error(String(e)) };
}
}Now failures are values, not exceptions, and the type system forces callers to handle both branches.
Concurrency with limits
Promise.all is all-or-nothing and unbounded. For large lists you usually want bounded concurrency. A small helper:
async function mapWithConcurrency<T, U>(
items: T[],
limit: number,
fn: (item: T) => Promise<U>,
): Promise<U[]> {
const results: U[] = new Array(items.length);
let cursor = 0;
async function worker() {
while (cursor < items.length) {
const i = cursor++;
results[i] = await fn(items[i]);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}This runs at most limit operations at once, preserves order, and avoids the memory spike of starting a thousand promises simultaneously.
Takeaways
- Await in a loop only when each step depends on the previous.
- Make failures values when the caller needs to branch on them.
- Bound concurrency for large collections.
Async code that reads synchronously is a gift, provided you remember that reading-order and execution-order are now two different things.