This article is published in English.
What async/await Really Guarantees, and What It Leaves to You
Understand what await actually suspends, how to avoid serialized requests, and why errors, cancellation, ordering and retries need designs beyond async/await.
A function full of await expressions reads like a script that runs line by line, and that impression is exactly where many asynchronous bugs begin. Slow dashboards, swallowed errors, stale search results and double-charged payments often come from expecting async/await to provide guarantees it never offered. Once you know the precise, fairly small contract behind the syntax, you can tell at a glance which behavior comes from the language and which behavior you still have to design.
Here is a function that looks unambiguously sequential:
async function loadDashboard(userId) {
const user = await fetchUser(userId);
const projects = await fetchProjects(userId);
const notifications = await fetchNotifications(userId);
return {
user,
projects,
notifications,
};
}
The natural reading is: fetch the user, wait, fetch the projects, wait, fetch the notifications, then return. That reading is not exactly wrong, but it hides the part that matters once the code needs to be fast, concurrent, cancellable or resilient to failure.
await says nothing about how the underlying work is performed. It only marks the place where the enclosing async function has to suspend until a promise settles. While that function is suspended, the runtime keeps processing other work. Many surprises come from attributing to async/await behavior that really belongs to promises, to the host environment, to an API such as fetch, to a concurrency strategy, or to the application itself.
await suspends one function, not the whole runtime
Consider this small program, which logs around a network call:
async function loadUser() {
console.log("A");
const user = await fetch("/api/user");
console.log("B");
return user;
}
console.log("1");
loadUser();
console.log("2");
Calling loadUser() starts executing its body right away, so "A" is logged first after "1". When execution reaches the await, the function does not block the thread while the request is in flight. It suspends itself and hands control back to its caller, which is why "2" appears before "B". Once the fetch promise settles, the rest of loadUser() is queued to resume, and only then is "B" logged. The resulting order is 1, A, 2, B.
So the correct translation of await is not "stop JavaScript here". It is closer to "everything after this line depends on this value, so continue this function later".
This holds even when the awaited value is already available. Awaiting a fulfilled promise, or a plain non-promise value, still defers the rest of the function to a later microtask instead of continuing inline, as MDN documents. That is why sprinkling seemingly harmless extra await expressions around can change scheduling: each one adds another microtask boundary.
The practical consequence is that you cannot understand a program by treating each await as a blocking call in a synchronous function. You have to track what has already been started, which functions are currently suspended, and what else is allowed to run before a given function resumes.
async does not move work off the main thread
The async keyword invites a second misconception. Look at this CPU-bound loop:
async function calculateTotal(items) {
let total = 0;
for (const item of items) {
total += expensiveCalculation(item);
}
return total;
}
Nothing about this is concurrent. Adding async does not put expensiveCalculation() on another thread, does not parallelize the loop, and does not prevent heavy synchronous work from blocking whatever else wants to run on the same thread.
What async changes is the function's return contract. Calling it always yields a promise, and returning an ordinary value fulfills that promise with the value. The ECMAScript specification describes async function evaluation in terms of a promise capability that becomes the function's result.
async function getNumber() {
return 42;
}
const result = getNumber();
console.log(result);
// Promise
Logging result shows a pending or fulfilled Promise, not 42. You only get the number by awaiting the promise or calling .then() on it.
Returning a promise does not make the body asynchronous, though. Everything before the first await runs synchronously at the moment of the call. If you place an expensive computation inside an async function and expect the UI to stay responsive, you will be disappointed: the function will eventually hand back a promise, but the heavy work still occupies the thread first. For genuinely CPU-heavy work, the tools are Web Workers in the browser, worker_threads in Node.js, or splitting the work into chunks.
In short, async/await is a way of writing promise chains that read top to bottom. It schedules continuations; it never makes blocking code run concurrently.
Where you place await can serialize independent work
Back to the dashboard loader:
async function loadDashboard(userId) {
const user = await fetchUser(userId);
const projects = await fetchProjects(userId);
const notifications = await fetchNotifications(userId);
return {
user,
projects,
notifications,
};
}
Assume fetchProjects() does not need user, and fetchNotifications() needs neither earlier result. The function still performs three independent requests one after another, because the second call is not even made until the first promise fulfills, and the third waits for the second. Total latency is the sum of all three:
fetch user
████████
fetch projects
███████████
fetch notifications
███████
This fix is usually described as "use Promise.all() to run them in parallel". A more precise description is that the independent operations must all be started before the function waits for any of them. Calling the three functions first creates three in-flight promises, and only then does the code wait for the combined result:
async function loadDashboard(userId) {
const userPromise = fetchUser(userId);
const projectsPromise = fetchProjects(userId);
const notificationsPromise =
fetchNotifications(userId);
const [user, projects, notifications] =
await Promise.all([
userPromise,
projectsPromise,
notificationsPromise,
]);
return {
user,
projects,
notifications,
};
}
The requests now overlap, and total latency is roughly that of the slowest one:
fetch user
████████
fetch projects
███████████
fetch notifications
███████
all required results available
Promise.all() takes a collection of promises and returns one promise that fulfills when every input has fulfilled. The result array keeps the order of the inputs, regardless of which operation finished first, so destructuring into user, projects and notifications stays correct.
Notice that what separated sequential from concurrent code was never the presence of await. It was dependencies. When one step truly needs the output of another, awaiting in sequence is the right choice:
const user = await fetchUser(userId);
const permissions = await fetchPermissions(user.role);
When there is no such dependency, waiting for each operation before starting the next adds latency and buys no correctness. So the useful code-review question is not "should this use Promise.all()?" but "which operations depend on earlier results, and which could already be running?"
Promise.all() coordinates results; it does not cancel work
Having discovered Promise.all(), many developers form a new assumption about what happens on failure. Take this version:
const [user, projects, notifications] =
await Promise.all([
fetchUser(userId),
fetchProjects(userId),
fetchNotifications(userId),
]);
If fetchProjects() rejects early, the combined promise rejects immediately with that reason instead of waiting for the rest. It is tempting to think the other two requests are now stopped. They are not. Rejection of the aggregate promise does not cancel anything that has already started, a point MDN states explicitly. The other operations keep running, and their eventual results are simply ignored.
With reads this mostly wastes resources. With writes it can be serious:
await Promise.all([
updateProfile(),
writeAuditLog(),
sendWebhook(),
]);
If writeAuditLog() rejects, updateProfile() may already have committed and sendWebhook() may already be on the wire. Catching the rejection does not undo either of them.
That is a system-design problem, not a syntax problem. If these steps must succeed or fail together, you need a real atomicity mechanism: a database transaction for changes in one database, or for remote systems some combination of compensating actions, idempotent operations and persisted workflow state. Promise.all() promises to tell you when a group of promises has settled. It does not promise rollback, cancellation, or that the side effects happen as one unit. Those guarantees must come from elsewhere.
A related tool is Promise.allSettled(), which waits for every input and reports each outcome individually. It is useful when you need to know exactly which steps succeeded, but it offers no rollback either.
try/catch only sees rejections that flow through it
One reason async/await caught on is that promise errors can be handled with familiar try/catch:
async function loadUser(userId) {
try {
const user = await fetchUser(userId);
return user;
} catch (error) {
console.error("Failed to load user", error);
throw error;
}
}
When the awaited promise rejects, the await expression throws the rejection reason inside the function, and the surrounding catch receives it just like a synchronous exception.
That does not mean try watches every asynchronous operation started inside its braces. Consider account creation:
async function createAccount(input) {
try {
await saveUser(input);
sendWelcomeEmail(input.email);
return { success: true };
} catch (error) {
console.error(error);
return { success: false };
}
}
saveUser() is awaited, so its failure lands in the catch. sendWelcomeEmail() is not. If it returns a promise that later rejects, the rejection never enters this control flow, because nothing awaits or returns that promise. createAccount() may already have returned { success: true } by the time the email fails, and the failure surfaces separately, typically as an unhandled rejection. In Node.js, unhandled rejections terminate the process by default in current versions, so this is not a cosmetic issue.
Skipping the await is not automatically a mistake. Sometimes the email is intentionally kept off the request path. In a production design, though, that kind of detached work usually goes to a durable queue instead of being launched as an unobserved promise. The real mistake is believing the try block creates an error boundary around future asynchronous work just because the call sits inside it visually.
The same trap appears at the call site. This caller looks protected, but the snippet is plain JavaScript without an await:
try {
loadDashboard(userId);
} catch (error) {
console.error("Dashboard failed");
}
loadDashboard() returns a promise immediately. Any asynchronous failure rejects that promise later, after the try block has already finished, so the catch never runs. The caller has to take part in the promise chain:
try {
await loadDashboard(userId);
} catch (error) {
console.error("Dashboard failed");
}
Alternatively, attach a rejection handler with .catch(). The underlying rule is simple once you stop viewing async functions as ordinary functions that happen to contain await: their callers receive promises, and errors travel along that promise contract.
Cancellation is a separate protocol
Picture a search field where the user types one character at a time:
r
re
rea
reac
react
A straightforward implementation fires a request for every keystroke, even while earlier ones are still pending:
async function search(query) {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`
);
return response.json();
}
Nothing in await says that the request for "r" should stop because the request for "react" is now the one that matters. The earlier request runs to completion even though the interface no longer needs it.
For fetch, you express cancellation with AbortController and its AbortSignal. This version aborts the previous request before starting a new one:
let controller;
async function search(query) {
controller?.abort();
controller = new AbortController();
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{
signal: controller.signal,
}
);
return response.json();
}
The controller exposes a signal that compatible APIs listen to. Aborting it tells fetch to stop, covering both the request itself and reading of the response body. One detail to plan for: the aborted call rejects with an AbortError, so whoever calls search() should recognize that error and ignore it rather than showing it to the user.
The phrase "compatible APIs" is important. Promises have no universal cancel operation, and await has no way to stop the thing it is waiting for. Cancellation only works if the operation you call supports it and passes the signal down to whatever does the real work.
Your own functions can adopt the same contract by accepting a signal and checking it between steps:
async function processFile(file, { signal }) {
for (const chunk of file.chunks) {
signal.throwIfAborted();
await processChunk(chunk);
}
}
signal.throwIfAborted() throws the abort reason if cancellation has been requested, so the loop stops before processing the next chunk. Cancellation is now an explicit part of the function's interface instead of something callers hope await will do for them. For finer granularity, you can also pass the signal into processChunk() so a long chunk can stop midway.
Timeouts follow the same logic. Racing an operation against a timer with Promise.race() lets the caller stop waiting, but the underlying operation carries on unless it was also told to stop. Stopping observation and stopping work are two different things.
Local ordering is not global ordering
Sequential await does guarantee order within one function:
await saveOrder(order);
await sendConfirmation(order);
sendConfirmation() is not even called until saveOrder() has fulfilled. That local guarantee, however, says very little about the rest of the system. Imagine two requests updating the same profile. One sends:
await updateProfile({
name: "Umar",
});
A few milliseconds later, another sends:
await updateProfile({
name: "Umar Dev",
});
Each caller correctly awaits its own update. None of that determines which write reaches the database last, how retries on either side interleave, whether the two requests are handled by different servers, or whether stale data ends up overwriting newer data.
The browser version of the problem is the classic search race. Request A starts before request B but finishes after it, and code that renders whatever comes back puts outdated results on screen:
const results = await search(query);
render(results);
No extra await can fix this. The application needs a rule about relevance or order: cancel older searches, tag requests with a version and drop outdated responses, compare identifiers before rendering, or enforce version checks where the data is stored. The boundary to remember is that await orders control flow inside one function; it creates no global ordering across independent asynchronous operations.
Retries expose what await never promised
Retries are where an incomplete mental model becomes expensive. Start with a payment call:
async function submitPayment(payment) {
const response = await fetch("/api/payments", {
method: "POST",
body: JSON.stringify(payment),
});
return response.json();
}
Suppose the request times out and a developer adds a naive retry:
try {
return await submitPayment(payment);
} catch {
return await submitPayment(payment);
}
This assumes the failed attempt did nothing. A timeout only means the client did not get a response in time. The server may have charged the card and lost the response, or the connection may have dropped after the side effect was already committed. Retrying blindly can charge the customer twice.
await cannot tell you whether a retry is safe. A promise reports whether this attempt produced a fulfillment or a rejection that the caller could observe. It does not report whether the remote system performed irreversible work before that outcome.
For side-effecting operations, retry safety has to be designed into the operation itself, usually through idempotency. The client generates a stable key once per logical payment attempt and sends it with every retry:
await fetch("/api/payments", {
method: "POST",
headers: {
"Idempotency-Key": paymentAttemptId,
},
body: JSON.stringify(payment),
});
The key only helps if the server honors it: it must record the key alongside the result and, when the same key arrives again, return the original outcome instead of charging again. The key also has to stay the same across retries of one attempt; generating a fresh one per request defeats the purpose. The implementation differs between systems, but the principle does not: retry semantics belong to the operation and the systems executing its side effects, not to async/await. Server-side handling is covered in understanding idempotency keys in Node.js POST endpoints.
A valuable habit in production JavaScript follows from this. Whenever an awaited call fails, keep two statements apart: "I did not receive a success" and "the operation definitely did not happen". They are not the same claim.
A smaller, more accurate mental model
You do not need to memorize the ECMAScript specification to reason well about async/await. You need a narrow contract: an async function returns a promise; its body runs normally until it reaches an await; the await suspends that function until the awaited value settles and then schedules the continuation.
Everything else is a separate question with its own answer:
- Several operations: when does each one start, and does it depend on another? This tells you whether sequencing is intentional or accidental.
- Failures: which promise carries the error, and does anything actually await or handle it? This tells you whether a
try/catchprotects what you think it protects. - Irrelevant work: does the API support cancellation, and does the signal reach the operation doing the work?
- Ordering: who owns the ordering guarantee, the function, the UI state, or the database?
- Retries: is it safe to repeat the operation after an ambiguous failure?
- Several side effects as one unit: which real atomicity or coordination mechanism provides that, since sequential
awaitcalls cannot?
Key takeaways
async/await is deliberately small. It makes asynchronous control flow readable, which is enormously valuable, but that same readability makes code look more synchronous than the system underneath really is. When you encounter an await, avoid reading it as "the program waits here". Read it as a prompt: this function is parked until a value arrives, so which operations are already in flight, what may run in the meantime, and which guarantees must your own design supply? That question matches what the syntax actually does, and it leads you straight to the design decisions that prevent production bugs.