25 min read

Chapter 4 — async/await Traps

The syntax reads like synchronous code; the promises underneath do not behave like it

Part 1 of the async core. Prerequisites: Chapter 3 — Promises in Real Code (promise states, then chains, unhandled rejections) and the microtask queue from FE-10. Next: Chapter 5 — Race Conditions, which is what most of these traps turn into under load.


How to Read This Chapter

async/await is a thin layer over promises. The code looks sequential, so reviewers read it as sequential, and that is where the bugs hide. Every trap in this chapter comes from one gap: the code on the page is in one order, but it runs as a chain of microtasks, and other code can run between them.

Read Part 1 carefully. Every later part is a consequence of it. For each trap, practise saying the mechanism in one sentence, because in the interview you will find the bug in a file you did not write and then have to explain it out loud.

TermMeaning
ContinuationThe rest of an async function after an await. It runs later, as a microtask.
Yield pointAny await. The function pauses there, and other code may run before it resumes.
Floating promiseA promise nobody awaits, returns, or attaches .catch to. Its rejection has nowhere to go.
Serial awaitsIndependent operations awaited one after another, so their latencies add up.
Fan-outStarting many operations at once, usually Promise.all(items.map(fn)).
Fail-fastPromise.all rejects on the first rejection. The other operations keep running.
Overlapping runA timer or handler fires again while the previous async run is still in progress.
Top-level await (TLA)await at module scope in an ES module. The module, and every module that imports it, waits.
Static factorystatic async create(): an async function that does the async setup and then calls a synchronous constructor.

Part 1: What await Desugars To

An async function runs synchronously until its first await. At that await, it returns a pending promise to its caller. The rest of the function is registered as a callback on the awaited value, and runs as a microtask once that value settles.

js
async function load() {
  console.log('2. load starts, runs synchronously');
  const user = await 42;           // even a non-promise yields
  console.log('4. after await, in a microtask');
  return user;
}
console.log('1. before call');
const p = load();
console.log('3. call returned', p instanceof Promise);
p.then((v) => console.log('5. resolved', v));

Output, in Node 22:

code
1. before call
2. load starts, runs synchronously
3. call returned true
4. after await, in a microtask
5. resolved 42

Roughly, this function:

js
async function getBalance(userId) {
  const row = await db.query('SELECT balance FROM wallets WHERE user_id = $1', [userId]);
  return row.balance;
}

behaves like this one:

js
function getBalance(userId) {
  return new Promise((resolve, reject) => {
    Promise.resolve(db.query('SELECT balance FROM wallets WHERE user_id = $1', [userId]))
      .then((row) => resolve(row.balance), reject);
  });
}

Three facts fall out of that, and the rest of this chapter uses all three:

  1. Every await is a yield point. Between the line before it and the line after it, any other ready callback, request handler or timer can run. Chapter 5 is about what that does to shared state.
  2. await always yields, even on a value that is not a promise. await 42 still defers the rest of the function to a microtask.
  3. An async function never throws to its caller. A throw anywhere inside it, even before the first await, becomes a rejection of the returned promise. If the caller does not await or .catch that promise, the error is lost or crashes the process (Part 6).

Step through the same ordering in the event-loop sim. Predict each line before you press next.

Loading simulation…

Part 2: Array Methods Do Not Wait

forEach, map, filter, some and reduce call your callback and use its return value. An async callback returns a promise. None of these methods were written to await anything, so each one does something different with that promise, and all of them are wrong in their own way.

js
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const charged = [];
async function chargeAll(ids) {
  ids.forEach(async (id) => {
    await sleep(10);
    charged.push(id);
  });
  console.log('done, charged =', charged.length);
}
await chargeAll([1, 2, 3]);
await sleep(50);
console.log('later, charged =', charged.length);
code
done, charged = 0
later, charged = 3

forEach started three async callbacks, threw away the three promises, and returned undefined. chargeAll logged "done" while all three were parked at their first await. await chargeAll(...) resolved with nothing still running in its scope. Any error inside the callbacks becomes an unhandled rejection.

#
chargeAll
callback(1)
callback(2)
callback(3)
1
calls forEach
starts, hits await sleep
1 / 8

What each array method does with an async callback:

