cheat sheet

Promises

JavaScript Promises represent the eventual completion or failure of an async operation. Covers states, chaining, combinators, callback conversion, AbortController, and common anti-patterns.

Promises

What it is

A Promise is an object representing the eventual completion or failure of an asynchronous operation. It is the foundation of JavaScript async programming — every async/await expression compiles down to Promise chains, and the Fetch API, timers wrapped via promisify, and most browser/Node APIs return Promises.

Promise states

A Promise is always in exactly one of three states:

StateDescriptionTransitions to
pendingInitial state; neither fulfilled nor rejectedfulfilled or rejected
fulfilledOperation completed successfully; has a result value(terminal)
rejectedOperation failed; has a rejection reason (error)(terminal)

Once a Promise settles (fulfilled or rejected) it never changes state.

Creating a Promise

The Promise constructor takes an executor function that receives two callbacks: call resolve(value) to fulfill the promise and reject(error) to reject it. The executor runs synchronously; the resolution is asynchronous. Only wrap APIs that aren't already Promise-based — wrapping an existing Promise is an anti-pattern.

javascript
const p = new Promise((resolve, reject) => {
  // Perform async work here
  setTimeout(() => {
    const success = true;
    if (success) {
      resolve("done");       // fulfills the promise with "done"
    } else {
      reject(new Error("something went wrong")); // rejects with an Error
    }
  }, 1000);
});

Always pass an Error object (not a plain string) to reject(). It preserves the stack trace and works correctly with .catch().

.then(), .catch(), .finally()

javascript
fetch("/api/users")
  .then((response) => response.json())       // runs on fulfillment
  .then((data) => console.log(data))         // chained; receives previous return value
  .catch((err) => console.error(err))        // runs on any rejection in the chain
  .finally(() => console.log("done"));       // always runs; receives no argument

.then() accepts two arguments: onFulfilled and onRejected. Using .catch(fn) is shorthand for .then(undefined, fn).

Each .then() returns a new Promise, enabling chaining. The value returned inside a .then() callback becomes the resolved value of that new Promise.

javascript
Promise.resolve(1)
  .then((v) => v + 1)   // 2
  .then((v) => v * 3)   // 6
  .then(console.log);   // 6

Output:

text
6

Promise.resolve() and Promise.reject()

Promise.resolve(value) wraps a synchronous value in an already-fulfilled Promise, which is useful for normalising APIs that sometimes return a value and sometimes a Promise. Promise.reject(reason) creates an immediately-rejected Promise — always pass an Error instance to preserve the stack trace.

javascript
// Wrap an already-known value in a resolved Promise
const p1 = Promise.resolve(42);
p1.then(console.log); // 42

// Wrap a known error in a rejected Promise
const p2 = Promise.reject(new Error("bad"));
p2.catch(console.error); // Error: bad

If you pass another Promise to Promise.resolve(), it returns that same Promise (no double-wrapping).

Promise combinators

Promise.all() — all must resolve

Fulfills when every input Promise fulfills; rejects immediately on the first rejection.

javascript
const [user, posts] = await Promise.all([
  fetch("/api/user").then((r) => r.json()),
  fetch("/api/posts").then((r) => r.json()),
]);
console.log(user, posts);

Output:

text
{ id: 1, name: 'Jay' } [ { id: 1, title: 'Hello' } ]

If any Promise rejects, the whole Promise.all() rejects and you get nothing from the others.

Promise.allSettled() — waits for all

Always fulfills (never rejects) when all input Promises have settled. Returns an array of status descriptor objects.

javascript
const results = await Promise.allSettled([
  Promise.resolve("ok"),
  Promise.reject(new Error("fail")),
  Promise.resolve("also ok"),
]);

for (const result of results) {
  if (result.status === "fulfilled") {
    console.log("value:", result.value);
  } else {
    console.log("reason:", result.reason.message);
  }
}

Output:

text
value: ok
reason: fail
value: also ok

Promise.race() — first to settle wins

Fulfills or rejects with the outcome of whichever Promise settles first.

javascript
const timeout = new Promise((_, reject) =>
  setTimeout(() => reject(new Error("timeout")), 3000)
);

const result = await Promise.race([fetch("/api/data"), timeout]);

Promise.any() — first to fulfill wins

Fulfills with the first fulfilled Promise; rejects with an AggregateError only if all reject.

