Skip to content
C Codeloom
JavaScript

JavaScript Promises Deep Dive

Go beyond the basics of JavaScript promises — explore the microtask queue, error propagation, cancellation patterns, and advanced combinators like any and allSettled.

·4 min read · By Codeloom
Beginner 10 min read

What you'll learn

  • How promise states transition under the hood
  • Why promises run as microtasks
  • How errors propagate through chains
  • Patterns for cancellation and timeouts
  • When to use any, race, allSettled

Prerequisites

  • Familiarity with JavaScript basics

Promises are the bedrock of modern asynchronous JavaScript. While then and catch are easy to use, a deeper understanding of how promises actually work makes you a far more confident developer. In this tutorial we will go beyond the surface and look at the internals, error semantics, and advanced combinators.

The Three States

Every promise lives in exactly one of three states: pending, fulfilled, or rejected. Once it settles (fulfilled or rejected), it cannot change again. This immutability is what makes promises predictable.

const p = new Promise((resolve, reject) => {
  setTimeout(() => resolve('done'), 100);
});

p.then(value => console.log(value)); // 'done'

You can attach as many handlers as you like, and each will receive the same settled value.

Microtasks and the Event Loop

Promise callbacks do not run in the regular task queue used by setTimeout. They run in the microtask queue, which is drained after every synchronous task completes and before the next macrotask. This means promise handlers always run before the next timer fires.

console.log('A');
Promise.resolve().then(() => console.log('B'));
setTimeout(() => console.log('C'), 0);
console.log('D');
// Output: A, D, B, C

Understanding this ordering helps you avoid subtle race conditions, especially when mixing promises with DOM events.

Error Propagation

A rejected promise will skip ahead to the next catch in the chain. Any thrown error inside a then handler is automatically converted to a rejection.

fetch('/api/users')
  .then(res => res.json())
  .then(data => {
    if (!data.ok) throw new Error('Bad payload');
    return data.value;
  })
  .catch(err => console.error('Handled:', err.message));

A common bug is forgetting to return a promise from inside then. If you do not return it, the outer chain will not wait for it, and errors inside it will become unhandled rejections.

Promise.all vs allSettled vs any vs race

These combinators solve different problems and they are often confused.

  • Promise.all resolves when every input fulfills, rejects on the first rejection.
  • Promise.allSettled always resolves with an array describing each outcome.
  • Promise.any resolves with the first fulfillment, rejects only if all fail.
  • Promise.race resolves or rejects with the first settled promise of any kind.
const results = await Promise.allSettled([
  fetch('/a'),
  fetch('/b'),
  fetch('/c'),
]);

for (const r of results) {
  if (r.status === 'fulfilled') console.log('ok', r.value);
  else console.log('err', r.reason);
}

Use allSettled when partial failures are acceptable, and any when you need the first success regardless of failures.

Timeouts and Cancellation

Promises are not cancellable by themselves, but you can race them against a timeout to bail out early.

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

await withTimeout(fetch('/slow'), 3000);

For real cancellation, pair fetch with an AbortController. The promise will reject with an AbortError when you call controller.abort().

Common Pitfalls

A few patterns tend to bite even experienced developers. First, do not wrap an existing promise in new Promise — it is unnecessary and easy to get wrong. Second, always either await or return promises from inside async functions. Forgetting can leave dangling work that errors silently.

Finally, never mix then and await in the same expression. Pick one style per function so the control flow stays readable.

Wrapping Up

Promises are simple on the surface but rich underneath. By understanding microtasks, error propagation, and the right combinator for the job, you can write async code that is both fast and easy to reason about. Next, layer async/await on top for even cleaner syntax.