CodeWhat you getWhy
arr.forEach(async …)undefined, immediatelyThe return value is ignored.
arr.map(async …)An array of promisesmap stores whatever the callback returns. You must await Promise.all(...) it.
arr.filter(async …)Every element keptA promise is an object, and objects are truthy.
arr.some(async …)true for any non-empty arraySame reason. every returns true too.
arr.reduce(async (accP, x) => (await accP) + x, 0)The right answer, seriallyEach step awaits the previous one. It works, but it is a slow for…of in disguise.
arr.sort(async …)The array, unsortedThe comparator returns a promise; sort converts it to a number, gets NaN, and treats every pair as equal.

The filter case is the one people miss in review:

js
const users = [{ id: 1, active: false }, { id: 2, active: true }];
const isActive = async (u) => u.active;

users.filter(async (u) => await isActive(u)).length;   // 2, both kept

const flags = await Promise.all(users.map(isActive));  // [false, true]
users.filter((_, i) => flags[i]).map((u) => u.id);     // [2]

The three correct shapes:

You wantWriteNote
One at a time, in orderfor (const id of ids) await charge(id);Use when order matters, or each step depends on the last.
All at onceawait Promise.all(ids.map(charge));Only when the list is small and bounded (Part 4).
At most K at a timeA pool (mapLimit below; full version in Chapter 7)The default for anything driven by user input or a queue.

Part 3: Accidental Serial Awaits

The opposite mistake. Each line below waits for the previous one, although none of them needs the previous result:

js
const user = await getUser(id);     // 100 ms
const plan = await getPlan(id);     // 100 ms
const usage = await getUsage(id);   // 100 ms
// about 300 ms in total
js
const [user, plan, usage] = await Promise.all([getUser(id), getPlan(id), getUsage(id)]);
// about 100 ms in total

Measured in Node 22 with three 100 ms timers: 305 ms serial, 103 ms with Promise.all. On an admin dashboard with eight independent queries, that is the difference between 80 ms and 640 ms.

Serial is correct when a later call needs an earlier result, when order matters (ledger entries), or when you are deliberately keeping load off a rate-limited API. Say which one it is in the review. "These three don't depend on each other" is the whole argument for changing it.

Compare the two shapes in the sim. Then raise the task count and see why "all at once" is not the end of the story.

Loading simulation…

The "start early, await later" trap

Someone reads the advice above and writes this instead:

js
async function loadDashboard() {
  const userP = getUser();   // 100 ms
  const planP = getPlan();   // rejects after 10 ms
  try {
    const user = await userP;   // planP rejects while we wait here
    const plan = await planP;
    return { user, plan };
  } catch (err) {
    return { error: err.message };
  }
}

It looks equivalent to Promise.all. It is not. planP rejects at 10 ms. At that moment nothing has attached a handler to it, because the function is still parked on await userP. Node's unhandled-rejection check runs after the microtask queue drains, sees a rejected promise with no handler, and crashes the process with exit code 1. The try/catch never gets a chance. This was verified on Node 22. Promise.all attaches a handler to every input at once, which is why it does not have this problem.


Part 4: Unbounded Promise.all Over User Input

js
app.post('/api/collections/:id/embed', async (req, res) => {
  const docs = await db.docs.findMany({ where: { collectionId: req.params.id } });
  const vectors = await Promise.all(docs.map((d) => embed(d.text)));
  await db.vectors.insertMany(vectors);
  res.json({ count: vectors.length });
});

Promise.all does not start anything. docs.map does: it calls embed once per document, all in the same synchronous loop, before Promise.all even sees the array. A collection with 4,000 documents means 4,000 HTTP requests opened in one tick.

What happens next depends on what is downstream:

DownstreamFailure
An LLM or embedding APIMost requests come back 429. If the client retries, it retries all of them at once (the retry storm in Chapter 7).
A pg pool of size 1010 queries run; the other 3,990 queue inside the pool. Other requests to the same instance wait behind them and hit connectionTimeoutMillis.
Memory4,000 request bodies and response buffers are alive at once.
Fail-fastOne 429 rejects the whole Promise.all, and the other 3,999 calls keep running and billing (Part 5).

The fix has two parts: cap the input size at the API boundary, and cap concurrency.

js
export async function mapLimit(items, limit, fn) {
  const results = new Array(items.length);
  let next = 0;
  async function worker() {
    while (next < items.length) {
      const i = next++;              // claimed synchronously, so no two workers take the same index
      results[i] = await fn(items[i], i);
    }
  }
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
  return results;
}

const vectors = await mapLimit(docs, 8, (d) => embed(d.text));

next++ is safe without a lock because it runs synchronously between yield points. No other worker can run between reading next and incrementing it. Chapter 5 explains why that is the key fact about races in JavaScript.