javascript
const fastest = await Promise.any([
  fetch("https://cdn1.example.com/data"),
  fetch("https://cdn2.example.com/data"),
  fetch("https://cdn3.example.com/data"),
]);
const data = await fastest.json();
javascript
// All-reject case
try {
  await Promise.any([
    Promise.reject(new Error("a")),
    Promise.reject(new Error("b")),
  ]);
} catch (err) {
  console.log(err instanceof AggregateError); // true
  console.log(err.errors.map((e) => e.message)); // ['a', 'b']
}

Output:

text
true
[ 'a', 'b' ]

Combinator comparison

MethodResolves whenRejects whenUseful for
Promise.all()All fulfillFirst rejectionParallel requests where you need all results
Promise.allSettled()All settle (any outcome)NeverParallel requests where partial success is acceptable
Promise.race()First to settleFirst to rejectTimeout races
Promise.any()First to fulfillAll rejectFastest CDN / redundant endpoints

Converting callbacks to Promises

Node.js util.promisify

javascript
import { promisify } from "node:util";
import { readFile } from "node:fs";

const readFileAsync = promisify(readFile);

const content = await readFileAsync("./data.txt", "utf8");
console.log(content);

Manual wrapping

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

await delay(500);
console.log("500 ms later");
javascript
// Wrapping a legacy callback API: cb(err, result)
function readJsonFile(path) {
  return new Promise((resolve, reject) => {
    fs.readFile(path, "utf8", (err, data) => {
      if (err) return reject(err);
      try {
        resolve(JSON.parse(data));
      } catch (parseErr) {
        reject(parseErr);
      }
    });
  });
}

AbortController + Promise.race()

Combine AbortController with Promise.race to cancel a fetch if it takes too long: the timeout rejects the race and the AbortController signal tells the network layer to drop the in-flight request. Node 17.3+ / modern browsers also provide AbortSignal.timeout(ms) as a one-liner.

javascript
function fetchWithTimeout(url, ms = 5000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), ms);

  return fetch(url, { signal: controller.signal }).finally(() =>
    clearTimeout(timer)
  );
}

// Node 17.3+ shorthand
const response = await fetch("/api/data", {
  signal: AbortSignal.timeout(5000),
});

.then() chain vs async/await

Aspect.then() chainasync/await
ReadabilityGets noisy with error handlingReads like synchronous code
Error handling.catch() at end of chaintry/catch blocks
DebuggingStack traces can be sparseStack traces are richer
Parallel executionPromise.all([...])Promise.all([...]) (same)
Conditional branchingNested .then() callsPlain if/else inside async fn
Sequential loops.reduce() trickfor...of + await
javascript
// Sequential loop with async/await
async function processAll(items) {
  const results = [];
  for (const item of items) {
    results.push(await processItem(item)); // one at a time
  }
  return results;
}

// Parallel with async/await
async function processAllParallel(items) {
  return Promise.all(items.map((item) => processItem(item)));
}

Common anti-patterns

Promise constructor anti-pattern

javascript
// BAD: wrapping a function that already returns a Promise
function getUserBad(id) {
  return new Promise((resolve, reject) => {
    fetch(`/api/users/${id}`)
      .then((r) => r.json())
      .then(resolve)
      .catch(reject);
  });
}

// GOOD: just return the chain directly
function getUserGood(id) {
  return fetch(`/api/users/${id}`).then((r) => r.json());
}

Forgotten .catch()

javascript
// BAD: unhandled rejection; Node.js will warn/crash
doSomethingAsync().then((result) => use(result));

// GOOD: always handle rejections
doSomethingAsync()
  .then((result) => use(result))
  .catch((err) => console.error("Failed:", err));

Broken chain (not returning the inner Promise)

javascript
// BAD: the inner fetch is not chained; p resolves to undefined
const p = Promise.resolve().then(() => {
  fetch("/api/data").then((r) => r.json()); // missing return
});

// GOOD
const p = Promise.resolve().then(() => {
  return fetch("/api/data").then((r) => r.json());
});

Sequential work written as parallel by mistake

javascript
// BAD: if step2 depends on step1's result, this is wrong
const [a, b] = await Promise.all([step1(), step2()]);

// GOOD: sequential when there is a dependency
const a = await step1();
const b = await step2(a);

The microtask queue and execution order

Every settled Promise's .then/.catch/.finally callback is scheduled as a microtask. Microtasks run on the host's microtask queue, which is drained completely between every macrotask (timer, I/O event, message). This means:

  • Promise callbacks always run after the currently-executing synchronous code finishes.
  • Promise callbacks run before the next setTimeout(_, 0) or setImmediate (Node) callback.
  • Nested microtasks added during the drain are processed in the same drain — the queue runs to empty before yielding.
