Async/await is syntax over Promises. An async function always returns a Promise, and `await` pauses only that async function until the awaited value settles. It does not block the JavaScript event loop or remove the need for rejection handling and cancellation.
// async function always returns a Promise
async function greet() {
return 'Hello!';
}
greet().then(msg => console.log(msg)); // Hello!
// await pauses until the Promise resolves
async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
console.log(user.name);
}
fetchUser(1);
Both approaches do the same thing - async/await is simply easier to read, especially when you have multiple sequential async steps.
// -- Promise chain --
function loadData() {
return fetch('/api/user/1')
.then(r => r.json())
.then(user => fetch(`/api/posts?userId=${user.id}`))
.then(r => r.json())
.then(posts => console.log(posts));
}
// -- Async / Await (same logic, cleaner) --
async function loadData() {
const userRes = await fetch('/api/user/1');
const user = await userRes.json();
const postsRes = await fetch(`/api/posts?userId=${user.id}`);
const posts = await postsRes.json();
console.log(posts);
}
Use try/catch to handle errors in async functions. Any rejected promise inside the try block will throw and be caught by catch. You can also use finally for cleanup.
async function loadUser(id) {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
const user = await res.json();
console.log('User:', user.name);
return user;
} catch (error) {
console.error('Failed to load user:', error.message);
} finally {
console.log('Request finished'); // always runs
}
}
loadUser(42);
Using await sequentially means each operation waits for the previous one to finish. When operations are independent, use Promise.all() with await to run them in parallel and save time.
// Sequential - total time = time1 + time2
async function sequential() {
const users = await fetch('/api/users').then(r => r.json());
const posts = await fetch('/api/posts').then(r => r.json());
return { users, posts };
}
// Parallel - total time = max(time1, time2)
async function parallel() {
const [users, posts] = await Promise.all([
fetch('/api/users').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
]);
return { users, posts };
}
Be careful when using await inside loops. Using await in a for loop runs iterations sequentially. To run all iterations in parallel, collect the promises first and use Promise.all().
const ids = [1, 2, 3, 4, 5];
// Sequential - each waits for the previous
async function loadSequential() {
for (const id of ids) {
const user = await fetchUser(id); // waits each time
console.log(user.name);
}
}
// Parallel - all fire at once
async function loadParallel() {
const promises = ids.map(id => fetchUser(id));
const users = await Promise.all(promises);
users.forEach(u => console.log(u.name));
}
// Note: forEach does NOT work with await - use for...of or map
// ids.forEach(async id => { ... }); // won't wait correctly
Calling an `async` function always returns a Promise. Returning a plain value fulfills that Promise, throwing rejects it, and returning another promise adopts that promise's eventual state. The function begins synchronously and runs until the first `await` that cannot continue immediately; its continuation is scheduled as a promise job after the awaited value settles.
`await` applies promise resolution semantics to its operand, so awaiting a plain value still yields asynchronously at the continuation boundary. Other synchronous code in the current job finishes first, then queued microtasks run before the browser selects a later task such as a timer. This ordering explains why replacing `.then()` with `await` can change stack traces and placement without making I/O synchronous.
Only the async function is suspended, not the JavaScript thread. The host can process other jobs while network, storage, or timers progress. CPU-heavy loops still block rendering and input because they do not yield merely because their containing function is async. Split work, use platform scheduling, or move appropriate computation to a worker.
Await operations sequentially when the next input depends on the previous result, when order is a business invariant, or when concurrency would exceed a protected dependency. Independent operations can begin together and then be awaited with `Promise.all`. Starting each promise before awaiting avoids an unnecessary request waterfall.
`Promise.all` fulfills with ordered results when every input fulfills and rejects when one rejects; the other underlying operations are not automatically cancelled. `Promise.allSettled` reports every outcome, `Promise.any` fulfills from the first success and rejects with an aggregate error when all fail, and `Promise.race` settles from the first settled input. Choose from the workflow contract rather than convenience.
Unbounded `map(async ...)` can launch thousands of operations at once. Use a concurrency limiter, worker pool, queue, or chunked loop that matches browser connections, API quotas, memory, and downstream capacity. Preserve item identity with each result so failures can be retried or reported without rerunning successful side effects.
A Promise has no universal cancel method. Cancellation must be supported by the underlying operation, commonly through `AbortController` and an `AbortSignal`. Pass one signal through every cancellable layer, stop starting new work after abort, and distinguish an intentional abort from a network or application failure in the user interface and telemetry.
A timeout wrapper that merely rejects after a timer does not stop the original fetch, parser, or database call. Combine a timer with abort support, clear the timer in `finally`, and account for the total deadline across retries and nested calls. An inner operation should receive less time than the outer request so cleanup and a useful response remain possible.
React components, route changes, search suggestions, and repeated submissions commonly make older results obsolete. Abort prior work or compare a request generation before applying the result. Otherwise a slow earlier response can overwrite newer state. Cancellation is cooperative, so code between awaited operations must still check the signal where meaningful.
Use `try` and `catch` around the operation whose failure you can handle. A broad catch that converts every error to `null` destroys the distinction between not found, unauthorized, invalid data, cancellation, and infrastructure failure. Add context with a cause when rethrowing, preserve the original stack, and handle an error at one ownership boundary instead of logging it repeatedly at every layer.
Retry only failures classified as transient and only when the operation is safe to repeat. Use bounded attempts, exponential backoff with jitter, a total deadline, and server guidance such as `Retry-After` where applicable. Mutating requests need an idempotency key or reconciliation rule because a timeout may occur after the server committed the change.
`finally` runs after fulfillment or rejection and is appropriate for releasing locks, ending loading state, clearing timers, and closing resources. Avoid returning from `finally` because it can replace the original result or error. When several resources are acquired, clean them up in reverse ownership order and make cleanup tolerate partial initialization.
Test async code by controlling the dependency that settles the Promise, not by sleeping for an arbitrary number of milliseconds. Stub network or storage boundaries with deferred promises so the test can assert loading state, trigger fulfillment or rejection, and then await the observable result. Fake timers help with backoff and deadlines, but promise microtasks may require a separate explicit flush supported by the test framework.
Cover success, validation failure, dependency rejection, timeout, abort, partial aggregate failure, retry exhaustion, and cleanup. Verify both returned results and durable side effects. A test that only expects a rejected Promise can miss a duplicated write that happened before rejection. For concurrent work, force completions in different orders to prove the code does not depend on accidental timing.
Instrument each owned operation with a stable operation ID, start time, attempt number, deadline, dependency, result class, and duration. Do not log the same error as new at every stack layer. The boundary that handles or reports the failure should attach context once, while traces and error causes preserve the chain. Avoid logging secrets or full response bodies simply because parsing failed.
At application boundaries, ensure every Promise has an owner. Event handlers, queue consumers, startup functions, and scheduled tasks should await or explicitly observe the work they launch. Prefixing a call with `void` can document intentional non-awaiting for a linter, but it does not handle rejection or provide lifecycle ownership by itself.
Top-level await is available in modules, but it can delay modules that depend on the current module. Use it for a genuine module initialization dependency, not for unrelated analytics or optional data. When startup can fail, expose a clear failed state, preserve the cause, and avoid leaving half-registered listeners or partially mutated global state.
Explore 500+ free tutorials across 20+ languages and frameworks.