In the interview: "map starts every call at once, so the concurrency is set by the size of the user's input. I'd put a pool limit on it and a maximum size on the request."


Part 5: Promise.all Fails Fast; the Other Work Does Not Stop

js
async function task(name, ms, fail) {
  await sleep(ms);
  if (fail) throw new Error(name + ' failed');
  console.log(name, 'finished and wrote its result');
}
try {
  await Promise.all([task('A', 10, true), task('B', 50), task('C', 100)]);
} catch (e) {
  console.log('caught:', e.message);
}
code
caught: A failed
B finished and wrote its result
C finished and wrote its result

A promise is a receipt for work, not a handle that can stop it. Promise.all rejected at 10 ms, the caller handled the error and moved on, perhaps returned a 500, and B and C kept running. If they write rows, send emails, or call a paid API, those side effects still happen, after the caller has reported failure.

Two fixes, for two different goals:

GoalUse
"I want every result, failures included"Promise.allSettled, then inspect each { status, value | reason }.
"If one fails, stop the rest"Pass one AbortSignal to every task and abort it on the first failure.

The sim shows both side by side: Promise.all rejecting while its lanes keep going, and allSettled with an AbortController.

Loading simulation…

Build: allOrAbort(tasks)

Spec. Takes an array of functions (signal) => Promise. Resolves with the results in input order. On the first rejection, it aborts a shared AbortSignal with that error as the reason, so the other tasks can stop, and rejects with the original error. A task that throws synchronously counts as a rejection. An empty array resolves to [].

Reference solution
js
export function allOrAbort(tasks) {
  const controller = new AbortController();
  return Promise.all(
    tasks.map((task) =>
      Promise.resolve()
        .then(() => task(controller.signal))   // a sync throw becomes a rejection
        .catch((err) => {
          controller.abort(err);               // tell the siblings to stop
          throw err;
        }),
    ),
  );
}

Tested with node:test: results come back in order; on a failure at 10 ms, two 100 ms siblings that listen to the signal both see signal.reason equal to the original error and never finish; a synchronous throw is a rejection; [] resolves to [].

Edge cases interviewers check:

  • The abort only helps if each task passes signal down to fetch, to its sleeps, and to its own loops (Chapter 6).
  • The returned promise rejects immediately. The siblings are still winding down at that moment. If you need "rejected and everything has stopped", await Promise.allSettled on the wrapped promises before rethrowing.
  • controller.abort(err) is a no-op after the first call, so the reason is always the first error.
  • An external caller signal should be combined with AbortSignal.any([callerSignal, controller.signal]).

Part 6: try/catch Only Catches What You Await

js
async function save() { throw new Error('db down'); }

function handler() {
  try {
    save();               // not awaited
  } catch (e) {
    console.log('caught?', e.message);
  }
  console.log('handler returned normally');
}
handler();

This prints handler returned normally, then Node prints the stack for Error: db down and exits with code 1. save() did not throw. It returned a rejected promise (Part 1, fact 3). The try block finished synchronously before anyone looked at that promise. When the microtask queue drained with a rejected, unhandled promise, Node raised unhandledRejection. Since Node 15 the default for that is to crash.

The same bug in other shapes:

js
try { items.forEach(async (i) => await process(i)); } catch {}      // catches nothing
try { setTimeout(async () => { await flush(); }, 0); } catch {}       // catches nothing
try { return somethingAsync(); } catch {}                             // Part 7

When you really want fire-and-forget (an analytics event, a cache warm-up), say so in the code and give the error a place to go:

js
void sendWelcomeEmail(user).catch((err) => logger.warn({ err }, 'welcome email failed'));

The void tells the reader, and the no-floating-promises lint rule, that dropping the promise is deliberate. The .catch is what actually stops the crash.


Part 7: return vs return await Inside try

js
const fail = () => new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 10));

async function a() {
  try { return fail(); }
  catch (e) { return 'fallback'; }
  finally { console.log('a finally runs'); }
}
async function b() {
  try { return await fail(); }
  catch (e) { return 'fallback'; }
  finally { console.log('b finally runs'); }
}

await b() gives 'fallback'. await a() rejects with timeout, and the catch block never runs.

The mechanism: return fail() leaves the try block at once, carrying a pending promise. The async function's own promise is then resolved with that pending promise and follows it. By the time fail() rejects, a has already left try, catch and finally. return await fail() waits inside the try, so the rejection is thrown there and catch sees it.

