22 min read

Chapter 3 — Promises in Real Code

The state machine, floating promises, lost errors, and choosing a combinator

Prerequisites: Chapter 1 (microtasks), Chapter 2 (what promises replaced), FE-10 Part 8 (what a promise object is). Practice: the gym, exercises 06-cache-layer and 10-polyfill-kata.


How to Read This Chapter

A promise fixes the callback bugs from Chapter 2 by design: it settles once, and its handlers never run synchronously. What it adds are bugs of disconnection. A promise that nobody returns, nobody awaits, or nobody catches still runs, still fails, and in Node 15+ still kills the process. Most promise bugs in review come down to one question: is this promise connected to the chain that the caller is waiting on?

The chapter ends with a full Promises/A+ implementation. Writing one is a common interview round, and it is also the fastest way to make the rules in Parts 1–7 feel obvious.

TermMeaning
Pending / fulfilled / rejectedThe three states. Fulfilled and rejected are settled, and a settled promise never changes
ResolvedLocked in: either settled, or following another promise or thenable. A resolved promise can still be pending
ReactionA handler registered with .then, queued as a microtask when the promise settles
ThenableAny object or function with a then method. Promises treat thenables as promises
AssimilationA promise adopting the eventual state of a thenable it was resolved with
Floating promiseA promise that nobody returns, awaits or catches. Its result and its errors go nowhere
Unhandled rejectionA promise rejected with no handler attached by the time the host checks
CombinatorPromise.all, allSettled, race, any: one promise from many

Part 1: The State Machine

The "following" state is what the spec calls resolved but pending. It explains three behaviours you will be asked about:

  • new Promise((resolve) => resolve(Promise.reject(err))) rejects. resolve does not mean "fulfil". It means "lock in to this", and it locked in to a rejection.
  • After resolve(p) is called, later calls to resolve or reject are ignored, even though the promise is still pending.
  • Resolving with a thenable costs extra microtask hops, because the promise has to call thenable.then in a job of its own (Chapter 1, Part 2).

Each .then(onFulfilled, onRejected) returns a new promise. That new promise is decided by what the handler does:

The handler…The returned promise…
returns a value vfulfils with v
returns a promise or thenable pfollows p
throws erejects with e
is missing for this outcometakes the same outcome as the previous promise (pass-through)

That last row is how one .catch at the end of a chain catches a rejection from any step above it: each .then without a rejection handler passes the rejection down.


Part 2: The .then That Forgets to return

js
function chargeForStory(userId, cost) {
  return db.getWallet(userId)
    .then((wallet) => {
      db.charge(wallet, cost);       // starts the charge, returns nothing
    })
    .then(() => res.json({ status: 'charged' }));
}

The braces turn the arrow function into a block body, so it returns undefined. The second .then fulfils at once, and the charge runs detached from the chain. Run in Node with a fake 20 ms charge:

text
cost 30:
  route: sending 200 "charged"
  db: charged 30
cost 500:
  route: sending 200 "charged"
  unhandledRejection: insufficient
#
Request chain
db.charge (floating)
Client
1
getWallet resolves
waiting
1 / 5

The fix is one word, and the reason is the table in Part 1: return the inner promise so the next step follows it.

js
.then((wallet) => db.charge(wallet, cost))     // concise body returns the promise

