logodev atlas

Promises — Internals and Deep Dive

What is a Promise?

A Promise is an object representing the eventual completion or failure of an async operation. It's a proxy for a value that may not be available yet.

Promise States:
                ┌──────────────┐
         ┌────→ │  FULFILLED   │ (value available)
         │      └──────────────┘
┌─────────┐
│ PENDING │                        (irreversible once settled!)
└─────────┘
         │      ┌──────────────┐
         └────→ │   REJECTED   │ (reason/error available)
                └──────────────┘
  • A Promise starts pending
  • It settles to either fulfilled (with a value) or rejected (with a reason)
  • Once settled, it never changes state

Creating Promises

The Promise constructor takes an executor function and runs it synchronously — the executor is not deferred. Inside the executor you call resolve(value) to fulfill the promise or reject(reason) to reject it. Only the first call matters; subsequent calls to either function are silently ignored. Errors thrown inside the executor are automatically converted to rejections. The pattern of wrapping an async operation in new Promise((resolve, reject) => {...}) is called "promisification."

javascript// Constructor takes an executor function (runs synchronously!)
const p = new Promise((resolve, reject) => {
  // resolve(value) → fulfills the promise
  // reject(reason) → rejects the promise
  // Only the first call matters — subsequent calls are ignored

  console.log('executor runs synchronously');
  setTimeout(() => resolve('done'), 1000);
});

console.log('this logs before the promise resolves');
p.then(val => console.log(val)); // logs 'done' after 1s

Already-settled Promises

javascript// Immediately fulfilled:
const fulfilled = Promise.resolve('immediate value');
fulfilled.then(v => console.log(v)); // 'immediate value' (next microtask)

// Immediately rejected:
const rejected = Promise.reject(new Error('immediate error'));
rejected.catch(e => console.error(e.message)); // 'immediate error'

// From a thenable:
Promise.resolve({ then: (resolve) => resolve(42) }); // resolves to 42

The .then() Chain

.then() is the core mechanism of promise composition. It always returns a new Promise, which means every .then() call extends the chain rather than modifying the original promise. The new promise's value is determined by what you return from the handler: a primitive value wraps to a fulfilled promise, returning another promise makes the chain wait for it, and throwing an error converts the new promise to a rejected state. This predictable return semantics is what makes chaining reliable.

.then(onFulfilled, onRejected) returns a new Promise:

javascriptPromise.resolve(1)
  .then(x => x + 1)          // returns 2
  .then(x => x * 3)          // returns 6
  .then(x => {
    console.log(x);          // 6
    return x + 4;
  })
  .then(x => console.log(x)); // 10

Return Values from .then()

What you return from .then() determines the next promise:

javascript// Return primitive → next promise fulfills with that value
.then(() => 42)              // next gets 42

// Return nothing (undefined) → next promise fulfills with undefined
.then(() => {})              // next gets undefined

// Return a promise → next waits for that promise
.then(() => fetch('/api'))   // next gets the fetch result

// Throw an error → next promise rejects
.then(() => { throw new Error('oops'); })  // next rejects

// Return a rejected promise → next rejects
.then(() => Promise.reject('fail'))         // next rejects

Error Propagation

Promise error propagation is automatic and ordered: a rejection (or a throw in a .then handler) skips all subsequent fulfilled handlers in the chain and travels directly to the nearest rejection handler (.catch or the second argument of .then). Once a rejection handler runs and returns normally (or returns a value), the chain recovers and subsequent fulfilled handlers resume. This gives you Rails-style centralized error handling while still allowing recovery at any point in the chain.

Errors skip fulfilled handlers and go to the next rejection handler:

javascriptPromise.resolve('start')
  .then(v => { throw new Error('step 1 failed'); })
  .then(v => console.log('step 2'))   // SKIPPED
  .then(v => console.log('step 3'))   // SKIPPED
  .catch(e => {
    console.log('caught:', e.message); // 'step 1 failed'
    return 'recovered';
  })
  .then(v => console.log('step 4:', v)) // 'step 4: recovered' ← resumes!
  .catch(e => console.log('step 5 error')) // not reached

.catch() and .finally()

.catch(fn) is purely a convenience alias for .then(undefined, fn) — they are identical in behavior. .finally(fn) runs its callback regardless of whether the promise fulfilled or rejected, and crucially, it passes through the original value or rejection reason to the next handler without interfering. This makes it ideal for cleanup logic (hiding a spinner, closing a connection) where you want to ensure something always runs but don't want to alter the result.

javascript// .catch(fn) is shorthand for .then(undefined, fn)
promise.catch(err => handleError(err));
// equivalent to:
promise.then(undefined, err => handleError(err));

// .finally(fn) — runs regardless of outcome
fetch('/api')
  .then(res => res.json())
  .catch(err => handleError(err))
  .finally(() => {
    hideLoader(); // always runs — cleanup
    // Note: .finally() passes through the value/error to next handler
  });

Promise Resolution Procedure

The resolution procedure defines exactly how a Promise's value is determined when resolve(x) is called. It is specified in the Promises/A+ spec and implemented consistently across all compliant libraries and native Promises. Understanding it explains why returning a Promise from .then() makes the chain wait (the outer promise adopts the inner promise's state) and why returning Promise.resolve(x) inside .then() is slightly slower than returning x directly (an extra microtask tick to unwrap the thenable).