javascript
console.log("1: sync start");

setTimeout(() => console.log("4: timer (macrotask)"), 0);

Promise.resolve().then(() => console.log("3: microtask"));

queueMicrotask(() => console.log("3b: microtask (direct)"));

console.log("2: sync end");

Output:

text
1: sync start
2: sync end
3: microtask
3b: microtask (direct)
4: timer (macrotask)

A long microtask chain can starve I/O — a .then() that itself returns a Promise re-schedules a microtask each link, and the event loop cannot service pending macrotasks until the queue empties. Don't put an unbounded recursive Promise chain on the microtask queue.

javascript
// Starves the event loop — never yields to timers / I/O until done
function loop(i) {
  if (i === 0) return;
  return Promise.resolve().then(() => loop(i - 1));
}
loop(1_000_000);

For genuinely long async work, yield to macrotasks periodically:

javascript
async function yieldToEventLoop() {
  await new Promise((resolve) => setTimeout(resolve, 0));
}

Promise.all — preserving partial results on rejection

Promise.all rejects on the first rejection and surfaces only the rejection reason — the other in-flight promises continue executing in the background, but their results are lost. If you need to know what completed before the failure, two patterns are common.

Pattern 1 — track results manually

javascript
async function allWithProgress(promises) {
  const results = new Array(promises.length);
  let firstRejection;
  await Promise.all(
    promises.map((p, i) =>
      p.then(
        (v) => (results[i] = { ok: true, value: v }),
        (e) => {
          if (!firstRejection) firstRejection = e;
          results[i] = { ok: false, reason: e };
        }
      )
    )
  );
  if (firstRejection) {
    const err = new Error("partial failure");
    err.partial = results;
    err.cause = firstRejection;
    throw err;
  }
  return results.map((r) => r.value);
}

Pattern 2 — use Promise.allSettled

allSettled is the right primitive when partial success is acceptable. It returns one descriptor per input — { status: 'fulfilled', value } or { status: 'rejected', reason }.

javascript
const results = await Promise.allSettled([
  fetch("/api/a"),
  fetch("/api/b"),
  fetch("/api/c"),
]);

const ok = results.filter((r) => r.status === "fulfilled").map((r) => r.value);
const fail = results.filter((r) => r.status === "rejected").map((r) => r.reason);

console.log(`${ok.length} succeeded, ${fail.length} failed`);

Output:

text
2 succeeded, 1 failed

Cancellation — AbortController in depth

AbortController is the standard way to cancel a Promise-returning operation. The controller exposes a signal (an AbortSignal) that the operation observes; calling controller.abort(reason?) flips the signal's aborted flag and fires its abort event. Promise-based APIs like fetch, events.once, stream/promises.pipeline, and setTimeout/Promise integrate with it natively.

javascript
const controller = new AbortController();

setTimeout(() => controller.abort(new Error("user cancelled")), 1000);

try {
  const res = await fetch("/api/slow", { signal: controller.signal });
  await res.json();
} catch (err) {
  if (err.name === "AbortError") {
    console.log("aborted:", err.cause?.message ?? err.message);
  } else {
    throw err;
  }
}

Output:

text
aborted: user cancelled

Composing signals

Node 20 / Chrome 116 added AbortSignal.any([signals]) — the returned signal aborts as soon as any input signal does. Use it to combine a user-cancellation signal with a timeout signal.

javascript
const userCancel = new AbortController();
const combined = AbortSignal.any([
  userCancel.signal,
  AbortSignal.timeout(5000),
]);

await fetch("/api/data", { signal: combined });

Wrapping non-cancellable APIs

For Promise APIs that don't accept a signal, wrap them in a race against the signal's aborted Promise.

javascript
function withSignal(promise, signal) {
  if (signal.aborted) return Promise.reject(signal.reason);
  return new Promise((resolve, reject) => {
    promise.then(resolve, reject);
    signal.addEventListener("abort", () => reject(signal.reason), { once: true });
  });
}

const controller = new AbortController();
setTimeout(() => controller.abort(new Error("timeout")), 100);

try {
  await withSignal(somePromise(), controller.signal);
} catch (err) {
  console.log("cancelled:", err.message);
}