The same bug inside async code is a missing await (Chapter 4). The lint rule that catches both is @typescript-eslint/no-floating-promises; in plain JS projects, look for .then( with a { body and no return.


Part 3: The Explicit-Construction Antipattern

js
function getUser(id) {
  return new Promise((resolve, reject) => {
    fetchUser(id).then((user) => resolve(normalize(user)));
  });
}

This wraps a promise in a new promise for no reason, and it breaks error handling twice:

  1. If fetchUser rejects, nothing calls reject. The outer promise stays pending forever, and the inner rejection is unhandled.
  2. If normalize throws, it throws inside a .then handler, which rejects the inner chain, not the outer promise. Same result: pending forever plus an unhandled rejection. (Verified: with normalize throwing on a null name, getUser(1) was still pending 100 ms later and Node reported the TypeError as unhandled.)

The executor's built-in try/catch does not help, because both failures happen after the executor returned. The fix is to return the chain you already have:

js
function getUser(id) {
  return fetchUser(id).then(normalize);
}
// or
async function getUser(id) {
  return normalize(await fetchUser(id));
}

new Promise is for wrapping things that are not promises: callback APIs, events, timers. If the executor's body calls a function that already returns a promise, that is the antipattern.


Part 4: Swallowed Errors and Where .catch Goes

Two placements look equivalent and are not:

js
op().then(onOk, onFail);          // onFail handles op() failing only
op().then(onOk).catch(onFail);    // onFail handles op() failing OR onOk throwing

With op resolving { items: null } and onOk reading r.items.length: in the first form the TypeError from onOk escapes onFail and goes to whatever is next. In the second, onFail sees it.

The more common bug is a .catch that logs and carries on:

js
op()
  .then((r) => r.items.length)
  .catch((err) => { log.error(err); })     // returns undefined → fulfilled
  .then((count) => billing.record(count)); // runs with count = undefined

A .catch handler is an ordinary handler: if it returns normally, the chain recovers. Code after it runs as if nothing failed, with undefined. Decide which of three things you mean, and write that:

IntentWriteEffect downstream
Recover with a real fallback.catch(() => CACHED_DEFAULT)Continues with a usable value
Add context and keep failing.catch((err) => { throw new Error('usage lookup failed', { cause: err }); })Still rejected, now with a better message
Log and keep failing.catch((err) => { log.error(err); throw err; })Still rejected. Prefer logging once, at the top

Where to put the final .catch: at the boundary that can do something about the failure. That is the route handler that turns it into a 500, the job runner that marks the job failed, the UI that shows an error. Catching in the middle of a chain is right only when that step has a real fallback.


Part 5: Unhandled Rejections

When a promise rejects and has no handler, the host notices. What it does differs:

HostDefault behaviour
BrowserFires unhandledrejection on window; logs "Uncaught (in promise)". The page keeps running
Node ≤ 14Printed a warning and kept running
Node 15+--unhandled-rejections=throw: emits unhandledRejection; if no listener exists, raises it as an uncaught exception. The process exits with code 1

The timing matters. Node checks after the microtask queue drains. A handler attached later in the same drain is fine; a handler attached in a later task is too late:

js
const p = Promise.reject(new Error('provider 503'));
await null;
await null;
p.catch((e) => console.log('caught:', e.message));   // fine: same drain

const q = Promise.reject(new Error('provider 503'));
setTimeout(() => q.catch(() => {}), 0);               // too late: the process has already exited

The pattern that hits this in real code is starting two operations and awaiting them one by one:

js
async function buildDashboard(userId) {
  const profileP = getProfile(userId);   // 50 ms
  const usageP = getUsage(userId);       // fails after 10 ms
  try {
    const profile = await profileP;
    const usage = await usageP;
    return { profile, usage };
  } catch (err) {
    return { error: 'dashboard unavailable' };
  }
}
#
buildDashboard
profileP
usageP
Node
1
starts both, awaits profileP
loading
loading
1 / 5

Fix with await Promise.all([profileP, usageP]), or allSettled if a partial dashboard is acceptable.

What a process-level handler should do: log and exit, not swallow. process.on('unhandledRejection', (err) => { log.fatal(err); process.exit(1); }) adds logging; a handler that only logs turns crashes into silent corruption. Chapter 8 covers shutdown.


Part 6: finally Semantics

p.finally(fn) is for cleanup. It is not p.then(fn, fn):

CodeResultRule
Promise.resolve(1).finally(() => 2)fulfils with 1The return value is ignored
Promise.reject(e).finally(() => 'x')rejects with eThe outcome passes through
Promise.resolve(1).finally(() => { throw err2; })rejects with err2A throw replaces the outcome
Promise.reject(e).finally(() => Promise.reject(err2))rejects with err2A rejected returned promise replaces it too
p.finally(() => sleep(30))settles 30 ms later, with p's outcomeA returned promise is waited for
p.finally((x) => …)x is undefinedfinally gets no argument

The waiting row is a feature: .finally(() => client.release()) holds the chain until the connection is back in the pool. The throwing row is a trap: if release() throws, it replaces the original error, and the log shows "release failed" instead of the query error that caused it.

try/finally inside an async function follows ordinary JS rules, which differ: a return inside finally overrides the try's return value or thrown error. async () => { try { return 'try'; } finally { return 'finally'; } } resolves with 'finally'. Never return from a finally block.


Part 7: Thenables and Assimilation

Any object with a then method is treated as a promise by Promise.resolve, await, return from an async function, and every combinator.

js
const t = { then(resolve) { console.log('then() called'); resolve(42); } };
Promise.resolve(t).then((v) => console.log('assimilated', v));
console.log('sync end');
// sync end, then() called, assimilated 42

The then method is called in a later microtask, never synchronously. The rules for a thenable are defensive, because it may be badly written:

  • Only the first call to resolve or reject counts.
  • A throw from then after it called resolve is ignored.
  • The then property is read once, because it may be a getter with side effects.
  • Promise.resolve(p) === p when p is already a native promise; no new wrapper.

The review bug is accidental thenables. Query builders such as Knex and Mongoose queries are thenables on purpose: awaiting the builder runs the query. Returning a builder from an async function therefore runs it:

js
async function queuedJobsQuery() {
  return knex('jobs').where({ status: 'queued' });   // caller wanted a builder to add .limit()
}
const q = await queuedJobsQuery();   // q is the rows; the query already ran without a limit

The same happens with any class that grows a then method, or a module that exports a function named then, which changes what await import() returns. To hand back a thenable unexecuted, wrap it: return { query: knex('jobs') }.


Part 8: Choosing a Combinator

CombinatorSettles whenResultUse it for
Promise.allAll fulfil, or the first rejectsArray of values in input orderEverything is required: profile + wallet + plan
Promise.allSettledAll settle[{ status, value | reason }]Partial results are fine: a fan-out to 10 webhooks
Promise.raceThe first settles, either wayThat value or reasonTimeouts; first response wins, including failures
Promise.anyThe first fulfils, or all rejectFirst value, or AggregateErrorRedundant sources: the first healthy LLM provider

Edge cases interviewers check: all([]) fulfils with []; allSettled([]) fulfils with []; any([]) rejects with an AggregateError whose errors is empty; race([]) stays pending forever. Non-promise items are treated as already fulfilled, so race([slowPromise, 'plain']) returns 'plain'.

AggregateError has an errors array in input order, not in the order they failed. For a provider failover with any, log every entry: "All promises were rejected" alone tells you nothing.

No combinator cancels anything. When Promise.all rejects, the other operations keep running and their results are thrown away. When race picks a winner, the loser keeps running. (Verified: after all rejected at 10 ms, the 50 ms operation still finished.) For LLM calls, that means you still pay for the losing request. Pass an AbortSignal and abort the losers (Chapter 6).

Promise.withResolvers() (Node 22, all current browsers) returns { promise, resolve, reject }, for when the code that settles a promise is not inside its executor: resolve a waiting request from a webhook handler, or build a queue whose consumers wait on a promise that producers resolve.

js
const waiting = new Map();   // jobId -> resolve

export function waitForRender(jobId) {
  const { promise, resolve } = Promise.withResolvers();
  waiting.set(jobId, resolve);
  return promise;            // add a timeout in real code; see Spot the Bug, Drill 2
}

app.post('/webhooks/render', (req, res) => {
  waiting.get(req.body.jobId)?.(req.body);
  waiting.delete(req.body.jobId);
  res.sendStatus(204);
});

Spot the Bug

Drill 1: the embedding cache

js
const embeddings = new Map();   // text -> Promise<number[]>

export function getEmbedding(text) {
  if (!embeddings.has(text)) {
    embeddings.set(text, provider.embed(text));
  }
  return embeddings.get(text);
}

Caching the promise, not the value, is deliberate: two concurrent callers share one provider call. What's wrong?

Answer

Bug. A rejected promise is cached forever. If the provider returns a 503 once, every later call for that text gets the same rejected promise and the provider is never asked again. (Verified: three attempts, three failures, one provider call.) The service fails until the process restarts, and the cache has no TTL or size limit, so it also grows without bound.

Interleaving. Call 1 misses, stores the pending promise → provider fails → promise rejects, still in the map → call 2 hits, returns the rejected promise → call 3 the same.

Fix. Evict on rejection, but only if the entry is still the one you stored (a newer call may have replaced it):

js
export function getEmbedding(text) {
  let p = embeddings.get(text);
  if (!p) {
    p = provider.embed(text);
    embeddings.set(text, p);
    p.catch(() => {
      if (embeddings.get(text) === p) embeddings.delete(text);
    });
  }
  return p;
}

A side effect worth knowing: the p.catch attaches a handler, so this promise can no longer trigger an unhandled rejection. Callers that await it still receive the rejection. Add an LRU bound and a TTL for real use. Gym exercise 06-cache-layer has this bug.

Say it: "Caching the promise gives you single-flight, but you also cache the failure. Evict on rejection, and compare identity before deleting."

Drill 2: the timeout helper

js
export function withTimeout(promise, ms) {
  return Promise.race([
    promise,
    new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)),
  ]);
}