When you resolve(x):

  1. If x is the promise itself → reject with TypeError (circular)
  2. If x is a thenable (has .then method) → adopt its state
  3. Otherwise → fulfill with x directly
javascript// This is why returning Promise.resolve(x) in .then() costs extra ticks:
Promise.resolve()
  .then(() => Promise.resolve(42))  // extra microtask ticks to unwrap
  .then(v => console.log(v));       // 42 (but later than returning 42 directly)

// Returning 42 directly is faster:
Promise.resolve()
  .then(() => 42)      // 1 microtask tick
  .then(v => console.log(v));

Implementing a Basic Promise (Simplified)

Building a Promise from scratch is the most direct way to internalize how the state machine, handler queuing, and microtask scheduling all fit together. The key design points: state is stored privately and can only transition once (pending → fulfilled/rejected); handlers registered after settlement are called immediately via queueMicrotask; handlers registered before settlement are stored and called when settlement occurs; each .then() returns a new Promise that is resolved by the return value of the handler.

javascriptclass MyPromise {
  #state = 'pending';
  #value;
  #handlers = [];

  constructor(executor) {
    const resolve = (value) => {
      if (this.#state !== 'pending') return;
      this.#state = 'fulfilled';
      this.#value = value;
      this.#handlers.forEach(h => h.onFulfilled?.(value));
    };

    const reject = (reason) => {
      if (this.#state !== 'pending') return;
      this.#state = 'rejected';
      this.#value = reason;
      this.#handlers.forEach(h => h.onRejected?.(reason));
    };

    try {
      executor(resolve, reject);
    } catch(e) {
      reject(e);
    }
  }

  then(onFulfilled, onRejected) {
    return new MyPromise((resolve, reject) => {
      const handle = (fn, val, next) => {
        queueMicrotask(() => {
          try {
            if (typeof fn === 'function') {
              const result = fn(val);
              resolve(result);
            } else {
              next(val); // pass through
            }
          } catch(e) {
            reject(e);
          }
        });
      };

      if (this.#state === 'fulfilled') {
        handle(onFulfilled, this.#value, resolve);
      } else if (this.#state === 'rejected') {
        handle(onRejected, this.#value, reject);
      } else {
        this.#handlers.push({
          onFulfilled: (v) => handle(onFulfilled, v, resolve),
          onRejected:  (r) => handle(onRejected, r, reject)
        });
      }
    });
  }

  catch(onRejected) { return this.then(undefined, onRejected); }

  static resolve(v) { return new MyPromise(res => res(v)); }
  static reject(r)  { return new MyPromise((_, rej) => rej(r)); }
}

Microtask Scheduling

One of the fundamental guarantees of the Promise specification is that .then() handlers are always called asynchronously — as microtasks — even when the Promise is already resolved at the time .then() is registered. This design choice ensures consistent ordering: you can safely write code after a .then() registration and know it executes before the handler does. Without this guarantee, synchronously-resolved Promises would behave differently from asynchronously-resolved ones, breaking reasoning about execution order.

Promises always execute callbacks asynchronously — even if already resolved:

javascriptconst p = Promise.resolve(1); // already resolved

p.then(v => console.log('then:', v));
console.log('sync code');

// Output:
// sync code   ← runs first
// then: 1     ← microtask

This guarantees consistent behavior — .then() is always async.


Common Mistakes

These are the most frequent Promise misuse patterns in production codebases. "Promise constructor antipattern" (wrapping a Promise in another Promise) is particularly common because it feels intuitive but adds unnecessary complexity and can silently swallow errors. "Forgetting to return" breaks the chain by passing undefined to the next handler rather than the promise you intended.

javascript// ❌ Creating unnecessary promise wrapper:
function readData() {
  return new Promise((resolve, reject) => {
    fetch('/api')
      .then(res => resolve(res.json()))  // Redundant! fetch already returns a promise
      .catch(reject);
  });
}

// ✅ Just return the chain:
function readData() {
  return fetch('/api').then(res => res.json());
}

// ❌ Not returning in .then():
getUser()
  .then(user => {
    getOrders(user.id); // missing return! next .then gets undefined
  })
  .then(orders => console.log(orders)); // undefined!

// ✅ Always return:
getUser()
  .then(user => getOrders(user.id)) // return the promise
  .then(orders => console.log(orders)); // orders!

Interview Questions

Q: How many states does a Promise have? Can it transition back? A: Three states: pending, fulfilled, rejected. Once a Promise transitions from pending to fulfilled or rejected, it's settled and NEVER changes state. The value/reason is permanently stored.

Q: What is the difference between .then(fn, errFn) and .then(fn).catch(errFn)? A: .then(fn, errFn)errFn does NOT catch errors thrown in fn (both handlers are for the same promise). .then(fn).catch(errFn)errFn catches errors from BOTH the original promise AND from fn. The second form is generally preferred.

Q: Why is returning a Promise inside .then() different from returning a value? A: Returning a value immediately resolves the next promise with that value (1 microtask tick). Returning a Promise causes the chain to wait for that Promise to settle — the next handler only runs after the inner promise resolves. This costs extra microtask ticks.

Q: Is the Promise executor synchronous or asynchronous? A: The executor function runs synchronously when new Promise(executor) is called. Only the resolve/reject callbacks (.then() handlers) are asynchronous (scheduled as microtasks).

[prev·next]