The finally version is worse, because it releases resources too early:

js
async function withConnection() {
  const conn = await pool.connect();
  try {
    return conn.query('SELECT …');   // query still running
  } finally {
    conn.release();                  // runs now, before the query finishes
  }
}

A verified version with a flag in place of the connection prints query sees lock held? true, then released, then query done, lock held? false. The connection goes back to the pool while the query is still using it, so the next borrower gets a connection that is in the middle of someone else's query.

Where the return isreturn p vs return await p
Inside try with catchDifferent: only return await lets catch see the rejection.
Inside try with finally, or a using blockDifferent: only return await keeps the resource alive until p settles.
Anywhere elseSame result. return await gives a slightly better async stack trace.

Lint rule: @typescript-eslint/return-await with "in-try-catch", which requires return await exactly where it matters.


Part 8: Async Functions as Event Handlers and Timer Callbacks

setInterval, EventEmitter, Express 4 and DOM events all call your function and ignore what it returns. When the function is async, two things are lost: the caller cannot wait for it to finish, and the caller cannot see its errors.

Overlapping runs

js
setInterval(async () => {
  const jobs = await claimDueJobs();
  for (const job of jobs) await runJob(job);
}, 1000);

setInterval fires every 1000 ms, whether or not the previous run has finished. If one run takes 2.5 s, three runs are in flight at once. All three can claim and run the same jobs, unless claimDueJobs is atomic in the database. A test with a 250 ms task on a 100 ms interval gave { runs: 9, maxRunning: 3 } in one second.

#
Timer
Run 1
Run 2
Run 3
1
t = 0 s fires
claimDueJobs() → job 7
1 / 6

The fix is to schedule the next run from the end of the previous one:

js
function startPolling(fn, ms) {
  let timer;
  let stopped = false;
  const tick = async () => {
    try { await fn(); }
    catch (err) { logger.error({ err }, 'poll failed'); }   // one failure does not kill the loop
    if (!stopped) timer = setTimeout(tick, ms);
  };
  timer = setTimeout(tick, ms);
  return () => { stopped = true; clearTimeout(timer); };
}

The same test gives { runs: 3, maxRunning: 1 }. With more than one process, you still need the database to make the claim atomic (FOR UPDATE SKIP LOCKED, Chapter 5).

Lost errors

CallerWhat happens to a rejected async handler
setInterval / setTimeoutUnhandled rejection; Node exits.
EventEmitter.onUnhandled rejection, unless the emitter was created with { captureRejections: true }, which routes it to the 'error' event.
Express 4 route handlerUnhandled rejection: by default Node exits. If an unhandledRejection hook only logs, the request hangs until the client times out. Express 5 catches a rejected promise and passes it to next(err).
DOM addEventListener, React onClickLogged as Uncaught (in promise) in the console. The UI may be stuck in a "loading" state.
stream.on('data', async …)No backpressure: the stream keeps emitting while your handlers pile up (Chapter 9).

A click handler is also an overlapping-run problem: two quick clicks start two async runs. That is the double-submit race in Chapter 5.


Part 9: Top-Level Await

In an ES module you can await at the top level. The module's evaluation pauses there, and every module that imports it, directly or indirectly, waits before its own body runs.

js
// config.mjs
console.log('config: start');
await new Promise((r) => setTimeout(r, 200));
export const config = { model: 'x' };
console.log('config: loaded');

// main.mjs
import { log } from './logger.mjs';
import { config } from './config.mjs';
log('main: body runs, config =', config.model);

Modules that do not depend on config.mjs (here, logger.mjs) still evaluate. main.mjs waits 200 ms.

TrapWhat happens (verified on Node 22)
A TLA that never settles (a connection that hangs)Node exits with code 13 and Warning: Detected unsettled top-level await. In a server, startup just stops.
require() of an ESM module that uses TLAThrows ERR_REQUIRE_ASYNC_MODULE. Node 22 can require ES modules, but not ones that use TLA.
TLA in a library moduleEvery consumer's startup now waits on your network call, and they cannot catch its error.
TLA in a module the HTTP server importslisten() runs only after it settles, so a slow call there is slow startup, and health checks fail meanwhile.

Good uses: one-off scripts, and the entry module of a server loading config or connecting before listen(). Bad use: anything another module imports.


Part 10: Async Constructors