// used as
const reply = await withTimeout(llm.complete(prompt), 30_000);

What's wrong? There are two problems.

Answer

Bug 1: the timer is never cleared. When promise wins, the 30-second timer stays scheduled. Under load you hold thousands of timers; in a CLI or a test, the process cannot exit until each one fires. (Verified: a 10 ms success with a 2000 ms timeout kept Node alive for 2 s.) When it fires, it rejects a promise nobody listens to. That is harmless here only because race already attached a handler.

Bug 2: the timeout doesn't stop the work. When the timer wins, llm.complete keeps running. You pay for the tokens, hold a socket, and maybe write the late result somewhere.

Fix.

js
export function withTimeout(promise, ms) {
  let timer;
  const timeout = new Promise((_, reject) => {
    timer = setTimeout(() => reject(new Error(`timed out after ${ms} ms`)), ms);
  });
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}

// better: let the operation itself stop
const reply = await llm.complete(prompt, { signal: AbortSignal.timeout(30_000) });

AbortSignal.timeout rejects with a TimeoutError and the client cancels the HTTP request (Chapter 6).

Say it: "race picks a winner but cancels nothing: clear the timer in finally, and pass an AbortSignal so the slow call actually stops."

Drill 3: the provider wrapper

js
function callProvider(prompt) {
  return openai.complete(prompt)
    .catch((err) => {
      log.warn('openai failed, will fall back', err);
    })
    .then((res) => res.text);
}

