Skip to content

No Nested Await

Kris Kowal edited this page Nov 10, 2023 · 6 revisions

context: Coding Style

Avoid Nested/Conditional Await

Our general rule (which will be enforced as part of the Jessie linter) is that await must only appear in the top level of any given function. It should not appear inside of a conditional or a loop.

For example, the following function runs thunk() (which might or might not be async), and wants to ensure that the meteringDisabled counter is decremented afterwards. The non-recommended approach is:

async function runWithoutMeteringAsync(thunk) {
    meteringDisabled += 1;
    try {
      return await thunk(); // NOT RECOMMENDED
    } finally {
      meteringDisabled -= 1;
    }
  }

This version has a subtle timing concern. If thunk() throws synchronously, the await is bypassed entirely, and control jumps immediately to the finally { } block. This is made more obvious by holding the supposed return Promise in a separate variable:

async function runWithoutMeteringAsync(thunk) {
    meteringDisabled += 1;
    try {
      const p = thunk(); // if this throws..
      return await p;    // .. this never even runs
    } finally {
      meteringDisabled -= 1; // .. and control jumps here immediately
    }
  }

The recommended approach rewrites this to avoid the await, and instead uses the Promise's .finally method to achieve the same thing:

  async function runWithoutMeteringAsync(thunk) {
    meteringDisabled += 1;
    return Promise.resolve()
      .then(() => thunk())
      .finally(() => {
        meteringDisabled -= 1;
      });
  }

(Note that thunk() must be called inside a .then to protect against any synchronous behavior it might have. It would not be safe to use return thunk().finally(...), and thunk() might even return a non-Promise with some bogus .finally method.)

await effectively splits the function into two pieces: the part that runs before the await, and the part that runs after, and we must review it with that in mind (including reentrancy concerns enabled by the loss of control between the two). Using await inside a conditional means that sometimes the function is split into two pieces, and sometimes it is not, which makes this review process much harder.

We sometimes refer to this rule as "only use top-level await". Keep in mind that we mean "top of each function", rather than "top level of the file" (i.e. outside of any function body, which is a relatively recent addition to JS, and only works in a module context).

See "Atomic" vs "Transactional" terminology note

New workaround

Often our intent is to ensure that each method can run in as few turns as possible, but sometimes the simplest way to write a method requires an await deep in a loop, or inside a try-catch. A work-around takes advantage of a recent improvement to the lint rule that enforces this constraint. As long as the first appearance of await is at the top level, further awaits are allowed. So you can just write

await null;

early in a method, and this rule doesn't impose further restrictions.

Every async function has a synchronous prelude: when you call an async function, every statement until the first await runs on your stack. An async function has a static synchronous prelude if it has a top-level await before all other awaits.

Audit, refinement

See #6219

Clone this wiki locally