Promise.race([promise, abortSignal]) only wins the race — it does not stop the underlying work. The losing operation continues running and consuming resources. To stop the work itself, you must pass signal into the operation when it supports cancellation, or accept the resource leak.

AggregateError

AggregateError is the error type thrown by Promise.any when every input rejects. Its .errors property is an array of the individual rejection reasons. It can also be constructed directly when you want to collect multiple errors from independent operations.

javascript
try {
  await Promise.any([
    Promise.reject(new Error("CDN A failed")),
    Promise.reject(new Error("CDN B failed")),
    Promise.reject(new Error("CDN C failed")),
  ]);
} catch (err) {
  console.log(err instanceof AggregateError);   // true
  console.log(err.message);                      // "All promises were rejected"
  for (const e of err.errors) console.log("-", e.message);
}

Output:

text
true
All promises were rejected
- CDN A failed
- CDN B failed
- CDN C failed

Constructing your own

javascript
function validate(data) {
  const errors = [];
  if (!data.email) errors.push(new Error("missing email"));
  if (!data.name)  errors.push(new Error("missing name"));
  if (errors.length) {
    throw new AggregateError(errors, "validation failed");
  }
}

try {
  validate({});
} catch (e) {
  console.log(e.errors.map((x) => x.message));
}

Output:

text
[ 'missing email', 'missing name' ]

The deferred pattern (and why to avoid it)

A deferred is an object that exposes a Promise plus its resolve and reject functions so they can be invoked from outside the executor. It was common in pre-Promise libraries (jQuery's $.Deferred); modern JS provides it as Promise.withResolvers() since Node 22 / Chrome 119.

javascript
// Built-in since ES2024 (Node 22+, Chrome 119+, Safari 17.4+)
const { promise, resolve, reject } = Promise.withResolvers();

setTimeout(() => resolve("done"), 100);
console.log(await promise);

Output:

text
done

The deferred pattern is genuinely useful in two cases:

  1. Bridging callbacks to Promises — you have a callback API whose completion you want to expose as a Promise.
  2. Cross-function lifetimes — one function creates the Promise, another (later) resolves it. Common in event-driven code, queues, and FSMs.

If you can solve the problem with new Promise((resolve, reject) => { … }) and the executor function, do that — it scopes the resolvers tightly. The deferred pattern leaks resolvers into outer scope, where they can be lost or called twice without anyone noticing.

The classic anti-pattern — wrapping an already-Promise-returning API in a deferred:

javascript
// BAD — adds a layer of indirection, swallows stack traces
function getUserBad(id) {
  const { promise, resolve, reject } = Promise.withResolvers();
  fetch(`/api/users/${id}`).then((r) => r.json()).then(resolve, reject);
  return promise;
}

// GOOD — return the chain directly
function getUserGood(id) {
  return fetch(`/api/users/${id}`).then((r) => r.json());
}

Promise chaining and value propagation

A .then(onFulfilled) callback returns one of three things, each with a distinct effect on the next link in the chain:

  1. A plain value — becomes the next link's fulfillment value.
  2. A thrown error — becomes the next link's rejection reason (skips intermediate thens until a catch).
  3. Another Promise — the chain pauses until that Promise settles, then adopts its state and value.
javascript
Promise.resolve(1)
  .then((v) => v + 1)                   // returns 2
  .then((v) => Promise.resolve(v * 10)) // returns Promise<20>, chain pauses
  .then((v) => { throw new Error(`bad ${v}`); }) // throws
  .then((v) => console.log("never runs", v))     // skipped
  .catch((err) => console.error("caught:", err.message));

Output:

text
caught: bad 20

Returning a Promise — flat, not nested

Returning a Promise from then does not produce Promise<Promise<T>>. The runtime "assimilates" (unwraps) thenables transparently. This is what makes .then().then().then() chains stay flat regardless of whether each callback returns a value or a Promise.

javascript
// Both look identical to the next link
Promise.resolve(1).then((v) => v + 1);                  // next sees 2
Promise.resolve(1).then((v) => Promise.resolve(v + 1)); // next sees 2 (after one tick)

.finally is transparent

.finally(cb) runs cb with no arguments and ignores its return value (unless cb throws, in which case the chain rejects with that error). The value passes through unchanged.

javascript
const v = await Promise.resolve(42).finally(() => {
  console.log("cleanup");
  return 999;       // ignored
});
console.log(v);     // 42

Output:

text
cleanup
42

Real-world recipes

Retry with exponential backoff

A Promise-returning operation that retries on rejection, doubling the delay between attempts, up to a max.

javascript
async function retry(fn, { attempts = 5, base = 200, factor = 2, maxDelay = 10_000 } = {}) {
  let lastErr;
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (err) {
      lastErr = err;
      if (i === attempts - 1) break;
      const delay = Math.min(base * factor ** i, maxDelay);
      await new Promise((r) => setTimeout(r, delay));
    }
  }
  throw lastErr;
}