export async function answer(prompt) {
  try {
    return await callProvider(prompt);
  } catch (err) {
    return anthropicFallback(prompt, err);
  }
}

The failover "works", but the on-call engineer sees TypeError: Cannot read properties of undefined (reading 'text') in every incident and never the real provider error. What's wrong?

Answer

Bug. The .catch logs and returns undefined, so the chain recovers. The next .then runs with res = undefined and throws a TypeError. The fallback runs, but the error it receives is the TypeError, not the 429 or 503 from OpenAI. The decision "should we fall back for this kind of error?" is now impossible, because every error looks the same.

Fix. Put the .catch after the step it should guard, or rethrow:

js
function callProvider(prompt) {
  return openai.complete(prompt).then((res) => res.text);
}

and let answer decide. If you want a log line at this level, rethrow with context: throw new Error('openai failed', { cause: err }).

Say it: "A catch that doesn't rethrow turns a rejection into a fulfilment with undefined. The real error is replaced by a TypeError one step later."

Drill 4: what happens?

js
const results = [];
const p = new Promise((resolve, reject) => {
  resolve(Promise.reject(new Error('A')));
  resolve('B');
  reject(new Error('C'));
});
p.then((v) => results.push(v), (e) => results.push(e.message))
  .finally(() => results.push('done'))
  .then(() => console.log(results));
Answer

Prints [ 'A', 'done' ].

The first resolve locks the promise to Promise.reject(new Error('A')), so p is resolved but still pending. The later resolve('B') and reject(C) are ignored. When the inner promise's rejection is adopted, p rejects with A. The rejection handler pushes 'A' and returns normally, so the chain is fulfilled; finally runs and passes that through.

Say it: "resolve means lock in, not fulfil. The first call wins even though the promise is still pending at that moment."


Build It: MyPromise, Promises/A+ Style

Spec.

  • new MyPromise(executor) calls executor(resolve, reject) synchronously. A throw from the executor rejects, unless resolve or reject was already called.
  • Only the first call to resolve or reject counts.
  • resolve(x) runs the resolution procedure: x === this rejects with a TypeError; an object or function with a callable then is adopted (read then once; the first of its callbacks wins; a throw after a callback is ignored); anything else fulfils.
  • then(onFulfilled, onRejected) returns a new promise. Handlers run in a later microtask (queueMicrotask), in registration order. A missing handler passes the outcome through. A handler's return value goes through the resolution procedure; a throw rejects.
  • catch, finally (with the semantics in Part 6), MyPromise.resolve, MyPromise.reject.
  • all, allSettled, race, any accept any iterable, keep input order, and treat non-promises as fulfilled values.
