Home / Articles

Await Sees Only One Promise: Designing Async/Await APIs That Own Their Work

What JavaScript async/await actually observes, why detached work, callbacks, streams and Promise.all slip past it, and how to design Promises that mean done.

Publisher: REACTAPP.TOP13 min read3003 words

This article is published in English.

Sources: 90% of Developers Don’t Understand What JavaScript `await` Actually Waits For

It is easy to read await as "wait until this function is done", yet the runtime makes a much narrower promise: it evaluates one expression, treats the result as a Promise-like value, and suspends the current async function until that single value settles. Everything else depends on how that value was produced. That gap explains why code with carefully placed await keywords can still return too early, run slower than necessary, swallow errors or act on stale data. After working through the cases below, you will be able to look at any await and ask the one question that matters: does this Promise actually represent the work I care about?

The problem: awaited code that looks finished but is not

The bugs in this area share a pattern. A developer awaits a function and assumes everything that function kicked off has completed. They await an array method and assume every async callback has run. They await a timer setup, an event subscription or a stream write and believe the underlying operation is over. The code reads correctly because await is right there.

But await has no notion of "the task", "the request" or "everything this function started". If the expression on its right does not cover the full lifecycle of the work, nothing about the keyword can stretch to cover it. Keeping that in mind changes how you design APIs, loops, background jobs, event-driven code and error handling.

The call happens before the waiting

Take the most ordinary line in async JavaScript.

const user = await loadUser();

The runtime does not see await first and then politely ask loadUser to run asynchronously. It evaluates loadUser() exactly like any other function call. That call produces a value, which might be a native Promise, a custom thenable, a plain object, a string, undefined or anything else. Only after that does await get to work on the result.

The consequence is that the actual operation usually starts during the call, before any waiting begins. If you invoke two async functions and hold on to their Promises before awaiting either one, both operations get under way almost simultaneously:

const userPromise = loadUser();
const settingsPromise = loadSettings();

const user = await userPromise;
const settings = await settingsPromise;

Here loadUser() starts its work and hands back a Promise, then loadSettings() starts its own. The two await lines merely collect the results as they become available.

Compare that with the version most people write by default:

const user = await loadUser();
const settings = await loadSettings();

Now loadSettings() is not even invoked until the first Promise has settled and the function has resumed. The two requests run one after the other.

Notice that await itself made no decision about parallelism. The position of the function calls did. A compact way to hold this in your head: calling an async function starts the operation; awaiting decides when the current function continues. One caution with the first pattern: if settingsPromise rejects while you are still waiting on userPromise, that rejection is briefly unhandled, which some runtimes report as an error. Promise.all avoids the issue by observing both Promises at once. For a deeper comparison of these shapes, see choosing between Promise.all, Promise.race and sequential awaits.

Settlement of one Promise, not every side effect

Consider an account-creation function that stores a record, fires off a welcome email without awaiting or returning it, and then returns the account.

async function createAccount(input) {
  const account = await accountRepository.save(input);

  sendWelcomeEmail(account.email);
  return account;
}

A caller then writes the obvious line:

const account = await createAccount(input);

This caller resumes when the Promise returned by createAccount fulfills, which happens after the save completes and the function returns. It tells you nothing about the email. sendWelcomeEmail created a separate asynchronous operation whose Promise was never linked to the one createAccount returns, so from the caller's point of view that work does not exist.

This is the heart of the misunderstanding. await follows only the chain of Promises reachable from the value it was given. It does not scan for everything the function started, and it certainly does not wait for the process to go idle.

The practical fallout is threefold:

  • If the email Promise rejects, the caller's try/catch will never see it.
  • If the process shuts down, the email may simply never go out.
  • If an HTTP handler responds with success, that success says nothing reliable about the detached work.

None of this means every side effect has to finish inside the request. Plenty of work legitimately belongs in the background. The real defect is unclear ownership. Work that matters should be returned and awaited, explicitly marked and handled as optional, or handed to a durable queue or job system. Creating a Promise and letting it fall on the floor is not background processing; it is hoping.

An async function can settle while its children are still running

A related assumption is that the async keyword makes JavaScript track every asynchronous operation started inside the function. It does not. An async function produces exactly one Promise, and that Promise is tied only to what the function explicitly returns or awaits along its own control flow. Anything else can keep running after that Promise has settled.

The classic illustration uses forEach:

async function processUsers(users) {
  users.forEach(async (user) => {
    await updateUser(user);
  });
}

Each invocation of the async callback returns a Promise, but forEach throws those return values away. It does not collect them, wait for them or surface them; it returns undefined. So processUsers reaches its closing brace almost instantly, and because an async function that runs off its end resolves with undefined, the caller continues long before any update has finished:

await processUsers(users);
console.log("All users processed");