A constructor cannot be async. class A { async constructor() {} } is a SyntaxError: Class constructor may not be an async method. The two usual workarounds are both bad:

  • Return a promise from the constructor. new returns whatever object a constructor returns, so new Store() is now a promise: instanceof Store is false, and every caller must remember await new Store().
  • Start this.connect() without awaiting it. The object is handed out before it is ready, and the connect promise is floating, so its failure crashes the process.

Use a static factory. All the async work happens before the constructor, and the constructor only assigns fields:

js
class VectorStore {
  #pool;
  constructor(pool) { this.#pool = pool; }   // synchronous, always gets a ready pool
  static async create(url) {
    const pool = await connect(url);
    await pool.query('SELECT 1');           // fail here, not on the first real query
    return new VectorStore(pool);
  }
}
const store = await VectorStore.create(process.env.DATABASE_URL);

If the object must be created synchronously and initialised lazily, cache the init promise, not the result. Chapter 5 shows why caching the result causes two connections.


Part 11: Mixing Callbacks and await

await works on promises. Callback APIs do not return promises. Mixing them goes wrong in three ways.

Awaiting a callback API. fs.readFile with a callback returns undefined. await undefined resumes on the next microtask, long before the file has been read.

js
import fs from 'node:fs';
let config;
await fs.readFile('./cfg.json', 'utf8', (err, data) => { config = JSON.parse(data); });
console.log(config);   // undefined

An async promise executor. The Promise constructor ignores the promise that its executor returns. If the awaited call rejects, nobody calls reject. The outer promise stays pending forever, and the error becomes an unhandled rejection.

js
function getQuota() {
  return new Promise(async (resolve) => {
    const q = await fetchQuota();   // rejects
    resolve(q);
  });
}
// getQuota() never settles; Node reports the rejection as unhandled.

A throw inside a callback inside new Promise. The executor's own try has finished by the time the callback runs, so a throw there is an uncaught exception, not a rejection.

js
function readJson(path) {
  return new Promise((resolve, reject) => {
    fs.readFile(path, 'utf8', (err, data) => {
      if (err) return reject(err);
      resolve(JSON.parse(data));   // throws on bad JSON: crashes the process
    });
  });
}

The fix for all three is the same: use the promise version of the API, and write plain async code on top of it.

js
import { readFile } from 'node:fs/promises';
async function readJson(path) {
  return JSON.parse(await readFile(path, 'utf8'));   // bad JSON is a rejection the caller can catch
}

For an old callback API with no promise version, wrap it once with util.promisify, or write the wrapper by hand following Chapter 2.


Spot the Bug

Drill 1: the nightly refund job

js
export async function refundFailedGenerations(batchId) {
  const failed = await db.generations.findMany({
    where: { batchId, status: 'failed', refundedAt: null },
  });

  failed.forEach(async (gen) => {
    await ledger.credit(gen.userId, gen.cost, { reason: 'generation_failed', ref: gen.id });
    await db.generations.update({ where: { id: gen.id }, data: { refundedAt: new Date() } });
  });

  await db.batches.update({ where: { id: batchId }, data: { refundsDone: true } });
  logger.info({ batchId, count: failed.length }, 'refunds complete');
}

What's wrong?

Answer

Bug. forEach does not await its async callback. The batch is marked refundsDone and "refunds complete" is logged while every refund is still parked at its first await. All the refunds also start at once, with no limit. If any ledger.credit rejects, it is an unhandled rejection. Node exits, and the job runner sees a crash with the batch already marked done.

Interleaving. Callback 1 reaches await ledger.credit and yields. Callbacks 2…N do the same. forEach returns. batches.update runs and the log line prints. The credits complete later, or never, if the process dies first.

Fix.

js
for (const gen of failed) {
  await ledger.credit(gen.userId, gen.cost, { reason: 'generation_failed', ref: gen.id });
  await db.generations.update({ where: { id: gen.id }, data: { refundedAt: new Date() } });
}
await db.batches.update({ where: { id: batchId }, data: { refundsDone: true } });

Use serial code, or mapLimit if there are many. The credit and the refundedAt update also belong in one transaction, with a unique ledger reference, so that a retry cannot refund twice (Chapter 5).

Say it: "forEach throws away the promises, so the batch is marked done before any refund has run, and a failed refund crashes the process instead of failing the job."

Drill 2: provider failover

js
async function generate(prompt) {
  try {
    return primary.complete(prompt, { timeoutMs: 20_000 });
  } catch (err) {
    metrics.increment('llm.failover');
    return fallback.complete(prompt, { timeoutMs: 20_000 });
  }
}

What's wrong?

Answer

Bug. return primary.complete(...) without await. The try block exits as soon as the call returns a pending promise. When the primary times out or returns a 5xx, the rejection goes straight to the caller. The catch never runs, so failover never happens and the metric stays at zero. The dashboard says "no failovers", which looks like good news.

Fix. return await primary.complete(prompt, { timeoutMs: 20_000 });. While you are there: the primary call should get an AbortSignal, so that a timeout actually stops it (Chapter 6).

Say it: "Without await the rejection happens after we've left the try, so the catch that does the failover is dead code."

Drill 3: the usage poller

js
// started once at boot
setInterval(async () => {
  const usage = await billingApi.listUsageEvents({ since: lastSync });
  for (const e of usage) await credits.applyUsage(e);
  lastSync = Date.now();
}, 5_000);

What's wrong?

Answer

Bugs.

