Skip to content

Promise Internals and Chaining

intermediate15 min read

The Chain That Broke

A developer writes this and expects sequential API calls:

getUser(id)
  .then(user => {
    getOrders(user.id)
      .then(orders => {
        getShipping(orders[0].id)
          .then(shipping => console.log(shipping));
      });
  })
  .catch(err => console.error(err));

When getOrders fails, the .catch doesn't fire. The error is silently swallowed. The developer is confused — "but I have a catch at the end!"

The problem: this isn't a chain. It's nested promises. The .catch is attached to the promise from getUser, not the inner promises. Understanding why requires understanding how promises actually work.

The Mental Model

Mental Model

A Promise is a state machine with exactly three states: pending, fulfilled, or rejected. Once it transitions from pending to fulfilled or rejected, it is locked — it can never change again. Think of it as a sealed envelope. While sealed (pending), you don't know the contents. Once opened (settled), the contents are permanent. You can pass the envelope to anyone (.then), and they'll all see the same contents — even if they open it years later.

The Promise State Machine

Every promise has:

  1. State: pending, fulfilled, or rejected
  2. Value: the fulfillment value or rejection reason (only set when settled)
  3. Reaction queue: a list of callbacks waiting for this promise to settle
           resolve(value)
pending ──────────────────→ fulfilled
    │                           │
    │   reject(reason)          │  .then(onFulfilled)
    └──────────────────→ rejected
                                │  .then(_, onRejected)
                                │  .catch(onRejected)

When you call .then() on a pending promise, the callbacks are stored in the reaction queue. When the promise settles, all queued reactions fire as microtasks. When you call .then() on an already-settled promise, the callback is scheduled as a microtask immediately.

const p = new Promise((resolve) => {
  // Promise is pending right now
  setTimeout(() => resolve('done'), 1000);
});

// Reaction stored in queue — promise is still pending
p.then(val => console.log(val));

// Even after settling, .then works — schedules immediately as microtask
setTimeout(() => {
  p.then(val => console.log('late:', val));
}, 2000);
Quiz
What happens when you call .then() on a promise that has already fulfilled?

.then() Returns a NEW Promise

This is the single most important thing to understand about promise chaining. Every .then() call creates and returns a brand new promise. The value of that new promise depends on what the .then() callback returns:

const p1 = Promise.resolve(1);

const p2 = p1.then(val => val * 2);
// p2 is a NEW promise that will fulfill with 2

const p3 = p2.then(val => val * 3);
// p3 is a NEW promise that will fulfill with 6

console.log(p1 === p2); // false
console.log(p2 === p3); // false

The Resolution Rules

What happens to the promise returned by .then() depends on its callback's return value:

Callback returnsNew promise becomes
A value xFulfilled with x
A fulfilled promiseFulfilled with that promise's value
A rejected promiseRejected with that promise's reason
Throws an errorRejected with that error
Nothing (undefined)Fulfilled with undefined
// Returns a value → next .then gets it
Promise.resolve(1)
  .then(x => x + 1)        // returns 2
  .then(x => console.log(x)); // logs 2

// Returns a promise → next .then waits for it
Promise.resolve(1)
  .then(x => fetch('/api'))  // returns a Promise<Response>
  .then(res => res.json())   // waits for fetch, gets Response
  .then(data => console.log(data));

// Throws → skips to .catch
Promise.resolve(1)
  .then(x => { throw new Error('fail'); })
  .then(x => console.log('skipped'))  // skipped!
  .catch(err => console.log(err.message)); // 'fail'

Chaining vs. Nesting: The Critical Difference

This is the part that causes the most real-world bugs. Let's compare the two patterns side by side.

Chaining (correct):

getUser(id)
  .then(user => getOrders(user.id))     // returns the promise
  .then(orders => getShipping(orders[0].id)) // returns the promise
  .then(shipping => console.log(shipping))
  .catch(err => console.error(err));    // catches ANY error above

Nesting (broken error handling):

getUser(id)
  .then(user => {
    getOrders(user.id)              // promise NOT returned
      .then(orders => {
        getShipping(orders[0].id)   // promise NOT returned
          .then(shipping => console.log(shipping));
      });
  })
  .catch(err => console.error(err)); // only catches getUser errors!

The nested version creates disconnected promise trees. Each inner .then() creates a new promise chain that has no connection to the outer .catch(). Errors in getOrders or getShipping are silently swallowed.

The chained version works because each .then() returns the inner promise, which the outer chain waits for. A rejection anywhere propagates down the single chain to the .catch().

Execution Trace
Chain
getUser(id) → Promise`<User>`
p1 created
.then()
p1.then(user => getOrders(user.id))
p2 created. Callback RETURNS a Promise.
Resolve
getOrders returns Promise<Order[]>. p2 'follows' this promise.
p2 won't settle until getOrders settles
.then()
p2.then(orders => getShipping(...))
p3 created. Waits for p2.
Error!
getOrders rejects with NetworkError
p2 rejects → p3 rejects (no handler) → catch fires
.catch()
catch(err) receives the NetworkError
Error propagated through the entire chain
Quiz
What's wrong with: p.then(x => { doSomethingAsync(x).then(y => process(y)); }).catch(handleError);