The log line is simply false. The caller waited for processUsers to finish scheduling callbacks, not for the callbacks' work.

To fix it, the child Promises have to be wired into the parent operation. Depending on the situation that means a for...of loop with await inside for strict sequencing, Promise.all(users.map(updateUser)) for full parallelism, or a concurrency limiter when the collection can be large enough to overwhelm a database or API. The broader rule reaches well beyond forEach: a parent Promise waits only for the child work that is part of its chain. Tools such as the no-floating-promises and no-misused-promises rules in typescript-eslint catch many of these mistakes automatically.

Callback APIs are invisible to await

A large part of the JavaScript ecosystem predates Promises and reports completion through callbacks. That leads to code like this:

await setTimeout(() => {
  console.log("Timer completed");
}, 1000);

It does not pause for a second. setTimeout registers the callback and immediately returns a timer handle (a number in browsers, a Timeout object in Node.js). That handle is not a Promise describing when the timer fires. await receives an ordinary value, treats it as already available and resumes the function on the next microtask, long before the callback runs.

The same trap appears with event listeners, older file-system APIs, database drivers, streams and hand-rolled SDKs. If a function returns a subscription, a request handle, a boolean or an event emitter, awaiting that return value does not turn the eventual callback into Promise completion.

To await callback-based behaviour, you need a Promise that resolves or rejects exactly when the callback reports the outcome:

function delay(milliseconds) {
  return new Promise((resolve) => {
    setTimeout(resolve, milliseconds);
  });
}

await delay(1000);

Now the awaited value genuinely stands for the event you care about: the Promise stays pending until the timer invokes resolve. Modern platforms often ship such wrappers already; Node.js, for example, offers a Promise-based setTimeout in node:timers/promises and util.promisify for callback-style functions.

This exposes the design question hiding behind every await: does the returned value represent completion, or merely registration, startup or a handle to work still in progress? If it is one of the latter, awaiting it will not wait for the result you are imagining.

Thenables, plain values and the microtask boundary

await does not insist on a genuine Promise instance. If the value is a thenable, meaning an object with a callable .then method, await adopts that object's settlement. That is what lets Promise-compatible libraries and custom async objects plug straight into async functions, and it means an object can behave asynchronously without ever going through new Promise.

For values that are not thenables, await still inserts an asynchronous step:

async function example() {
  console.log("before");
  await 42;
  console.log("after");
}

console.log("start");
example();
console.log("end");

This prints start, before, end and only then after, even though 42 was available immediately. The function yields at the await, the synchronous code after the call continues, and the remainder of example runs later as a microtask.

So awaiting a plain value is not synchronous. It can reorder your code relative to surrounding synchronous statements and other queued microtasks, and in ordering-sensitive or hot paths an unnecessary await adds a scheduling boundary. The delay is normally negligible in performance terms, but the conceptual point stands: code after await never continues on the same uninterrupted call stack.

Rejection is part of settling

People often say await waits for a Promise to resolve. It is more accurate to say it waits for the Promise to settle, and settling includes rejection. When the awaited Promise rejects, await throws the rejection reason inside the current async function, which is what makes ordinary try/catch work for asynchronous failures:

try {
  const order = await loadOrder(orderId);
  return order;
} catch (error) {
  logger.error(error);
  throw error;
}

That protection only applies if the rejecting Promise is actually awaited inside the try block. A Promise created and left floating escapes it entirely:

try {
  loadOrder(orderId);
} catch (error) {
  console.error("This does not catch the Promise rejection");
}

Here the call itself succeeded: it returned a Promise without throwing. The rejection arrives later, through that Promise, after the try block has long since exited.

This is particularly dangerous when await is dropped on purpose to make something "non-blocking" while the developer still believes the surrounding try/catch guards it. It does not. Every asynchronous operation needs a clear owner for its failure: a caller that awaits or returns it, an explicit .catch handler, or a durable system that takes responsibility. A Promise with no owner is not a faster architecture; it is an error path cut off from the code that understands what went wrong. The article on what async/await really guarantees explores the same ownership idea from the error-handling side.

Promise.all coordinates results, not outcomes

Promise.all is usually summarised as "wait for several Promises together". That is fine as a first approximation, but the details matter. It returns a new Promise that fulfills once every input fulfills and rejects as soon as any single input rejects.

const results = await Promise.all(tasks);

Even here, await has a single target: the aggregate Promise that Promise.all created.

When an early task fails, that aggregate fails with it and your function heads down its error path. The remaining tasks are not cancelled. They keep running and may still produce side effects after the caller has already written the batch off as failed. Picture a batch that fires several external updates: one fails immediately, the caller reports "batch failed", and meanwhile the other updates quietly succeed, leaving the system half-changed.

