Understanding Promise Internals: A Deep Dive into Promise/A+ Implementation

Constructor

The Promise constructor accepts an executor function that receives two arguments: resolve and reject. This is where the core initialization logic resides.

class SimplePromise {
    constructor(executor) {
        this.status = PENDING;
        this.onFulfilledCallbacks = [];
        this.onRejectedCallbacks = [];

        executor(
            this._createResolver(),
            this._createRejector()
        );
    }

    _createResolver() {
        return (value) => this._resolve(value);
    }

    _createRejector() {
        return (reason) => this._transitionTo(REJECTED, reason);
    }
}

The constructor initializes the promise in a pending state and sets up empty callback queues. The executor function is invoked immediately, receiving bound versions of resolve and reject methods.

The then() Method

The then() method is the cornerstone of Promise chaining. It always returns a new Promise, enabling the fluent API that developers use daily.

then(onFulfilled, onRejected) {
    const nextPromise = new SimplePromise(() => {});

    if (this.status === FULFILLED) {
        queueMicrotask(() => {
            this._runFulfilledHandler(onFulfilled, this.result, nextPromise);
        });
    }
    else if (this.status === REJECTED) {
        queueMicrotask(() => {
            this._runRejectedHandler(onRejected, this.error, nextPromise);
        });
    }
    else {
        this.onFulfilledCallbacks.push({ handler: onFulfilled, promise: nextPromise });
        this.onRejectedCallbacks.push({ handler: onRejected, promise: nextPromise });
    }

    return nextPromise;
}

The method handles three distinct scenarios based on the current promise state. If the promise is already settled, the appropriate handler executes asynchronously. If still pending, callbacks are queued for later execution when the promise transitions.

Resolution and Rejection

When resolving, we must handle various value types, including nested promises and thenable objects. This complexity requires a dedicated resolution handler.

_resolve(value) {
    this._settleWithValue(this, value, []);
}

_settleWithValue(promise, value, seenValues = []) {
    if (promise === value) {
        this._transitionTo(REJECTED, new TypeError('Circular reference detected'));
        return;
    }

    if (this._isPromiseInstance(value)) {
        if (value.status !== PENDING) {
            this._transitionTo(value.status, value.status === FULFILLED ? value.result : value.error);
        } else {
            value.then(
                (resolvedValue) => this._settleWithValue(promise, resolvedValue, seenValues),
                (rejectReason) => this._transitionTo(REJECTED, rejectReason)
            );
        }
        return;
    }

    if (this._isThenable(value)) {
        let invoked = false;
        try {
            const thenHandler = value.then;
            if (typeof thenHandler === 'function') {
                thenHandler.call(
                    value,
                    (resolved) => {
                        if (invoked) return;
                        seenValues.push(resolved);
                        this._settleWithValue(promise, resolved, seenValues);
                        invoked = true;
                    },
                    (rejectReason) => {
                        if (invoked) return;
                        this._transitionTo(REJECTED, rejectReason);
                        invoked = true;
                    }
                );
            } else {
                this._transitionTo(FULFILLED, value);
            }
        } catch (err) {
            if (!invoked) {
                this._transitionTo(REJECTED, err);
            }
        }
        return;
    }

    this._transitionTo(FULFILLED, value);
}

The resolution logic handles four distinct cases: direct values, promise instances, thenable objects, and everything else. The thenable detection uses duck typing, checking for the presence of a callable then property. A circular reference guard prevents infinite recursion in complex thenable chains.

State Transitions

Promises have exact three possible states: pending, fulfilled, or rejected. Once settled, a promise's state becomes immutable.

_transitionTo(targetStatus, data) {
    if (this.status !== PENDING || targetStatus === PENDING) {
        return;
    }

    Object.defineProperty(this, 'status', {
        value: targetStatus,
        writable: false,
        configurable: false
    });

    if (targetStatus === FULFILLED) {
        Object.defineProperty(this, 'result', {
            value: data,
            writable: false,
            configurable: false
        });
        queueMicrotask(() => {
            this.onFulfilledCallbacks.forEach(({ handler, promise }) => {
                this._runFulfilledHandler(handler, data, promise);
            });
            this.onFulfilledCallbacks = [];
        });
    }

    if (targetStatus === REJECTED) {
        Object.defineProperty(this, 'error', {
            value: data,
            writable: false,
            configurable: false
        });
        queueMicrotask(() => {
            this.onRejectedCallbacks.forEach(({ handler, promise }) => {
                this._runRejectedHandler(handler, data, promise);
            });
            this.onRejectedCallbacks = [];
        });
    }
}

The transition method ensures state immutability by redefining the status property with non-writable descriptors. When transitioning to a settled state, all queued callbacks execute asynchronously via microtasks.

Handler Execution

The handler execution functions bridge the gap between promise resolution and the next promise in the chain.

_runFulfilledHandler(handler, value, nextPromise) {
    if (typeof handler !== 'function') {
        this._transitionTo.call(nextPromise, FULFILLED, value);
        return;
    }

    try {
        const adoptedResult = handler(value);
        this._settleWithValue(nextPromise, adoptedResult, []);
    } catch (err) {
        this._transitionTo.call(nextPromise, REJECTED, err);
    }
}

_runRejectedHandler(handler, reason, nextPromise) {
    if (typeof handler !== 'function') {
        this._transitionTo.call(nextPromise, REJECTED, reason);
        return;
    }

    try {
        const adoptedResult = handler(reason);
        this._settleWithValue(nextPromise, adoptedResult, []);
    } catch (err) {
        this._transitionTo.call(nextPromise, REJECTED, err);
    }
}

If a handler is not a function, the value or reason passes through to the next promise unchanged. When a handler function is provided, its return value determines the next promise's fate through the _settleWithValue method.

Type Checking Utilities

Helper functions determine the nature of values encountered during promise resolution.

_isThenable(obj) {
    return obj !== null && (typeof obj === 'object' || typeof obj === 'function');
}

_isPromiseInstance(val) {
    return val instanceof SimplePromise;
}

These utilities enable the polymorphic behavior required by the Promise/A+ specification, allowing promises to handle various value types consistently.

Why Microtasks Matter

The asynchronous nature of promise callbacks serves a crucial purpose. When a promise resolves, it schedules callbacks for the next microtask rather than executing them immediately.

let promise = new SimplePromise((resolve) => {
    resolve(42);
});
.then((value) => {
    console.log(value);
});

This design ensures that callbacks registered via then() are properly queued before execution. Without microtask scheduling, the resolution would occur synchronously, but registered callbacks would not yet exist. The event loop handles microtasks after the current execution stack empties but before rendering or other tasks, providing the ideal timing for promise callbacks.

Tags: javascript Promise Async Promise/A+ Event Loop

Posted on Fri, 08 May 2026 04:06:07 +0000 by jmosterb