Reference solution
js
const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';

export class MyPromise {
  #state = PENDING;
  #value = undefined;
  #reactions = []; // handlers registered while pending

  constructor(executor) {
    if (typeof executor !== 'function') {
      throw new TypeError('MyPromise executor must be a function');
    }
    const { resolve, reject } = this.#createResolvingFunctions();
    try {
      executor(resolve, reject);
    } catch (err) {
      reject(err); // ignored if resolve/reject already ran
    }
  }

  // A resolve/reject pair that shares one "already resolved" flag.
  // Only the first call of either function counts.
  #createResolvingFunctions() {
    let alreadyResolved = false;
    return {
      resolve: (x) => {
        if (alreadyResolved) return;
        alreadyResolved = true;
        this.#resolveWith(x);
      },
      reject: (reason) => {
        if (alreadyResolved) return;
        alreadyResolved = true;
        this.#settle(REJECTED, reason);
      },
    };
  }

  // The Promise Resolution Procedure: [[Resolve]](promise, x)
  #resolveWith(x) {
    if (x === this) {
      this.#settle(REJECTED, new TypeError('Chaining cycle detected for promise'));
      return;
    }
    if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
      let then;
      try {
        then = x.then; // read exactly once: it may be a getter
      } catch (err) {
        this.#settle(REJECTED, err);
        return;
      }
      if (typeof then === 'function') {
        // Adopt the thenable's eventual state. A fresh pair: the thenable
        // may call them several times or throw after calling one.
        const { resolve, reject } = this.#createResolvingFunctions();
        try {
          then.call(x, resolve, reject);
        } catch (err) {
          reject(err);
        }
        return;
      }
    }
    this.#settle(FULFILLED, x);
  }

  #settle(state, value) {
    if (this.#state !== PENDING) return;
    this.#state = state;
    this.#value = value;
    const reactions = this.#reactions;
    this.#reactions = [];
    for (const reaction of reactions) this.#runLater(reaction);
  }

  // Handlers always run in a later microtask, never synchronously.
  #runLater({ onFulfilled, onRejected, resolve, reject }) {
    queueMicrotask(() => {
      const fulfilled = this.#state === FULFILLED;
      const handler = fulfilled ? onFulfilled : onRejected;
      if (typeof handler !== 'function') {
        // No handler for this outcome: pass it down the chain unchanged.
        if (fulfilled) resolve(this.#value);
        else reject(this.#value);
        return;
      }
      let result;
      try {
        result = handler(this.#value);
      } catch (err) {
        reject(err);
        return;
      }
      resolve(result); // runs the resolution procedure on the next promise
    });
  }

  then(onFulfilled, onRejected) {
    return new MyPromise((resolve, reject) => {
      const reaction = { onFulfilled, onRejected, resolve, reject };
      if (this.#state === PENDING) this.#reactions.push(reaction);
      else this.#runLater(reaction);
    });
  }

  catch(onRejected) {
    return this.then(undefined, onRejected);
  }

  finally(onFinally) {
    if (typeof onFinally !== 'function') return this.then(onFinally, onFinally);
    return this.then(
      (value) => MyPromise.resolve(onFinally()).then(() => value),
      (reason) => MyPromise.resolve(onFinally()).then(() => { throw reason; }),
    );
  }

  static resolve(x) {
    if (x instanceof MyPromise) return x;
    return new MyPromise((resolve) => resolve(x));
  }

  static reject(reason) {
    return new MyPromise((_, reject) => reject(reason));
  }

  static all(iterable) {
    return new MyPromise((resolve, reject) => {
      const results = [];
      let remaining = 0;
      let index = 0;
      for (const item of iterable) {
        const i = index++;
        remaining++;
        MyPromise.resolve(item).then((value) => {
          results[i] = value;
          if (--remaining === 0) resolve(results);
        }, reject);
      }
      if (remaining === 0) resolve(results); // empty input
    });
  }

  static allSettled(iterable) {
    return new MyPromise((resolve) => {
      const results = [];
      let remaining = 0;
      let index = 0;
      for (const item of iterable) {
        const i = index++;
        remaining++;
        MyPromise.resolve(item).then(
          (value) => {
            results[i] = { status: 'fulfilled', value };
            if (--remaining === 0) resolve(results);
          },
          (reason) => {
            results[i] = { status: 'rejected', reason };
            if (--remaining === 0) resolve(results);
          },
        );
      }
      if (remaining === 0) resolve(results);
    });
  }

  static race(iterable) {
    return new MyPromise((resolve, reject) => {
      for (const item of iterable) {
        MyPromise.resolve(item).then(resolve, reject);
      }
      // Empty input: stays pending forever, like the native one.
    });
  }

  static any(iterable) {
    return new MyPromise((resolve, reject) => {
      const errors = [];
      let remaining = 0;
      let index = 0;
      for (const item of iterable) {
        const i = index++;
        remaining++;
        MyPromise.resolve(item).then(resolve, (reason) => {
          errors[i] = reason;
          if (--remaining === 0) reject(new AggregateError(errors, 'All promises were rejected'));
        });
      }
      if (remaining === 0) reject(new AggregateError(errors, 'All promises were rejected'));
    });
  }
}

Verified in Node 22 with 56 test cases. Most of them run the same scenario against native Promise and MyPromise and compare the outcome: pass-through, recovery, throwing handlers, adopting native promises and sync/async/nested thenables, thenables that call both callbacks or throw before or after, a throwing then getter, functions as thenables, the cycle TypeError, every finally row from Part 6, and every combinator including empty input, Set and string iterables, non-iterables, and AggregateError.errors order. Also checked: handlers never run synchronously, the A F C E D B ordering from Chapter 1 matches native, await works on a MyPromise, and a 100,000-step chain and a 10,000-deep nested resolve complete without a stack overflow.

Edge cases interviewers check.

CaseExpectedWhat breaks if you get it wrong
then on an already-settled promiseHandler still runs asyncZalgo returns (Chapter 2)
Executor calls resolve then throwsStays fulfilledA settled promise changes state
p.then(() => p2) where p2 is that same returned promiseRejects with TypeErrorHangs forever
resolve(thenable)Adopts it; result is not the thenable objectNested promises leak to callers
then getter with side effectsRead onceDouble execution
then(undefined, undefined)Pass-throughValues or errors vanish mid-chain
finally handlerGets no argument; return value ignored unless it throws or rejectsCleanup changes results
all with results arriving out of orderInput orderResults paired with the wrong request
any when all rejectAggregateError with errors in input orderError details lost
race([])Pending forever— (know it, don't "fix" it)

Known differences from native promises, worth saying out loud: this version calls a thenable's then synchronously inside resolve (Promises/A+ allows it; native promises defer it by one job, so ordering around thenables differs by a tick); it has no unhandled-rejection tracking; and it ignores subclassing (Symbol.species).


The Causal Chains

Self-test: predict each consequence before revealing it.
  • If a .then handler starts a promise but doesn't return it
  • If new Promise wraps a call that already returns a promise
  • If a .catch logs without rethrowing
  • If a rejected promise has no handler when Node checks after the microtask drain
  • If you start two promises and await them one after the other
  • If a finally callback throws
  • If an object has a then method
  • If Promise.all rejects or race settles

Review Checklist

  1. .then((x) => { … }) with a block body and no return. The classic floating promise.
  2. new Promise whose executor calls a promise-returning function. Replace with the chain.
  3. .catch handlers that don't rethrow or return a real fallback. What does the next step receive?
  4. .then(ok, fail) where fail is expected to cover errors from ok.
  5. Promises started early and awaited later, one at a time. Each is an unhandled-rejection window.
  6. process.on('unhandledRejection') handlers that log and continue.
  7. finally handlers that can throw, and return inside a finally block.
  8. Promise caches with no eviction on rejection.
  9. Promise.race timeouts that never clear the timer or abort the loser.
  10. Promise.all over work where partial success is acceptable (should be allSettled), or over unbounded arrays (should be a pool, Chapter 7).

What's Next

async/await is promises with nicer syntax, and it keeps every rule in this chapter while hiding most of them. Chapter 4 — async/await Traps covers the missing await, forEach with async, sequential awaits that should be parallel, and return vs return await.