skip to content

Promises

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

15 min read 55 snippets deep dive

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.

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);
});

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

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

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.

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

Output:

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.

// 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.

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:

{ 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.

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:

value: ok
reason: fail
value: also ok

Promise.race() — first to settle wins#

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

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.

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();
// 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:

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#

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#

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

await delay(500);
console.log("500 ms later");
// 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.

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
// 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#

// 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()#

// 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)#

// 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#

// 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.
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:

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.

// 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:

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#

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 }.

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:

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.

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:

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.

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.

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);
}

[!WARNING] 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.

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:

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

Constructing your own#

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:

[ '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.

// 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:

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.

[!WARNING] 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:

// 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.
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:

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.

// 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.

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

Output:

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.

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.

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:

20

Promise-based queue#

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

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:

a
b

Cancellable fetch with timeout#

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

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.

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.

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.

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:

[HELLO]