RoadmapDay 4 / 80
JavaScript CoreMonth 1 · Week 1

Day 4: Promises Under the Hood (Building a Custom Polyfill)

Implement a spec-compliant-enough Promise from scratch to internalize state transitions, the microtask scheduling of .then, and chaining.

Mark this day complete

Study

Concepts

A Promise is a state machine

A Promise has exactly one of three states: pending, fulfilled, or rejected. It starts pending and can transition to fulfilled or rejected exactly once — after that it is settled forever ("immutability after settlement").

`.then(onFulfilled, onRejected)` does not run its callback synchronously, even if the promise is already settled — the spec requires the callback to be scheduled as a microtask. This is what guarantees the ordering you saw on Day 3.

Chaining works because .then returns a NEW promise

Each `.then()` call returns a brand-new Promise, whose resolution depends on what the handler returns: a plain value resolves the new promise with that value; a thrown error rejects it; and returning another thenable/Promise makes the new promise "adopt" that promise's eventual state — this adoption step is what lets you `return fetch(...)` inside a `.then` and keep chaining.

This returned-new-promise mechanic is exactly why Promises solve "callback hell": each step is a value you can pass around, not a nested callback you must be inside of.

See It

Visualizations

Visualization

Promise state machine

pending

executor is running

fulfilled(value)

resolve(value) called — settled forever

rejected(reason)

reject(reason) called — settled forever

Build It

Code Examples

MyPromise: a working polyfill (educational, ~ES2015 spec subset)

js
const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';

class MyPromise {
  #state = PENDING;
  #value;
  #callbacks = []; // { onFulfilled, onRejected, resolveNext, rejectNext }

  constructor(executor) {
    const resolve = (value) => this.#settle(FULFILLED, value);
    const reject = (reason) => this.#settle(REJECTED, reason);
    try {
      executor(resolve, reject);
    } catch (err) {
      reject(err);
    }
  }

  #settle(state, value) {
    if (this.#state !== PENDING) return; // settle exactly once
    // Adopt the state of a returned thenable instead of settling immediately
    if (state === FULFILLED && value && typeof value.then === 'function') {
      value.then(
        (v) => this.#settle(FULFILLED, v),
        (e) => this.#settle(REJECTED, e)
      );
      return;
    }
    this.#state = state;
    this.#value = value;
    this.#flush();
  }

  #flush() {
    // Callbacks must run as microtasks, never synchronously
    queueMicrotask(() => {
      this.#callbacks.forEach((cb) => this.#run(cb));
      this.#callbacks = [];
    });
  }

  #run({ onFulfilled, onRejected, resolveNext, rejectNext }) {
    try {
      if (this.#state === FULFILLED) {
        resolveNext(typeof onFulfilled === 'function' ? onFulfilled(this.#value) : this.#value);
      } else {
        if (typeof onRejected === 'function') resolveNext(onRejected(this.#value));
        else rejectNext(this.#value);
      }
    } catch (err) {
      rejectNext(err);
    }
  }

  then(onFulfilled, onRejected) {
    return new MyPromise((resolveNext, rejectNext) => {
      const callback = { onFulfilled, onRejected, resolveNext, rejectNext };
      if (this.#state === PENDING) this.#callbacks.push(callback);
      else this.#flush(), this.#callbacks.push(callback); // settled: schedule anyway
    });
  }

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

// Usage — behaves like the real thing
new MyPromise((resolve) => setTimeout(() => resolve(42), 100))
  .then((v) => v * 2)
  .then((v) => console.log('result:', v)); // 84

This is deliberately simplified (no static resolve/reject/all yet) — the goal is to see WHY .then always schedules a microtask and WHY chaining returns a new promise.

Remember

Key Takeaways

  • pending → fulfilled | rejected, exactly once, forever — model this as a tiny state machine.
  • .then callbacks are ALWAYS deferred to the microtask queue, even for already-settled promises.
  • Each .then returns a new Promise; its resolution adopts a returned thenable's eventual state.
  • Errors thrown inside a handler reject the next promise in the chain — this is how .catch works.
  • Promise.all/race/allSettled are built on top of this same primitive — worth implementing next as a stretch goal.

Do It

Practice

  1. 1Extend MyPromise with static `MyPromise.resolve` and `MyPromise.reject` helpers.
  2. 2Implement `MyPromise.all(promises)` from scratch using your polyfill.
  3. 3Write a test that proves .then callbacks run after all synchronous code, using console.log ordering.