Promise Resolution Procedure (The Algorithm)

Now let's go deeper. When a promise resolves with a value, the engine runs the Promise Resolution Procedure (Promises/A+ spec, section 2.3):

resolvePromise(promise, x):
  1. If x is the same object as promise → reject with TypeError (prevent cycles)
  2. If x is a Promise → adopt its state (follow it)
  3. If x is an object or function with a .then method ("thenable"):
     a. Call x.then(resolvePromise, rejectPromise)
     b. Only the first call to resolve/reject counts
  4. If x is any other value → fulfill with x

Step 3 is why you can return any "thenable" from .then(), not just native Promises. Libraries like Bluebird, jQuery Deferred, and even custom objects with a .then method all work with native promise chains.

// Custom thenable — works with native promises
const customThenable = {
  then(resolve, reject) {
    setTimeout(() => resolve(42), 100);
  }
};

Promise.resolve()
  .then(() => customThenable)
  .then(val => console.log(val)); // 42 (after 100ms)
Why resolve(anotherPromise) adds an extra microtask tick

When you call resolve(anotherPromise), the spec requires the engine to "follow" the other promise. This takes one extra microtask tick — the engine schedules a microtask to adopt the state of the other promise. This means resolve(Promise.resolve(42)) is one tick slower than resolve(42). In V8, this manifests as: Promise.resolve(Promise.resolve(42)).then(v => ...) takes two microtask ticks to fire the .then callback, while Promise.resolve(42).then(v => ...) takes one. This rarely matters in practice but explains some ordering subtleties in interview questions.

Production Scenario: The Error Black Hole

This one's a classic. A production service processes webhook events. Each handler returns a promise:

// The bug: errors in handleEvent are swallowed
app.post('/webhook', (req, res) => {
  handleEvent(req.body); // returns a promise — but nobody awaits it!
  res.status(200).send('OK');
});

async function handleEvent(event) {
  const user = await getUser(event.userId); // might throw
  await updateRecord(user, event);          // might throw
  await sendNotification(user);             // might throw
}

handleEvent returns a promise, but the route handler doesn't await it or attach a .catch(). If any step throws, the rejection is unhandled. In Node.js, this triggers unhandledRejection and can crash the process (default behavior since Node.js 15).

The fix:

app.post('/webhook', async (req, res) => {
  try {
    await handleEvent(req.body);
    res.status(200).send('OK');
  } catch (err) {
    console.error('Webhook processing failed:', err);
    res.status(500).send('Error');
  }
});

Or if you intentionally fire-and-forget, handle the error explicitly:

app.post('/webhook', (req, res) => {
  handleEvent(req.body).catch(err => {
    console.error('Background processing failed:', err);
  });
  res.status(200).send('OK'); // respond immediately
});

Common Mistakes

What developers doWhat they should do
Forgetting that .then() always returns a NEW promise, not the original
This is why p.then(a).then(b) chains correctly — each .then operates on the previous .then's returned promise, not the original.
Every .then(), .catch(), and .finally() creates a new promise. The original promise is unaffected.
Not returning promises inside .then() callbacks
Without return, the outer chain sees undefined (fulfilled immediately) and doesn't wait for the inner async operation. Errors in the inner operation are also lost.
Always return promises from .then() callbacks to maintain the chain. return fetch(...) not just fetch(...).
Using .then(success, error) instead of .then(success).catch(error)
In .then(success, error), if success throws, error doesn't catch it — they're registered on the same promise. .catch() catches errors from all preceding .then() calls.
Prefer .then(fn).catch(fn). The two-argument form of .then() doesn't catch errors thrown by the success handler.
Creating fire-and-forget promises without error handling
Unhandled promise rejections crash Node.js processes and show console warnings in browsers. There should be zero unhandled rejections in production code.
Every promise chain must end with .catch() or be awaited in a try/catch.

Challenge: Promise Chain Resolution

Challenge: What Does the Chain Produce?

Predict the output, then step through to verify:

Key insight: .catch() is not the end of the chain. It returns a new promise. If the catch callback returns a value (doesn't throw), the chain recovers and subsequent .then() handlers run with the returned value.

Key Rules

Key Rules
  1. 1A Promise has exactly three states: pending, fulfilled, rejected. Once settled, it never changes. .then() on a settled promise schedules a microtask (never synchronous).
  2. 2Every .then(), .catch(), and .finally() returns a NEW promise. The chain is a linked list of promises, each depending on the previous.
  3. 3ALWAYS return promises from inside .then() callbacks. Forgetting the return breaks the chain — the outer chain doesn't wait, and errors are swallowed.
  4. 4.catch() recovers the chain. If its callback returns a value (doesn't throw), the returned promise is fulfilled and subsequent .then() handlers run normally.
  5. 5Prefer .then(fn).catch(fn) over .then(fn, errorFn). The two-argument form doesn't catch errors thrown by the success handler itself.