Build a JavaScript Promise from Scratch: How `then`, Chaining, and Microtasks Actually Work
Most JavaScript developers use Promises every day without ever building one. That is fine — fetch() and library APIs hand them out and you chain .then(). But if you have ever been bitten by a chaining bug, a forgotten await, or a Promise that mysteriously "swallowed" an error, the cure is to build one yourself in 60 lines.
This article does. By the end you will know exactly what then, catch, resolve, and reject actually do — and why async/await is the syntactic sugar over this machine.
This is Part 8 of the JavaScript Fundamentals series. Read Part 7 on the event loop first — Promise callbacks are microtasks, and that mechanism is the heartbeat of what follows.
What a Promise actually is #
A Promise is an object that wraps an eventually-available value. It has:
- A state:
pending,fulfilled, orrejected. The state can only transition once. - A value (if fulfilled) or reason (if rejected).
- A way to register callbacks:
then(onFulfilled, onRejected). - A guarantee: registered callbacks run asynchronously, on the microtask queue.
That is the whole API. Everything else — catch, finally, chaining, Promise.all, async/await — is built on top.
Version 1: the simplest possible Promise #
class MyPromise {
constructor(executor) {
this.state = 'pending';
this.value = undefined;
this.callbacks = [];
const resolve = (value) => {
if (this.state !== 'pending') return;
this.state = 'fulfilled';
this.value = value;
this.callbacks.forEach(cb => queueMicrotask(() => cb.onFulfilled(value)));
};
const reject = (reason) => {
if (this.state !== 'pending') return;
this.state = 'rejected';
this.value = reason;
this.callbacks.forEach(cb => queueMicrotask(() => cb.onRejected(reason)));
};
try { executor(resolve, reject); } catch (e) { reject(e); }
}
then(onFulfilled, onRejected) {
if (this.state === 'fulfilled') {
queueMicrotask(() => onFulfilled(this.value));
} else if (this.state === 'rejected') {
queueMicrotask(() => onRejected(this.value));
} else {
this.callbacks.push({ onFulfilled, onRejected });
}
}
}
// Try it:
new MyPromise((resolve) => setTimeout(() => resolve('hi'), 100))
.then((v) => console.log(v)); // 'hi' after 100ms
This is already 80% correct. Three properties hold:
- The
statetransition is irreversible. - Callbacks registered before settlement queue up; callbacks registered after fire immediately (on the microtask queue).
- All callbacks run via
queueMicrotask— so order is guaranteed and we don't run sync insidethen.
What's missing? Chaining. then does not return a Promise yet.
Version 2: chaining #
For .then().then().then() to work, every then must return a new Promise that resolves based on what the callback returns.
then(onFulfilled, onRejected) {
return new MyPromise((resolve, reject) => {
const handle = (value) => {
try {
const result = onFulfilled ? onFulfilled(value) : value;
if (result instanceof MyPromise) {
// The callback returned another promise — adopt its state
result.then(resolve, reject);
} else {
resolve(result);
}
} catch (e) {
reject(e);
}
};
const handleReject = (reason) => {
try {
const result = onRejected ? onRejected(reason) : reason;
// ↑ if no onRejected, propagate the rejection through
if (!onRejected) { reject(reason); return; }
if (result instanceof MyPromise) result.then(resolve, reject);
else resolve(result);
} catch (e) {
reject(e);
}
};
if (this.state === 'fulfilled') queueMicrotask(() => handle(this.value));
else if (this.state === 'rejected') queueMicrotask(() => handleReject(this.value));
else this.callbacks.push({ onFulfilled: handle, onRejected: handleReject });
});
}
Now chains work:
new MyPromise(r => r(1))
.then(v => v + 1)
.then(v => new MyPromise(r => setTimeout(() => r(v * 10), 50)))
.then(v => console.log(v)); // 20
The magic line is if (result instanceof MyPromise) result.then(resolve, reject). This is what lets a .then callback return a promise and have the next .then wait for it. Without that, you would get a Promise-of-a-Promise.
Adding catch and finally #
These are one-line convenience methods:
catch(onRejected) {
return this.then(undefined, onRejected);
}
finally(onFinally) {
return this.then(
(v) => { onFinally(); return v; },
(e) => { onFinally(); throw e; },
);
}
catch(fn) is sugar for then(undefined, fn). finally(fn) runs the cleanup regardless and passes through the original value or error.
Static helpers #
static resolve(value) {
return new MyPromise((res) => res(value));
}
static reject(reason) {
return new MyPromise((_, rej) => rej(reason));
}
static all(promises) {
return new MyPromise((resolve, reject) => {
const results = [];
let remaining = promises.length;
if (remaining === 0) return resolve([]);
promises.forEach((p, i) => {
MyPromise.resolve(p).then(
(v) => { results[i] = v; if (--remaining === 0) resolve(results); },
reject,
);
});
});
}
Promise.all is interesting — note the early reject on first failure. There is also Promise.allSettled (waits for everyone regardless), Promise.race (settles with the first to settle), Promise.any (resolves on first success, rejects only if all fail). Each is ~10 lines on top of the core.
How async/await fits in #
async function automatically wraps its return value in a Promise. await unwraps one:
async function fetchUser(id) {
const res = await fetch(`/users/${id}`); // pause, wait for promise
const body = await res.json(); // pause again
return body; // wrapped in Promise.resolve(body)
}
Desugared:
function fetchUser(id) {
return fetch(`/users/${id}`)
.then((res) => res.json())
.then((body) => body);
}
The await is a hidden .then. The continuation after await becomes the .then callback. That is why awaited code is a microtask (Part 7) and why try/catch works for awaited rejections (because await re-throws the rejection inside the function's normal execution).
Our articles Async/Await Beyond Syntactic Sugar and Deciphering Async/Await go deeper into the desugaring.
The state diagram #
┌──────────────┐
│ pending │
└─┬─────────┬──┘
resolve(val) │ │ reject(reason)
▼ ▼
┌────────────┐ ┌────────────┐
│ fulfilled │ │ rejected │
└────────────┘ └────────────┘
(terminal) (terminal)
Once a promise settles, it never changes state. Calling resolve after reject (or vice versa) is silently ignored. That's why the spec calls them "settled" — locked in.
Microtask scheduling matters #
Why must Promise callbacks be scheduled as microtasks (not run synchronously)?
Consider:
let result;
Promise.resolve(42).then(v => result = v);
console.log(result); // undefined
If then ran the callback synchronously, result would be 42. But the spec forbids this. Why?
Because it breaks the synchronous-then-async invariant developers rely on. If the callback might be sync or async depending on whether the promise was already resolved, every API user has to guard against both. The spec normalizes this: callbacks are always async — even for an already-resolved promise. The microtask queue is the perfect channel — runs immediately after the current sync code, before any I/O or timers.
Common gotchas #
Gotcha 1: Forgetting to return a promise inside .then.
promise.then(v => {
fetch('/log', { body: v }); // ✗ fire-and-forget — next .then doesn't wait
return v;
});
If the chain should wait for the fetch, return fetch(...). Otherwise it's an orphan promise — and orphan rejections eventually crash Node and warn in browsers.
Gotcha 2: Synchronous code in an executor still runs synchronously.
new Promise((resolve) => {
console.log('a');
resolve('b');
console.log('c');
});
console.log('d');
// a, c, d (NOT a, c, b, d)
The executor body is sync. resolve('b') schedules but doesn't pause; the executor finishes through 'c'. The promise's .then callbacks only fire on the next microtask tick.
Gotcha 3: catch after then doesn't catch errors thrown in then's success callback only IF you also have an onRejected argument in the same .then.
p.then(
v => { throw new Error('boom'); },
e => console.log('caught', e), // ✗ does NOT catch — same .then's success errors propagate forward, not into its own onRejected
).catch(e => console.log('outer', e)); // ✓ this catches
Rule of thumb: prefer chained .then(...).catch(...) over two-arg .then(success, fail).
Gotcha 4: Unhandled rejections.
Promise.reject('oops'); // unhandled rejection — Node will crash, browser warns
Every rejection must eventually be .catched or awaited inside a try/catch. Add a global handler as a safety net:
window.addEventListener('unhandledrejection', e => { /* log */ });
process.on('unhandledRejection', (e) => { /* log */ });
The full ~60-line implementation #
For reference, here is the complete Promise. Drop into a file, runs as-is:
class MyPromise {
constructor(executor) {
this.state = 'pending';
this.value = undefined;
this.callbacks = [];
const resolve = (v) => this._settle('fulfilled', v);
const reject = (r) => this._settle('rejected', r);
try { executor(resolve, reject); } catch (e) { reject(e); }
}
_settle(state, value) {
if (this.state !== 'pending') return;
this.state = state;
this.value = value;
this.callbacks.forEach(cb => queueMicrotask(() => cb(state, value)));
}
then(onF, onR) {
return new MyPromise((resolve, reject) => {
const dispatch = (state, value) => {
const handler = state === 'fulfilled' ? onF : onR;
if (!handler) { state === 'fulfilled' ? resolve(value) : reject(value); return; }
try {
const out = handler(value);
out instanceof MyPromise ? out.then(resolve, reject) : resolve(out);
} catch (e) { reject(e); }
};
if (this.state !== 'pending') queueMicrotask(() => dispatch(this.state, this.value));
else this.callbacks.push(dispatch);
});
}
catch(onR) { return this.then(undefined, onR); }
finally(onF) { return this.then(v => { onF(); return v; }, e => { onF(); throw e; }); }
static resolve(v) { return new MyPromise(r => r(v)); }
static reject(r) { return new MyPromise((_, rj) => rj(r)); }
}
Recap #
- A Promise is a state machine:
pending→fulfilledorrejected, then frozen. - Callbacks registered with
thenare always async — they run on the microtask queue, never synchronously. - Chaining works because
thenreturns a new Promise that adopts the state of whatever the callback returns. catchis.then(undefined, fn).finallyruns cleanup and passes through.async/awaitis sugar —awaitis a hidden.then; awaited code is a microtask continuation.- Forgetting to
returninside.thenorphans promises; uncaught rejections crash Node.
Next up: Memory and Garbage Collection in JavaScript — how V8 decides what to free, why your SPA leaks memory, and the four classic patterns that keep objects alive forever.
Up next in JavaScript
More from this topic
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


