Skip to main content

Async/Await in JavaScript: Practical Patterns and Pitfalls

async/await is the modern way to write asynchronous JavaScript. It reads like synchronous code but runs on promises. This guide covers real-world async patterns, how to avoid common mistakes, and how to make async code reliable. All examples are TypeScript and include the output you should expect.

Quick start​

async function getName(): Promise<string> {
return "Ada";
}

async function main(): Promise<void> {
const name = await getName();
console.log(name);
}

main();

Result:

Ada

Async checklist​

  • Always await the promise you care about.
  • Use try/catch for async errors.
  • Prefer parallel work when tasks are independent.
  • Limit concurrency when tasks are heavy.
  • Add timeouts for external calls.
  • Avoid top-level unhandled promises.
  • Choose the right Promise combinator for the result shape you need.

Async functions always return promises​

An async function wraps its return value in a promise. That means even a plain return 42 is a Promise<number>.

async function value(): Promise<number> {
return 42;
}

const result = value();
console.log(result instanceof Promise);

Result:

true

The event loop: tasks and microtasks​

JavaScript runs synchronous code to completion before it handles queued work. After the current task finishes, the microtask queue drains fully, then the browser or runtime moves to the next task, such as a timer.

console.log("sync 1");

setTimeout(() => {
console.log("timer task");
}, 0);

Promise.resolve().then(() => {
console.log("promise microtask");
});

queueMicrotask(() => {
console.log("queued microtask");
});

async function example(): Promise<void> {
console.log("async start");
await null;
console.log("after await");
}

void example();
console.log("sync 2");

Result:

sync 1
async start
sync 2
promise microtask
queued microtask
after await
timer task

The code before await runs synchronously. The code after await resumes in a microtask, so it runs after the already-queued promise and queueMicrotask callbacks but before the timer task. Browsers can render between tasks, not while JavaScript is still draining microtasks, so endless microtask chains can block rendering and user input.

Sequential vs parallel​

If tasks are independent, run them in parallel with Promise.all. If they depend on each other, keep them sequential.

Interactive demo​

Run the same three tasks sequentially, with Promise.all, or with Promise.race and compare wall-clock time:

Sequential vs parallel timeline

Run the same three fake tasks sequentially, with Promise.all, or with Promise.race.

task A
task B
task C
const task = async (id: number): Promise<string> => `task-${id}`;

async function parallel(): Promise<string[]> {
const values = await Promise.all([task(1), task(2), task(3)]);
return values;
}

parallel().then((values) => console.log(values));

Result:

[ "task-1", "task-2", "task-3" ]
async function sequential(): Promise<string[]> {
const a = await task(1);
const b = await task(2);
const c = await task(3);
return [a, b, c];
}

sequential().then((values) => console.log(values));

Result:

[ "task-1", "task-2", "task-3" ]

Async mapping with Promise.all​

When you need to transform a list with async work, map to promises and await the array with Promise.all.

async function enrich(id: number): Promise<string> {
return `user-${id}`;
}

async function mapAsync(): Promise<void> {
const ids = [1, 2, 3];
const values = await Promise.all(ids.map((id) => enrich(id)));
console.log(values);
}

mapAsync();

Result:

[ "user-1", "user-2", "user-3" ]

Promise.all is fail-fast: it rejects with the first rejection from the input promises. The other promises keep running; they are not cancelled automatically. Use AbortController or an API-specific cancellation mechanism when you need to stop the underlying work.

Error handling with try/catch​

Async errors are thrown just like sync errors, but only if you await the promise.

async function fails(): Promise<void> {
throw new Error("Request failed");
}

async function run(): Promise<void> {
try {
await fails();
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
console.log(message);
}
}

run();

Result:

Request failed

Avoid async in forEach​

Array.forEach ignores the promise returned by an async callback: the outer function does not wait, and any rejection becomes an unhandled promise rejection. Use for...of when order matters, or Promise.all(array.map(...)) when you want parallel work.

async function logIds(ids: number[]): Promise<void> {
for (const id of ids) {
const value = await Promise.resolve(`id-${id}`);
console.log(value);
}
}

logIds([1, 2, 3]);

Result:

id-1
id-2
id-3

Promise.allSettled for partial success​

Use Promise.allSettled when you want all results, even if some tasks fail.

const maybe = (id: number): Promise<string> => {
if (id === 2) return Promise.reject(new Error("Timeout"));
return Promise.resolve(`ok-${id}`);
};

async function runAll(): Promise<void> {
const results = await Promise.allSettled([maybe(1), maybe(2), maybe(3)]);
const output = results.map((r) =>
r.status === "fulfilled" ? r.value : `error: ${r.reason.message}`
);
console.log(output);
}

runAll();

Result:

[ "ok-1", "error: Timeout", "ok-3" ]

Promise.any for first success​

Use Promise.any when several alternatives can produce the same answer and the first successful one is enough. It ignores rejected promises until every promise has rejected.

const mirrors = [
Promise.reject(new Error("cdn-a failed")),
Promise.resolve("cdn-b"),
Promise.reject(new Error("cdn-c failed")),
];

async function firstSuccess(): Promise<void> {
const value = await Promise.any(mirrors);
console.log(value);
}

void firstSuccess();

Result:

cdn-b

If every promise rejects, Promise.any rejects with an AggregateError. Its errors property contains all rejection reasons in input order.