Promise.all gives you coordination of results, not transactions or cancellation. Choose the tool that matches the semantics you need:

  • If partial success is acceptable, collect every outcome (for example with Promise.allSettled) and inspect each one.
  • If you need all-or-nothing behaviour, the underlying operations need a genuine rollback or compensation mechanism.
  • If the remaining work should stop after the first failure, each operation must support cancellation, typically through a shared AbortSignal.

The combined Promise cannot undo work that has already begun. Its settlement means only what its construction allows it to mean.

Streams and long-running resources

Streams are another frequent source of confusion. A stream's write method may return a boolean that says whether the internal buffer can take more data. A .pipe() call returns the destination stream. An event-based parser may return as soon as its listeners are attached. Awaiting any of those values does not wait for the stream to finish, flush, close or write every byte.

Completion is usually signalled through events such as finish, end, close or error, and to await it reliably those events must be turned into a Promise. Many libraries, and Node.js itself through pipeline in node:stream/promises, provide helpers that produce a single Promise whose settlement covers the whole pipeline, including its errors.

The principle generalises to any long-lived resource:

  • Starting an upload is not finishing it.
  • Opening a connection is not completing a query.
  • Scheduling a job is not running it.
  • Enqueuing a message is not processing it.

The value an API returns defines what await can observe. Before trusting an await, find out which lifecycle boundary its Promise actually marks.

Completion is not relevance

Imagine a search box where a user types "JavaScript" and then quickly changes it to "TypeScript". Two requests are in flight. The TypeScript one returns first and the screen shows the right results; then the slower JavaScript response arrives and overwrites them.

Every function in that flow used await correctly. The bug exists because waiting and relevance are separate concerns. await resumes when a Promise settles; it does not ask whether anyone still wants the result, whether a newer request has superseded it, or whether the component now shows a different record. Sequential-looking code only orders steps inside a single invocation, and multiple invocations of the same function can still overlap freely.

Solving this requires an explicit notion of which operation is current. You can abort outdated requests, compare each response with the latest request identifier, use version checks, or rely on a data-fetching layer that keys responses by the parameters that produced them. await cannot tell which result belongs to the present. For a React-focused walkthrough, see fixing race conditions debouncing cannot solve.

Fulfilled does not mean the system is consistent

A database write can succeed while replicas are still behind. Publishing a message typically resolves once the broker accepts it, not once a consumer has handled it. Sending an email usually resolves when the provider accepts the request, not when anyone receives it. It is easy to read more confirmation into await than the API actually gives.

await publishEvent(event);

This line may guarantee that the event reached the broker. It does not necessarily mean projections, notifications or search indexes downstream have caught up.

await sendEmail(message);

Likewise, this may mean only that the provider accepted the message. It does not guarantee delivery, that the recipient read it, or even that the provider kept it after later processing.

What "done" means is set by the contract of the API producing the Promise, and await cannot strengthen that contract. Trouble starts when the next line assumes a broader state change has already happened, such as an endpoint that publishes an event and immediately queries a read model maintained by a consumer, expecting the new data to be there. The Promise fulfilled correctly; the architecture is simply still catching up. Depending on the product, the right design might return a pending status, read from the write model instead, wait on an explicit confirmation, or embrace eventual consistency in the UI.

Design Promises that match their names

Most await confusion disappears once one question becomes a habit: does the value I am awaiting represent completion of the whole operation I care about? Run through the earlier cases and the answer is clearly no whenever:

  • a function starts child Promises without returning or awaiting them
  • an array method discards the Promises its async callbacks return
  • an API returns a handle and reports completion through events
  • a publisher resolves before consumers have done their part

The presence of await proves only that the current function paused until one Promise-like value settled. It does not prove that related work finished, that side effects happened exactly once, that superseded work was cancelled, that errors stayed connected, or that the result is still current.

This is why getting async code right is largely an API design problem. The Promise a function hands back should settle at the moment that matches the responsibility its name advertises. A function called saveDocument should make it obvious whether its Promise covers local persistence, remote synchronisation or both. A function called scheduleReport can legitimately resolve on job acceptance rather than report completion, but its name and return type should make that explicit, for instance by resolving with a job identifier the caller can poll or track.

What await can and can't promise

  • await evaluates one expression and waits for that single value to settle; it knows nothing about "the task" as a whole.
  • Where you call async functions decides whether work runs in parallel; await only decides when you resume.
  • Work that is not returned or awaited is detached: its errors, timing and completion are invisible to the caller.
  • Callback, event and stream APIs must be wrapped in Promises that settle on real completion before await is meaningful.
  • Promise.all neither cancels nor rolls back; pick allSettled, compensation or AbortSignal based on the semantics you need.
  • Completion, relevance and consistency are three different guarantees, and only the first comes from await.
  • Treat every Promise you return as a contract, and make its settlement mean exactly what the function name implies.