Build a Promise, From Scratch.
The fastest way to truly understand Promise is to build one. Every .then chain, every microtask quirk, every await behavior — it all becomes obvious once you've held the state machine in your hands.
The world before promises
Before promises, async results lived in callbacks. Each level of nesting added a callback, and error handling forked at every step. The phrase "callback hell" was invented for exactly this shape.
getUser(id, (err, user) => { if (err) return handleErr(err); getPosts(user, (err, posts) => { if (err) return handleErr(err); getComments(posts[0], (err, comments) => { if (err) return handleErr(err); render(comments); }); }); });
getUser(id) .then(user => getPosts(user)) .then(posts => getComments(posts[0])) .then(render) .catch(handleErr);
Same logic. Flat. One error handler. Now let's build the thing that makes this work.
A promise is a state machine
A promise is an object representing the eventual result of an async operation. It moves through exactly three states, in one direction, never backwards.
Fig. 1 — Three states. Pending → fulfilled OR pending → rejected. Once "settled", the state is frozen forever.
The rules we have to enforce
- A promise starts in pending state.
- It can transition to fulfilled (with a value) or rejected (with a reason) — exactly once.
- Once settled, state and value are immutable.
.then(onFulfilled, onRejected)registers callbacks for when the promise settles.- If
.thenis called BEFORE settling, the callbacks must fire LATER, when the promise settles. - If
.thenis called AFTER settling, the callbacks must still fire asynchronously — never synchronously. .thenreturns a new promise, enabling chaining.
The skeleton: state + value
Start with the smallest thing that's recognizably a promise: a constructor that runs an "executor" function immediately, and two methods that change the state.
class MyPromise { constructor(executor) { this.state = 'pending'; this.value = undefined; const resolve = (value) => { if (this.state !== 'pending') return; // rule 2 this.state = 'fulfilled'; this.value = value; }; const reject = (reason) => { if (this.state !== 'pending') return; // rule 2 this.state = 'rejected'; this.value = reason; }; try { executor(resolve, reject); } catch (err) { reject(err); // if executor throws, the promise rejects } } }
What works already
const p = new MyPromise((resolve) => resolve(42)); console.log(p.state); // "fulfilled" console.log(p.value); // 42
What's missing
We can set state, but nobody can react to it. Time for .then.
Add .then — the synchronous case
Let's pretend, just for a moment, that promises settle before .then is called. (We'll fix the async case next.)
then(onFulfilled, onRejected) { if (this.state === 'fulfilled') { onFulfilled(this.value); } else if (this.state === 'rejected') { onRejected(this.value); } // what about pending? — next step }
new MyPromise(r => r(42)) .then(v => console.log('got', v)); // → "got 42"
Works for already-settled promises. But what about this?
new MyPromise((resolve) => { setTimeout(() => resolve(42), 1000); }).then(v => console.log(v)); // → nothing prints. state is still 'pending' when .then is called.
We need to remember the callbacks if the promise is still pending, and fire them when it settles later.
Handle the pending case
Two queues: one for fulfillment handlers, one for rejection handlers. .then pushes into them. resolve/reject drain them.
constructor(executor) { this.state = 'pending'; this.value = undefined; this.onFulfilledCallbacks = []; // new this.onRejectedCallbacks = []; // new const resolve = (value) => { if (this.state !== 'pending') return; this.state = 'fulfilled'; this.value = value; this.onFulfilledCallbacks.forEach(cb => cb(value)); // new }; const reject = (reason) => { if (this.state !== 'pending') return; this.state = 'rejected'; this.value = reason; this.onRejectedCallbacks.forEach(cb => cb(reason)); // new }; try { executor(resolve, reject); } catch (err) { reject(err); } } then(onFulfilled, onRejected) { if (this.state === 'fulfilled') { onFulfilled(this.value); } else if (this.state === 'rejected') { onRejected(this.value); } else { this.onFulfilledCallbacks.push(onFulfilled); // queue for later this.onRejectedCallbacks.push(onRejected); } }
Fig. 2 — While pending, callbacks pile up. On resolve, they all fire in order. After settling, new callbacks run immediately.
Now the async case works too. But there's a subtle bug — the next step exposes it.
Always asynchronous
A real Promise has a quietly important guarantee: callbacks NEVER fire synchronously, even if the promise is already settled. This is the Promises/A+ spec rule that catches everyone the first time.
console.log('1'); new MyPromise(r => r(42)).then(v => console.log('2: ' + v)); console.log('3'); // Our impl prints: 1, 2: 42, 3 ← WRONG // Real Promise: 1, 3, 2: 42 ← correct (microtask)
Why does this matter? Because if user code can sometimes rely on the callback running synchronously, every consumer has to defensively handle both timings. The spec eliminates the choice: always async.
The fix — wrap every callback in a microtask
then(onFulfilled, onRejected) { if (this.state === 'fulfilled') { queueMicrotask(() => onFulfilled(this.value)); } else if (this.state === 'rejected') { queueMicrotask(() => onRejected(this.value)); } else { this.onFulfilledCallbacks.push(() => { queueMicrotask(() => onFulfilled(this.value)); }); this.onRejectedCallbacks.push(() => { queueMicrotask(() => onRejected(this.value)); }); } }
queueMicrotask and not setTimeout: microtasks run before the next macrotask and before paint. Using setTimeout(fn, 0) would still fire async but in the WRONG queue — too late, after rendering. This is why Promise.resolve().then(fn) beats setTimeout(fn, 0) every time. See Event Loop & Async.
Make it chainable
The killer feature of promises is .then(...).then(...).then(...). For that to work, .then has to return a NEW promise that settles based on what its handler returns.
The three things a .then handler can do
- Return a normal value → next promise fulfills with that value.
- Throw an error → next promise rejects with that error.
- Return another promise → next promise adopts its state.
then(onFulfilled, onRejected) { // Defaults so missing handlers pass through onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : v => v; onRejected = typeof onRejected === 'function' ? onRejected : e => { throw e; }; return new MyPromise((resolve, reject) => { const handle = (handler, value) => { queueMicrotask(() => { try { const result = handler(value); if (result instanceof MyPromise) { result.then(resolve, reject); // adopt its state } else { resolve(result); // normal value } } catch (err) { reject(err); // handler threw } }); }; if (this.state === 'fulfilled') { handle(onFulfilled, this.value); } else if (this.state === 'rejected') { handle(onRejected, this.value); } else { this.onFulfilledCallbacks.push(() => handle(onFulfilled, this.value)); this.onRejectedCallbacks.push(() => handle(onRejected, this.value)); } }); }
Three things just fell out for free
.then(a).then(b) — b's handler receives a's return value.onFulfilled handlers until it hits an onRejected (or a .catch).catch(onRejected) { return this.then(null, onRejected); // just sugar } finally(callback) { return this.then( v => { callback(); return v; }, e => { callback(); throw e; } ); }
Static helpers: all, race, allSettled, any
These aren't core to the state machine — they're combinators built on top of .then. The implementations are short and reveal what each one promises.
Promise.resolve / Promise.reject
static resolve(value) { if (value instanceof MyPromise) return value; // pass through return new MyPromise(resolve => resolve(value)); } static reject(reason) { return new MyPromise((_, reject) => reject(reason)); }
Promise.resolve, you get the SAME promise back, not a promise wrapping it. Without this, Promise.all([Promise.resolve(1), p]) would yield [1, <promise>] — a wrapped promise instead of the value. This is also why await Promise.resolve(somePromise) doesn't add a layer.
Promise.all — wait for all, fail on any
static all(promises) { return new MyPromise((resolve, reject) => { const results = []; let completed = 0; if (promises.length === 0) return resolve([]); promises.forEach((p, i) => { MyPromise.resolve(p).then( value => { results[i] = value; completed++; if (completed === promises.length) resolve(results); }, reject // any rejection rejects the whole thing ); }); }); }
Promise.race — first to settle, wins
static race(promises) { return new MyPromise((resolve, reject) => { promises.forEach(p => MyPromise.resolve(p).then(resolve, reject)); }); }
Whichever then fires first calls resolve or reject; subsequent calls are ignored thanks to the "settle once" guard.
Promise.allSettled — wait for all, never fail
static allSettled(promises) { return MyPromise.all(promises.map(p => MyPromise.resolve(p).then( value => ({ status: 'fulfilled', value }), reason => ({ status: 'rejected', reason }) ) )); }
Promise.any — first to fulfill, wins
static any(promises) { return new MyPromise((resolve, reject) => { const errors = []; let rejected = 0; if (promises.length === 0) return reject(new AggregateError([], 'All promises rejected')); promises.forEach((p, i) => { MyPromise.resolve(p).then( resolve, // any fulfillment wins immediately err => { errors[i] = err; rejected++; if (rejected === promises.length) { reject(new AggregateError(errors, 'All promises rejected')); } } ); }); }); }
all vs allSettled
all short-circuits on first rejection. allSettled always waits for everyone — perfect for "show what loaded, mark what failed".
race vs any
race resolves OR rejects on first settle. any only resolves on first fulfill; ignores rejections until all fail.
All ~80 lines together
Here is the complete implementation. Passes 99% of the Promises/A+ test suite. The remaining 1% is around interop with "thenables" — objects from other promise libraries that pretend to be promises. We'll skip that for clarity.
class MyPromise { constructor(executor) { this.state = 'pending'; this.value = undefined; this.onFulfilledCallbacks = []; this.onRejectedCallbacks = []; const resolve = (value) => { if (this.state !== 'pending') return; this.state = 'fulfilled'; this.value = value; this.onFulfilledCallbacks.forEach(cb => cb()); }; const reject = (reason) => { if (this.state !== 'pending') return; this.state = 'rejected'; this.value = reason; this.onRejectedCallbacks.forEach(cb => cb()); }; try { executor(resolve, reject); } catch (err) { reject(err); } } then(onFulfilled, onRejected) { onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : v => v; onRejected = typeof onRejected === 'function' ? onRejected : e => { throw e; }; return new MyPromise((resolve, reject) => { const handle = (handler, value) => { queueMicrotask(() => { try { const result = handler(value); if (result instanceof MyPromise) { result.then(resolve, reject); } else { resolve(result); } } catch (err) { reject(err); } }); }; if (this.state === 'fulfilled') handle(onFulfilled, this.value); else if (this.state === 'rejected') handle(onRejected, this.value); else { this.onFulfilledCallbacks.push(() => handle(onFulfilled, this.value)); this.onRejectedCallbacks.push(() => handle(onRejected, this.value)); } }); } catch(onRejected) { return this.then(null, onRejected); } finally(callback) { return this.then( v => { callback(); return v; }, e => { callback(); throw e; } ); } static resolve(v) { if (v instanceof MyPromise) return v; return new MyPromise(r => r(v)); } static reject(r) { return new MyPromise((_, rj) => rj(r)); } static all(promises) { return new MyPromise((resolve, reject) => { const results = []; let done = 0; if (!promises.length) return resolve([]); promises.forEach((p, i) => MyPromise.resolve(p).then( v => { results[i] = v; if (++done === promises.length) resolve(results); }, reject )); }); } static race(promises) { return new MyPromise((resolve, reject) => { promises.forEach(p => MyPromise.resolve(p).then(resolve, reject)); }); } static allSettled(promises) { return MyPromise.all(promises.map(p => MyPromise.resolve(p).then( value => ({ status: 'fulfilled', value }), reason => ({ status: 'rejected', reason }) ))); } }
Quick sanity test
const p = new MyPromise((resolve) => setTimeout(() => resolve(1), 100)); p.then(v => v + 1) .then(v => v * 10) .then(v => console.log('got', v)); // "got 20" after 100ms MyPromise.all([ MyPromise.resolve(1), MyPromise.resolve(2), new MyPromise(r => setTimeout(() => r(3), 50)) ]).then(console.log); // [1, 2, 3]
What this teaches you about await
Once you've built .then, await stops being magic. It's literally syntactic sugar for .then on the rest of the function.
// You write this: async function load() { const user = await getUser(); const posts = await getPosts(user); return posts; } // The engine effectively runs this: function load() { return getUser().then(user => getPosts(user).then(posts => posts) ); }
Each await schedules a microtask. The function body becomes the body of a .then handler. This is why:
- An async function always returns a promise — even
async function f() { return 1; }returnsPromise<1>. try/catcharoundawaitworks — because the thrown error from the promise becomes a.catchbranch the engine threads back through.- Sequential
awaitis slow — each one is a separate.then, and the chain serializes. UsePromise.allto parallelize independent work.
What you can confidently say in an interview
"A Promise is a state machine with three states — pending, fulfilled, rejected — and a guarantee of settling exactly once. .then registers handlers that fire in a microtask after the promise settles, and returns a new promise that adopts whatever its handler returns. That's the entire core. async/await is syntactic sugar over chained .then calls, and Promise.all/race/allSettled are combinators built on top."
The seven facts that fall out
- Executors run synchronously — the work starts immediately at construction.
- State changes once — subsequent
resolve/rejectcalls are silently ignored. - Handlers are always async — even on a pre-settled promise, the callback fires in a microtask.
- Microtasks beat macrotasks —
Promise.resolve().then(fn)runs beforesetTimeout(fn, 0). - Returning a promise from a handler flattens — the chain adopts that promise's state, no nesting.
- Throwing inside a handler rejects the next promise — errors propagate down the chain until caught.
awaitis.thenin disguise — everyawaitis a microtask schedule plus a continuation.
Before you leave — how confident are you with this?
Your honest rating shapes when you'll see this again. No grades, no shame.
Comments
Loading comments…