async function noneSucceeded(): Promise<void> {
try {
await Promise.any([
Promise.reject(new Error("cache failed")),
Promise.reject(new Error("network failed")),
]);
} catch (err) {
if (err instanceof AggregateError) {
console.log(err.message);
console.log(err.errors.map((error: Error) => error.message).join(", "));
}
}
}

void noneSucceeded();

Result:

All promises were rejected
cache failed, network failed
MethodResolves whenRejects when
Promise.allEvery promise fulfills; values preserve input orderThe first promise rejects; the rest keep running
Promise.allSettledEvery promise settlesNever rejects because of input promises
Promise.raceThe first settled promise fulfillsThe first settled promise rejects
Promise.anyThe first promise fulfillsEvery promise rejects, with AggregateError.errors

Timeouts with Promise.race​

Timeouts are essential for external calls. A simple pattern is to race your task against a timeout promise.

function timeout(ms: number): Promise<never> {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error("Timed out")), ms);
});
}

async function slowTask(): Promise<string> {
return "done";
}

Promise.race([slowTask(), timeout(100)])
.then((value) => console.log(value))
.catch((err: Error) => console.log(err.message));

Result:

done

Note: in real code, the timeout may win if the task is truly slow.

Cancellation with AbortController​

When working with browser APIs like fetch, use AbortController to cancel requests. This prevents wasted work and can improve UX.

// `ms` only bounds the time to get a response (headers) - clearTimeout runs as
// soon as fetch() resolves, so a slow response *body* is not covered here. Keep
// the timer running until you finish reading the body if you need that covered too.
async function fetchWithAbort(url: string, ms: number): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
return await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timer);
}
}

// The signal's `aborted` flag flips to true once abort() is called:
const controller = new AbortController();
controller.abort();
console.log(controller.signal.aborted);

Result:

true

Concurrency limits​

Launching too many promises at once can overwhelm APIs or your runtime. Use a simple limiter to cap concurrency.

async function runWithLimit<T>(
tasks: Array<() => Promise<T>>,
limit: number
): Promise<T[]> {
const results: T[] = new Array(tasks.length);
let nextIndex = 0;
const workerCount = Math.max(1, Math.min(limit, tasks.length));
const workers = Array.from({ length: workerCount }, async () => {
while (nextIndex < tasks.length) {
const currentIndex = nextIndex;
nextIndex += 1;
results[currentIndex] = await tasks[currentIndex]();
}
});
await Promise.all(workers);
return results;
}

const tasks = [1, 2, 3, 4].map((id) => async () => `job-${id}`);
runWithLimit(tasks, 2).then((values) => console.log(values));

Result:

[ "job-1", "job-2", "job-3", "job-4" ]

Avoiding unhandled rejections​

If you start a promise and do not await or catch it, errors become unhandled rejections.

function safeFireAndForget(task: Promise<void>): void {
task.catch((err) => {
const message = err instanceof Error ? err.message : "Unknown error";
console.log(`Unhandled: ${message}`);
});
}

safeFireAndForget(Promise.reject(new Error("boom")));

Result:

Unhandled: boom

Keep a clear async boundary​

Wrap top-level async code in a single main function so it is easier to handle errors in one place.

async function main(): Promise<void> {
console.log("start");
}

main().catch((err) => console.log(err instanceof Error ? err.message : "Unknown"));

Result:

start

Async iteration for streams​

If you are consuming a stream or paginated API, for await...of keeps code readable and memory usage low.

async function* numbers(): AsyncGenerator<number> {
yield 1;
yield 2;
yield 3;
}

async function consume(): Promise<void> {
const out: number[] = [];
for await (const n of numbers()) {
out.push(n);
}
console.log(out);
}

consume();

Result:

[ 1, 2, 3 ]

Common pitfalls​

  • Forgetting await: const value = getData() yields a promise, not data.
  • Using forEach with async: it does not await; use for...of.
  • Parallelizing dependent work: leads to race conditions.
  • Swallowing errors: avoid .catch(() => {}) unless you rethrow.
  • Unbounded concurrency: can cause rate limits or memory spikes.

Best practices​

  • Be explicit about parallelism: Promise.all vs sequential await.
  • Use allSettled for partial success and any when the first success wins.
  • Prefer small async functions for easier reasoning.
  • Document timeouts and retry policy.
  • Return typed results to avoid ambiguous promises.

FAQ: async/await​

How does async/await work in JavaScript?​

async functions always return a promise. await pauses execution until that promise resolves or rejects.

async function value(): Promise<number> {
return 42;
}

value().then((v) => console.log(v));

Result:

42

How do I run multiple async tasks in parallel?​

Use Promise.all for independent tasks. It rejects on the first failure, but it does not cancel the remaining work.

const a = Promise.resolve("A");
const b = Promise.resolve("B");
Promise.all([a, b]).then((values) => console.log(values));

Result:

[ "A", "B" ]

What is the safest pattern for async errors?​

Wrap the await in try/catch and normalize the error type.

async function safe(): Promise<void> {
try {
await Promise.reject(new Error("Bad"));
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
console.log(message);
}
}

safe();

Result:

Bad

Summary​

async/await makes async code readable, but you still need to be explicit about parallelism, microtask timing, timeouts, and error handling. Use Promise.all for all-or-nothing parallel work, allSettled for partial success, any for first success, and always make errors observable.