JavaScript async/await makes asynchronous code easier to read, but it does not decide when tasks should start, how many requests your API can handle, or what should happen when one request fails. Those decisions still belong in your application. A dashboard that loads three independent widgets and an import job that updates thousands of records need different strategies.
This guide builds from promise execution to a small concurrency limiter and a cancellable JSON request. You will learn how to choose sequential execution, Promise.all(), or Promise.allSettled(), and how to handle failures without silently losing work.
Before you start: Know functions, arrays, and basic promises. Save examples with top-level
awaitin an.mjsfile and run them with Node.js, or use a browser module. The cancellation examples requirefetch,AbortSignal.timeout, andAbortSignal.any. Example/api/...routes represent your application; they are not endpoints hosted by this blog.
1. How JavaScript async/await executes
An async function always returns a promise. Its body begins running immediately, up to the first await. Awaiting a promise suspends that function; the code that called it can continue. When the awaited operation settles, the suspended function resumes through a promise continuation.
Even an already fulfilled promise causes the code after await to run asynchronously. Run this example and compare the output with the comments. The async function reference and await control-flow documentation explain these execution rules.
async function inspectOrder() {
console.log("inside: before await");
await Promise.resolve();
console.log("inside: after await");
}
console.log("start");
const pending = inspectOrder();
console.log("outside");
await pending;
// start
// inside: before await
// outside
// inside: after await
That pause is local to the function. It does not freeze the entire browser or Node.js process. Conversely, putting a long calculation inside an async function still runs that calculation on the thread executing the function. For expensive CPU work, evaluate Web Workers in a browser or Node.js worker threads. Promises alone do not distribute calculations across cores.
2. Choose sequential or concurrent work
Start with dependencies. If loading orders requires the user's ID, the user lookup must finish first. Sequential awaits express that dependency clearly:
// Illustrative application functions; supply your own implementations.
async function loadAccount(email) {
const user = await findUser(email);
const orders = await findOrders(user.id);
return { user, orders };
}
A dashboard's profile and project list might be independent. Starting both requests before awaiting their combined result allows the network waits to overlap. The following example uses the getJSON helper introduced in section 5:
// These requests are independent and both are required.
async function loadDashboard() {
const [profile, projects] = await Promise.all([
getJSON("/api/profile"),
getJSON("/api/projects")
]);
return { profile, projects };
}
If two independent requests each take roughly 300 milliseconds, sequential execution spends roughly 600 milliseconds waiting. Concurrent execution can bring that closer to 300 milliseconds, plus overhead. This is an illustration, not a performance guarantee: connection limits, server load, and shared resources affect the result.
The calls to getJSON() initiate the requests. Promise.all() observes their returned promises. Passing the function itself, such as Promise.all([getJSON]), does not call it. Also attach the aggregate immediately when starting independent operations; creating promises and awaiting them separately can leave an early rejection temporarily unhandled. See async execution order.
3. Choose the right promise combinator
| Tool | Completion rule | Useful when |
|---|---|---|
Promise.all | All fulfill, or an input rejects | Every result is required |
Promise.allSettled | Every input settles; returns each outcome | Partial success is useful |
Promise.race | First input to settle determines the outcome | You need the earliest result or failure |
Promise.any | First fulfillment; rejects if all inputs reject | Any successful provider is enough |
Failing fast does not cancel sibling work. If a request inside Promise.all() fails, other requests can still complete and cause side effects. None of these combinators automatically cancels the underlying operations. The promise composition guide documents their different completion rules.
For optional dashboard widgets, collecting individual outcomes lets you show successful content while offering a retry for one failed widget:
const outcomes = await Promise.allSettled([
getJSON("/api/activity"),
getJSON("/api/recommendations")
]);
for (const [index, outcome] of outcomes.entries()) {
if (outcome.status === "fulfilled") {
console.log("Widget", index, outcome.value);
} else {
console.error("Widget", index, "failed:", outcome.reason);
}
}
allSettled() returns outcomes in input order, even when operations finish in a different order. Check status before reading value or reason; failures should remain distinguishable from an empty successful result. See the allSettled return value reference.
4. Fix async loops
forEach() does not wait for promises returned by its callback. An async callback can therefore leave the outer function finished while saves are still in progress, with callback rejections escaping the caller's error handling. Adding await before forEach() does not repair this because the method returns undefined. This behavior is documented in the forEach reference.
// Avoid: this function finishes before the saves finish.
async function saveAllIncorrectly(records) {
records.forEach(async record => {
await saveRecord(record);
});
}
// Sequential: each save completes before the next one starts.
async function saveAllInOrder(records) {
for (const record of records) {
await saveRecord(record);
}
}
// Concurrent: suitable only for a small, bounded collection.
async function saveSmallBatch(records) {
await Promise.all(records.map(record => saveRecord(record)));
}
Here, saveRecord represents your persistence function. Choose the sequential version when writes depend on previous writes or order matters. Choose the concurrent version only when the input is small enough for your service. Mapping 20,000 records creates 20,000 operations immediately; a promise aggregate is not a concurrency limit.
5. Handle async/await errors at a useful boundary
A request has several failure points: connecting to the server, receiving an unsuccessful HTTP status, reading the response body, and parsing its contents. fetch() can fulfill for HTTP 404 or 500, so check response.ok yourself. Reading JSON is also asynchronous and can fail after the headers arrive. See Using the Fetch API.
class HttpError extends Error {
constructor(status) {
super("Request failed with HTTP " + status);
this.name = "HttpError";
this.status = status;
}
}
async function getJSON(url, { signal } = {}) {
const response = await fetch(url, {
signal,
headers: { Accept: "application/json" }
});
if (!response.ok) {
// Discard the unused error body; preserve the HTTP error.
await response.body?.cancel().catch(() => {});
throw new HttpError(response.status);
}
return response.json();
}
This helper expects a successful response containing JSON. An endpoint returning an empty 204 response needs a different contract. Parsing JSON also does not validate its shape: check required fields before passing external data into application logic. In Node.js, supply an absolute URL; browser examples here use routes relative to the current site.
Let low-level helpers reject when they cannot produce their promised result. Catch at a boundary that can make a decision: an HTTP handler, an interface action, or a job runner. Returning an empty array from every catch block makes a failed service look like a valid empty database.
async function loadRequiredProfile() {
try {
return await getJSON("/api/profile");
} catch (cause) {
throw new Error("Could not load the required profile", { cause });
}
}
Inside this try block, return await matters: it allows the local catch to intercept the rejected request. Returning the promise directly would pass its later rejection to the caller instead. The original failure is retained as cause, so a boundary can log diagnostic context without replacing the useful underlying error. Avoid exposing raw responses, credentials, or personal data in user-facing error messages.
6. Limit concurrent work
For a larger collection, start a fixed number of workers. Each worker takes one item, waits for its mapper call, records the outcome, and then takes the next item. The function below preserves input order and continues after individual failures. It accepts an array of inputs, a positive integer limit, and a mapper that starts the operation.
async function mapSettledLimit(items, concurrency, mapper) {
if (!Array.isArray(items)) {
throw new TypeError("items must be an array");
}
if (!Number.isInteger(concurrency) || concurrency < 1) {
throw new RangeError("concurrency must be a positive integer");
}
if (typeof mapper !== "function") {
throw new TypeError("mapper must be a function");
}
const input = Array.from(items);
const results = new Array(input.length);
let nextIndex = 0;
async function worker() {
while (nextIndex < input.length) {
const index = nextIndex++;
try {
results[index] = {
status: "fulfilled",
value: await mapper(input[index], index)
};
} catch (reason) {
results[index] = { status: "rejected", reason };
}
}
}
const workerCount = Math.min(concurrency, input.length);
await Promise.all(
Array.from({ length: workerCount }, worker)
);
return results;
}
The index is claimed before each worker awaits. In this single JavaScript execution context, another worker cannot interrupt that synchronous claim, so each index is assigned once. Results are stored at their original index rather than pushed in completion order. The array is copied once so changes to the caller's array structure do not change the queue; contained objects are still shared references.
Try it without an API. The middle item fails quickly, but the final results still follow the original input order:
const outcomes = await mapSettledLimit(
[30, 5, 15],
2,
async (milliseconds, index) => {
await new Promise(resolve => setTimeout(resolve, milliseconds));
if (index === 1) throw new Error("Item unavailable");
return "item-" + index;
}
);
console.log(outcomes.map(outcome =>
outcome.status === "fulfilled"
? outcome.value
: outcome.reason.message
));
// ["item-0", "Item unavailable", "item-2"]
For API work, pass IDs or URLs and start the request inside the mapper: mapSettledLimit(urls, 4, url => getJSON(url)). Passing promises from requests you already started cannot throttle them. A mapper must return or await its complete operation; detached work escapes the limit.
This implementation bounds active mapper calls and keeps one result per input. It does not enforce requests per second, provide retries, or stop scheduling after a failure. It also waits indefinitely for a mapper that never settles. Use timeouts for network calls. For inputs too large to retain in memory, design a streaming queue with backpressure instead of collecting every result in an array.
7. Add timeouts and cancellation
A timeout should reach the operation doing the work. Racing a request against a timer only changes which promise you await; the request can continue afterward. Fetch accepts an abort signal, which gives the caller a way to stop waiting on that request and its response body.
Combine a caller's signal with a timeout signal so either can end the request. AbortSignal.any() uses the first signal that aborts, including its reason. AbortSignal.timeout() creates the timeout signal. In browsers its duration measures active time, so it is not a strict wall-clock deadline during suspension.
async function getJSONWithTimeout(
url,
{ signal, timeoutMs = 5000 } = {}
) {
const deadline = AbortSignal.timeout(timeoutMs);
const combined = signal
? AbortSignal.any([signal, deadline])
: deadline;
return getJSON(url, { signal: combined });
}
This extends the earlier getJSON helper, including JSON body reading. Here is an immediate cancellation example; an interface would call abort() from its Cancel action or cleanup handler:
const controller = new AbortController();
const request = getJSONWithTimeout("/api/report", {
signal: controller.signal,
timeoutMs: 5000
}).then(
data => console.log("Report:", data),
error => {
// Default abort() uses AbortError; a timeout uses TimeoutError.
if (error?.name === "AbortError") {
console.log("Request cancelled");
} else if (error?.name === "TimeoutError") {
console.log("Request timed out");
} else {
console.error("Request failed:", error);
}
}
);
// In a UI, call this from the Cancel button or component cleanup.
controller.abort();
await request;
A signal is one-shot: create a new controller for a new request. The example uses the default abort reason; callers supplying custom reasons may reject with values other than an Error. Treat cancellation as a distinct application outcome, and keep unexpected failures visible.
Check these methods in your target runtimes. Browser support for any() and timeout() became broadly available in 2024; older browsers may need a compatible implementation. Node.js documents timeout() from versions 16.14/17.3 and any() from 18.17/20.3. These are introduction versions, not recommendations to run those older releases. See the Node.js AbortSignal documentation.
Aborting a request does not roll back a server-side change that already happened. The worker pool above also keeps scheduling after item failures, including aborted requests. If your whole import must stop, add an explicit queue cancellation policy and define what happens to items that never started.
8. Test failures and ordering
A successful demo is only one case. First, copy mapSettledLimit into async-guide.mjs, add the checks below, and run node async-guide.mjs. These assertions exercise mixed outcomes, result order, empty input, and an invalid limit without network dependencies.
import assert from "node:assert/strict";
// Put mapSettledLimit above these checks in async-guide.mjs.
const results = await mapSettledLimit(
[1, 2, 3],
2,
async value => {
if (value === 2) throw new Error("expected failure");
return value * 10;
}
);
assert.equal(results[0].value, 10);
assert.equal(results[1].status, "rejected");
assert.equal(results[2].value, 30);
assert.deepEqual(await mapSettledLimit([], 2, value => value), []);
await assert.rejects(
mapSettledLimit([1], 0, value => value),
RangeError
);
console.log("Checks passed");
For an application test suite, also cover these behaviors:
- Concurrency: Hold mapper calls on controlled promises, count active calls, and confirm no additional item starts before a slot is released.
- Failure isolation: Make a mapper throw synchronously and another reject asynchronously; confirm subsequent items still run.
- Request handling: Test a valid JSON response, HTTP 500, malformed JSON, and a response that sends headers but stalls before completing the body.
- Cancellation: Test an already aborted signal, cancellation during body reading, and the timeout path. Confirm no stale result updates the interface.
Prefer explicit gates and assertions over tests that assume a particular request finishes within a few milliseconds. Short delays are convenient demonstrations, but overloaded test machines can make timing-only assertions unreliable.
9. Apply the patterns in a real application
Choose a concurrency limit from the resources the work consumes. Four workers is an example, not a universal safe setting. A database pool, an upstream service quota, and a browser's connection behavior can each impose a different constraint. Start with a modest limit, measure latency and error rate, then adjust against realistic traffic.
A per-request limit also multiplies across users and server instances. If 100 incoming requests each create four workers, the system can still create 400 active upstream operations. Shared services may need a central queue or a service-wide budget. A concurrency cap limits in-flight work; a rate limiter controls how frequently new work starts. Our Node.js API rate limiting guide covers that separate concern.
Retries need their own policy. Retrying a payment or an import write after a timeout can duplicate work when the server completed the first attempt. Use an operation's documented idempotency mechanism, bound the retry count, and respect the overall request budget. Record outcome counts and elapsed time so partial failures are visible to operators, not just hidden in a returned array.
For a dashboard, decide which widgets are essential and which may fail independently. For an import, decide whether to stop, skip, or report failed records. Those choices determine the promise pattern; the syntax follows from the behavior you want.
Common questions
Does await block JavaScript?
It suspends the current async function. Other work can run while the awaited operation is pending. Synchronous CPU work before or after the await still occupies its executing thread.
Does Promise.all run work in parallel?
It coordinates promises whose operations may overlap. It does not create worker threads or invoke functions passed as values. CPU parallelism requires an appropriate worker or process model.
When should I choose allSettled?
Use it when each outcome matters and partial success is useful, such as independent widgets or a batch report. It waits for every input to settle, so give operations that can hang a timeout.
Is a concurrency limiter also a rate limiter?
No. A worker can start another request as soon as the previous one finishes. Fast requests can still exceed a requests-per-second quota even with a small concurrency limit.
Try it in your next endpoint
Pick one handler that performs several asynchronous operations. Mark the dependencies, decide which results are required, and identify the boundary responsible for failures. Then add a concurrency cap or cancellation only where that behavior is needed, and test the failure paths before increasing traffic.
To apply these ideas in a complete service, continue with building a production Node.js API. If you are still learning request and response flow, start with what backend development means.