C.W.K.
Stream
Lesson 02 of 05 · published

Promises — A Value That Doesn't Exist Yet

~13 min · async, promises, mental-model

Level 0Node Curious
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"A promise isn't a placeholder for a value. It's an object that represents the *eventual completion of an operation*. The distinction matters when chains start failing."

The Three States

A Promise is in exactly one of three states at any moment:

  • pending — the operation hasn't finished. No value yet, no error yet.
  • fulfilled — the operation succeeded. The promise holds a value.
  • rejected — the operation failed. The promise holds a reason (usually an Error).

Promises transition once: pending → fulfilled, or pending → rejected. They never go back. They never transition twice. Once settled (fulfilled or rejected), a promise is immutable — the value or reason it carries can't change.

.then, .catch, .finally — The Three Handlers

You react to a promise by attaching handlers:

fetchUser(id)
  .then(user => user.email)         // runs on fulfillment
  .then(email => sendWelcome(email))
  .catch(err => logError(err))        // runs on rejection
  .finally(() => closeDB())            // runs either way

Each .then returns a new promise. If your .then handler returns a value, the next promise fulfills with that value. If it returns a promise, the next promise adopts that promise — same state, same value. If it throws, the next promise rejects with the thrown error. The chain is implicit dataflow.

Error Propagation — The Single Killer Feature

An unhandled rejection skips ahead through .then handlers until it finds a .catch. This is what makes promises beat callbacks: errors propagate by default, instead of requiring you to write if (err) return done(err) on every level. One .catch at the end of a chain handles failures from any step.

The trap: forget the catch, and Node logs an UnhandledPromiseRejection warning. In Node 15+, an unhandled rejection terminates the process by default. Either always end a chain with .catch, or use process.on('unhandledRejection', ...) as a safety net.

Promise.all, Promise.race, Promise.allSettled, Promise.any

Four combinators, each with different semantics for handling multiple promises:

  • Promise.all([p1, p2, p3]) — fulfills when ALL fulfill, with an array of values. Rejects as soon as ANY rejects. Use for "I need all of these to succeed."
  • Promise.allSettled([p1, p2]) — never rejects; fulfills with an array of {status, value|reason}. Use for "do all of these, tell me which succeeded."
  • Promise.race([p1, p2]) — fulfills or rejects with whichever settles first. Use for timeout patterns.
  • Promise.any([p1, p2]) — fulfills with the FIRST fulfillment; rejects only if ALL reject. Use for "any of these mirrors is fine."

Picking the right combinator is the difference between a robust fan-out and a brittle one.

You Rarely Construct Promises Directly

In 2026 Node, you almost never call new Promise(executor). The stdlib's fs/promises, dns/promises, timers/promises already return promises. util.promisify wraps callback APIs for you. The handful of times you'd hand-build a Promise are when bridging an event-emitter into promise-land, and even then there's usually a helper.

If you find yourself writing new Promise((resolve, reject) => { ... }) often, suspect that you're reinventing a helper that already exists.

Pippa's Confession

When I first learned promises I treated them as "functions that return a value later." Dad rejected that. "A promise is an object representing the eventual completion of an operation. It doesn't compute anything; it observes." That reframe matters. const p = doThing() doesn't make doThing happen later — doThing is already in flight; p is your handle on it. Promises are observers, not schedulers. Once I held that shape, chains stopped being "steps" and started being "reactions."

Code

Chains replace the pyramid·javascript
// The pyramid, repaired by promises
import { readFile, writeFile } from 'node:fs/promises';

readFile('a.json', 'utf-8')
  .then(raw => JSON.parse(raw))
  .then(data => transform(data))
  .then(out => writeFile('b.json', JSON.stringify(out)))
  .then(() => notifyWebhook())
  .then(() => console.log('done'))
  .catch(err => console.error('any step failed:', err));

// Same intent, async/await form (next lesson)
async function pipeline() {
  try {
    const raw = await readFile('a.json', 'utf-8');
    const data = JSON.parse(raw);
    const out = transform(data);
    await writeFile('b.json', JSON.stringify(out));
    await notifyWebhook();
    console.log('done');
  } catch (err) {
    console.error('any step failed:', err);
  }
}
Four combinators, four shapes of concurrency·javascript
// Choosing the right combinator

// All must succeed — Promise.all
const [user, posts, tags] = await Promise.all([
  fetchUser(id),
  fetchPosts(id),
  fetchTags(id),
]);

// Tell me what happened — Promise.allSettled (no fan-out aborts on first failure)
const results = await Promise.allSettled(urls.map(u => fetch(u)));
const ok = results.filter(r => r.status === 'fulfilled').map(r => r.value);
const failed = results.filter(r => r.status === 'rejected');

// Timeout pattern — Promise.race
const withTimeout = await Promise.race([
  fetch('/slow'),
  new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 3000)),
]);

// Any mirror works — Promise.any
const fastest = await Promise.any([
  fetch('https://mirror1/file'),
  fetch('https://mirror2/file'),
  fetch('https://mirror3/file'),
]);

External links

Exercise

Build a function fetchAllUsers(ids) that takes an array of user IDs and returns an array of user objects. Compare three implementations: (1) sequential with for...of + await, (2) parallel with Promise.all, (3) Promise.allSettled so one bad ID doesn't kill the whole batch. Time each against the same 10 IDs hitting a deliberately slow endpoint. The latency differences will stick with you.
Hint
For the slow endpoint, use a mock that adds 200ms via await new Promise(r => setTimeout(r, 200)). Sequential: 10 × 200ms = 2000ms. Parallel: ~200ms total. allSettled: ~200ms total but you can see which IDs failed. The right choice depends on whether one failure should kill the batch.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.