  1. Overlapping runs. When the billing API call or the loop takes more than 5 s, the next tick starts with the same lastSync, lists the same events and applies them again. Usage is double-counted.
  2. Lost errors. One rejected list call is an unhandled rejection, and the process exits.
  3. A gap. lastSync = Date.now() is the time the loop finished, not the cutoff that was queried. Events created while the loop ran are skipped.

Fix. Use a setTimeout loop (startPolling from Part 8) so that runs never overlap. Catch inside the tick. Move the cursor to the newest event actually applied. Make applyUsage idempotent by event ID with a unique constraint, because with two instances the timer runs in both.

Say it: "setInterval doesn't wait for an async callback, so slow runs overlap and apply the same events twice, and one failure kills the process."

Drill 4: dashboard loader

js
export async function loadAdminDashboard(orgId) {
  const membersP = db.members.count({ where: { orgId } });
  const spendP = billing.monthToDate(orgId);       // calls an external API
  const jobsP = db.jobs.groupBy({ by: ['status'], where: { orgId } });
  try {
    return {
      members: await membersP,
      spend: await spendP,
      jobs: await jobsP,
    };
  } catch (err) {
    logger.warn({ err }, 'dashboard partial failure');
    return { members: null, spend: null, jobs: null };
  }
}

What's wrong?

Answer

Bug. "Start early, await later." If billing.monthToDate rejects while the function is still waiting on membersP, spendP has no handler at that moment. Node raises unhandledRejection and exits with code 1. The try/catch never runs. The test suite passes, because in tests the member count is fast and resolves first.

Fix.

js
const [members, spend, jobs] = await Promise.allSettled([
  db.members.count({ where: { orgId } }),
  billing.monthToDate(orgId),
  db.jobs.groupBy({ by: ['status'], where: { orgId } }),
]);
return {
  members: members.status === 'fulfilled' ? members.value : null,
  spend: spend.status === 'fulfilled' ? spend.value : null,
  jobs: jobs.status === 'fulfilled' ? jobs.value : null,
};

allSettled attaches handlers to all three at once and also gives a real partial result, which is what the catch was trying to do.

Say it: "A promise has to have a handler by the time it rejects; awaiting it later is too late if it rejects while we're parked on an earlier await."


The Causal Chains

Self-test: predict each consequence before revealing it.
  • If you pass an async callback to forEach
  • If you filter with an async predicate
  • If you write return p inside a try
  • If an async function is called without await inside try
  • If you start two promises and await them one at a time
  • If you setInterval an async job slower than the interval
  • If Promise.all rejects on the first failure
  • If a module uses top-level await on a network call

Review Checklist

  1. grep -n "forEach(async" and .filter(async, .some(async, .every(async, .sort(async: almost always bugs.
  2. .map(async whose result is not passed to Promise.all, allSettled or a pool.
  3. return <call> inside try blocks in async functions: should it be return await?
  4. Calls to async functions on their own line with no await, return, void … .catch: floating promises. Turn on no-floating-promises.
  5. Promises created and stored in variables, then awaited later one by one.
  6. Promise.all( over anything whose length comes from a request, a query result or a queue.
  7. Promise.all around side-effecting calls: what happens to the others when one fails?
  8. setInterval(async and async listeners on EventEmitter, Express 4 routes, stream 'data' events.
  9. new Promise(async, and await on anything that takes a callback.
  10. Top-level await outside the entry module; constructors that start async work.

What's Next

Every trap here comes back to Part 1: an await is a point where other code runs. Chapter 5 — Race Conditions in Single-Threaded JS asks what happens when that other code touches the same state.