Most async/await bugs are not caused by forgetting the keyword. They come from an unclear boundary between starting work, waiting for a result, checking success, and deciding what failure means. A Promise represents the eventual completion or failure of an operation; await makes a function pause at a chosen point, but it does not make the underlying work synchronous or automatically cancelable.
const user = await fetchUser();
const permissions = await fetchPermissions(user.id);
The syntax is readable, but the program is not simply “paused.” An async function executes synchronously until an await needs a pending Promise. It then yields control, resumes later, and returns a Promise to its caller. The official MDN reference emphasizes that every async function returns a Promise and that errors not caught inside it reject that Promise. 1
Understanding the timeline explains bugs that otherwise look random.
The word “await” is therefore easy to misread. It does not block the entire JavaScript runtime while a network server responds. It pauses the current async function and lets surrounding work continue. The caller receives a Promise, and the function resumes when the awaited operation settles. This difference explains why a button can still respond while one handler is waiting, but also why shared state can change before the continuation runs.
A reliable review asks three questions for every await: what Promise is being awaited, what work may happen while it is pending, and what state must still be valid when the function resumes?
It also helps to draw the request as a small timeline. Mark the moment the operation starts, the moment the UI state changes, the moment the Promise settles, and the moment the response is committed. If two timelines can overlap, name the rule for stale results, cancellation, or ordering. This is often clearer than adding more console.log() calls after the bug appears.
Checkpoint 1: the function call returns immediately
async function loadUser() {
return { id: 7, name: "Ari" };
}
const result = loadUser();
console.log(result instanceof Promise); // true
Even though the function returns an object in its source, the caller receives a Promise. await loadUser() extracts the fulfilled value inside another async context; .then() does the same through a callback.
This is the first common mistake: forgetting that async changes the function’s return contract. A caller that expects a plain object may read result.name and receive undefined because result is a Promise, not the user.
The same issue appears in tests and event handlers. A test that does not return or await the Promise may finish before the assertion runs. A callback that starts an async operation but does not handle its rejection can create an unhandled rejection after the surrounding function has already returned. Make the asynchronous boundary visible in the function signature and in the caller’s control flow.
Checkpoint 2: code before the first await runs now
async function loadDashboard() {
console.log("start");
const response = await fetch("/api/dashboard");
console.log("after response");
return response.json();
}
console.log("before");
loadDashboard();
console.log("after");
The likely order begins with before, start, and after; the continuation after the pending network operation comes later. The exact scheduling involves the Promise job queue, but the practical rule is simple: code above the first pending await runs during the call, while code below it runs after resumption.
This is why a loading flag should be set before the first await and cleared after the last one. It is also why a component may unmount while the request is pending. The resumed code must either check that the result is still relevant or use a cancellation policy. Timing bugs often come from assuming the screen, input, or selected record will be unchanged when the Promise settles.
This matters for loading flags, counters, and cleanup. Set a loading state before awaiting; clear it in a finally block so both fulfillment and rejection reach the cleanup path.
Checkpoint 3: fetch resolves for HTTP errors too
fetch() is Promise-based, but a 404 or 500 response does not automatically reject the Promise. The Promise can fulfill with a Response whose ok property is false. A response is also a stream: reading response.json() or response.text() consumes the body, so the code should choose the representation it needs and handle parsing failures separately.
A robust request helper can distinguish transport, protocol, and data errors:
async function requestJson(url, options) {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
try {
return await response.json();
} catch (error) {
throw new Error("The server returned invalid JSON", { cause: error });
}
}
The caller can then decide whether an authentication response needs a login flow, a missing resource needs an empty state, or malformed data needs an operational alert. Keeping these meanings separate prevents every rejection from becoming “Network error.”
A 404 may be an expected “not found” state in one feature and an operational error in another. The request helper can normalize transport behavior, while the feature decides what message or fallback the user should see. MDN’s Fetch documentation recommends checking the response before reading its body. 2
async function getJson(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
}
A network failure, an aborted request, and an HTTP error are different events. Give them different handling when the user experience depends on the distinction.
Checkpoint 4: sequential await may be accidental serialization
const profile = await getProfile();
const notifications = await getNotifications();
If the second request does not depend on the first, this waits for two durations in sequence. Start both operations before awaiting their results:
const profilePromise = getProfile();
const notificationsPromise = getNotifications();
const [profile, notifications] = await Promise.all([
profilePromise,
notificationsPromise,
]);
Promise.all() expresses a dependency rule: the combined operation fulfills only when every input fulfills, and rejects when one rejects. If partial success is useful, Promise.allSettled() may represent the requirement better.
Do not parallelize operations that depend on each other’s output, compete for a rate limit, or must happen in order. Concurrency is a design decision, not an optimization to apply mechanically.
There is a useful distinction between starting work together and awaiting results together. Promise.all([a(), b()]) calls both functions before waiting for the combined result. await a(); await b(); does not start b() until a() fulfills. When performance matters, inspect the dependency graph rather than rearranging keywords by intuition.
Checkpoint 5: a try block must cover the awaited operation
async function load() {
try {
const response = await getJson("/api/data");
return response;
} catch (error) {
console.error("Loading failed", error);
throw error;
}
}
The catch sees a rejection from the awaited Promise because await turns it into a throw at that point. If you start a Promise without awaiting or returning it, a surrounding try may finish before the rejection occurs:
async function broken() {
try {
getJson("/api/data");
} catch (error) {
// This may not see the asynchronous rejection.
}
}
Return or await the Promise when its failure belongs to the current operation. An unhandled rejection is often a missing connection in the Promise graph.
Checkpoint 6: multiple Promises need an explicit failure policy
const first = fetch("/api/first");
const second = fetch("/api/second");
const results = await Promise.all([first, second]);
If the first request rejects, Promise.all() rejects. The second request may still be running; rejection of the combined Promise does not automatically cancel every underlying operation. Decide whether remaining requests should continue, be ignored, or be aborted with a shared controller.
For independent cards on a dashboard, Promise.allSettled() can preserve each result. For a transaction-like operation, fail fast and show one consistent error. The correct combinator follows the product behavior, not the developer’s preference for shorter code.
For a search screen, a later request can finish before an earlier request. Without a request identity or abort policy, stale data may overwrite the latest result. One approach is to keep a sequence number and ignore responses that are no longer current; another is to cancel the previous request with an AbortController. The important part is that the UI policy is explicit rather than an accidental consequence of network timing.
For example, a typeahead search can keep the controller for the current query and abort it when the query changes. A dashboard may instead allow all cards to finish independently. A file upload may need progress and a retry policy. The same Promise primitives support all three, but the correct state machine is different. Describe the user-visible states before choosing the combinator.
The UI policy must be explicit rather than an accidental consequence of network timing.
Checkpoint 7: cancellation is not the same as failure
async function loadWithTimeout(url, milliseconds) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), milliseconds);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} finally {
clearTimeout(timer);
}
}
AbortController lets an application cancel one or more web requests. MDN documents that an aborted fetch can reject with an AbortError. 3 A canceled request may be normal when a user navigates away or types a new search query; do not show it as a server outage automatically.
Cancellation is cooperative at the API boundary. The controller signals the fetch, but application code still needs to clear timers, remove loading indicators, and avoid presenting a cancellation as a generic failure. Use finally for cleanup and decide whether an AbortError should be logged, ignored, or reported differently from a server error.
A timeout is also a product decision. A short timeout may protect an interactive screen but harm a slow upload; a long timeout may preserve a report request but leave a spinner that feels broken. Keep timeout values near the operation’s policy and include enough context in the error for the caller to decide what to show.
One final distinction is between a Promise that has settled and work that has been committed to the interface. The request may succeed while a component has already unmounted or while the user has selected a different record. Treat the response as an input to a state transition, not as permission to mutate whatever UI happens to be present. Keep an identity, version, or cancellation check at the commit point.
This timeline-based review is also useful in Node.js services and test runners. The runtime changes some APIs, but the questions about ownership, ordering, rejection, and cleanup remain the same.
The async bug checklist
| Symptom | Timeline question | Likely correction |
|---|---|---|
| Value is a Promise | Did the function have async? | await or return the Promise |
| HTTP error reaches success code | Was response.ok checked? | Throw on unexpected status |
| Two requests feel slow | Are independent awaits sequential? | Start both and use a combinator |
catch never runs | Was the Promise awaited or returned? | Connect it to the current chain |
| Old search overwrites new search | Can the old request finish later? | Abort or ignore stale results |
| Spinner never stops | Is cleanup guaranteed on rejection? | Use finally |
The timeline is the mental model: call, synchronous prefix, pending Promise, yield, resumption, fulfillment or rejection, and cleanup.
A useful test suite follows the same timeline. Test a successful response, a non-2xx response, a network rejection, malformed JSON, a timeout, and a stale response arriving after a newer request. Those cases exercise different parts of the Promise graph; one happy-path test cannot prove that the asynchronous policy is correct.
The official Promise reference describes a Promise as the eventual completion or failure of an asynchronous operation. 4 async/await improves the syntax for consuming that result, but the states and policies remain. async/await makes the control flow readable, but it does not remove the underlying Promise graph or the need to define concurrency, cancellation, and failure policies.
Next step: compare this request timeline with How JavaScript Promises and async/await work or review Introduction to APIs for Beginners for request-level examples.
Choose concurrency as an explicit policy
Sequential await is correct when the second operation depends on the first. It is accidental serialization when independent operations could start together. The concurrency method should match the failure policy, not just the desired speed.
| Situation | Best starting point | Failure behavior to expect |
|---|---|---|
| Second request needs the first result | Sequential await | The second operation starts after the first succeeds. |
| All independent tasks must succeed | Promise.all | The aggregate rejects when one input rejects. |
| Every outcome must be reported | Promise.allSettled | Fulfilled and rejected results are returned together. |
| First settled result matters | Promise.race | It does not automatically cancel slower work. |
| First fulfilled result is acceptable | Promise.any | It rejects only when all inputs reject. |
MDN documents these Promise states and concurrency methods [1]. Add an explicit timeout or AbortController when the underlying API supports cancellation; a Promise itself has no universal cancellation protocol.
Keep the error boundary around the awaited operation
A try block should cover the operation whose rejection you intend to handle. Check HTTP response status separately when using fetch: a completed HTTP request can still represent a 404 or 500 response. Then preserve enough context to diagnose the request without logging private data.
try {
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
throw new Error("Could not load the profile", { cause: error });
}For related explanations, see JavaScript classes, APIs for beginners, and common JavaScript mistakes. The goal is not to eliminate every failure; it is to make timing, ownership, and recovery visible.
Async review checklist
- Identify when each operation starts and when its result is needed.
- Choose sequential or concurrent execution deliberately.
- Check both Promise rejection and application-level HTTP failure.
- Keep the intended awaited operation inside the relevant
try. - Choose an aggregate method whose failure semantics match the task.
- Use cancellation support explicitly when the underlying operation provides it.

Alex Carter is the editorial name behind Vandutz Academy, a programming blog for beginners. Alex reviews and tests the examples and explanations published on the site, with a focus on making Python, JavaScript, web development, and developer tools easier to understand.