const data = await retry(() => fetch("/api/data").then((r) => r.json()));

Promise pool with bounded concurrency

Promise.all fires every task immediately. For a list of N tasks where only K may run concurrently, schedule them into a fixed-size pool.

javascript
async function pool(items, limit, worker) {
  const results = new Array(items.length);
  let next = 0;

  async function runOne() {
    while (true) {
      const i = next++;
      if (i >= items.length) return;
      results[i] = await worker(items[i], i);
    }
  }

  await Promise.all(Array.from({ length: limit }, runOne));
  return results;
}

const urls = Array.from({ length: 20 }, (_, i) => `/api/item/${i}`);
const data = await pool(urls, 5, async (url) => {
  const r = await fetch(url);
  return r.json();
});
console.log(data.length);

Output:

text
20

Promise-based queue

A FIFO queue of pending values, where consumers can await dequeue() and producers enqueue(value). Useful for cross-callback signalling.

javascript
function createQueue() {
  const values = [];
  const waiters = [];

  return {
    enqueue(value) {
      if (waiters.length) {
        waiters.shift().resolve(value);
      } else {
        values.push(value);
      }
    },
    dequeue() {
      if (values.length) return Promise.resolve(values.shift());
      const { promise, resolve } = Promise.withResolvers();
      waiters.push({ resolve });
      return promise;
    },
  };
}

const q = createQueue();
q.enqueue("a");
setTimeout(() => q.enqueue("b"), 100);
console.log(await q.dequeue());
console.log(await q.dequeue());

Output:

text
a
b

Cancellable fetch with timeout

A reusable wrapper that aborts the network request after ms milliseconds.

javascript
function fetchWithTimeout(url, options = {}, ms = 5000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(new Error(`timeout after ${ms}ms`)), ms);
  return fetch(url, { ...options, signal: controller.signal }).finally(() =>
    clearTimeout(timer)
  );
}

try {
  const res = await fetchWithTimeout("/api/slow", {}, 2000);
  await res.json();
} catch (err) {
  if (err.name === "AbortError") console.log("aborted:", err.cause?.message);
}

Debouncing a Promise

Coalesce rapid-fire async calls so only the last one's Promise resolves. The earlier callers all receive the same final result.

javascript
function debouncePromise(fn, ms) {
  let timer;
  let pending;
  return (...args) => {
    clearTimeout(timer);
    if (!pending) {
      pending = Promise.withResolvers();
    }
    timer = setTimeout(async () => {
      const p = pending;
      pending = null;
      try {
        p.resolve(await fn(...args));
      } catch (err) {
        p.reject(err);
      }
    }, ms);
    return pending.promise;
  };
}

const search = debouncePromise(
  (q) => fetch(`/api/search?q=${encodeURIComponent(q)}`).then((r) => r.json()),
  300
);

await search("h");
await search("he");
await search("hel");   // only this one actually fires

Promise memoization

Cache the promise itself (not just the resolved value). Concurrent callers get the same in-flight Promise instead of triggering parallel work.

javascript
function memoize(fn) {
  const cache = new Map();
  return (key, ...rest) => {
    if (!cache.has(key)) {
      cache.set(key, fn(key, ...rest).catch((err) => {
        cache.delete(key);   // don't cache failures
        throw err;
      }));
    }
    return cache.get(key);
  };
}

const fetchUser = memoize((id) => fetch(`/api/users/${id}`).then((r) => r.json()));

const [a, b, c] = await Promise.all([fetchUser(1), fetchUser(1), fetchUser(1)]);
// Only one network request was made.

Sequential reduce — fold an async pipeline

When each step depends on the previous result, fold them with a Promise-aware reducer.

javascript
const steps = [
  async (input) => input.trim(),
  async (input) => input.toUpperCase(),
  async (input) => `[${input}]`,
];

const result = await steps.reduce(
  (acc, step) => acc.then(step),
  Promise.resolve("  hello  ")
);
console.log(result);

Output:

text
[HELLO]