How JavaScript Promises Work Internally: The Three States and Mechanics Explained

JavaScript Promises function as immutable state machines that transition from an initial pending state to either fulfilled or rejected, queuing reaction callbacks as microtasks that execute after the current call stack clears.

The leonardomso/33-js-concepts repository contains a comprehensive test suite in tests/functions-execution/promises/promises.test.js that demonstrates exactly how Promises work under the hood. These tests reveal the precise timing, state transitions, and queuing mechanics that govern asynchronous operations in JavaScript runtimes.

The Three States of a JavaScript Promise

Every Promise instance acts as a container for an asynchronous operation's eventual result, existing in exactly one of three mutually exclusive states.

Pending

The pending state represents the initial condition immediately after constructing a new Promise. In this state, the asynchronous operation has not yet completed, and the final value remains unknown.

Fulfilled

A Promise becomes fulfilled when the executor function successfully completes the operation by calling resolve(value). Once fulfilled, the Promise stores the resulting value and immediately schedules any attached .then() handlers to run as microtasks.

Rejected

The rejected state occurs when the executor calls reject(reason) or throws an uncaught exception during execution. This signals that the operation failed, and the Promise stores the error reason for propagation to .catch() handlers.

Internal Mechanics of Promise Execution

The Promise implementation follows a strict protocol for state management and callback execution that ensures predictable asynchronous behavior.

Synchronous Executor Function

When you invoke new Promise(executor), the executor function runs synchronously before the constructor returns. As demonstrated in tests/functions-execution/promises/promises.test.js lines 22-34, the executor executes immediately within the same call stack:

const order = [];
order.push('before');
new Promise((resolve) => {
  order.push('inside executor');
  resolve();
});
order.push('after');
// Result: ['before', 'inside executor', 'after']

This synchronous execution guarantees that resolver functions are available immediately, even though resolution itself happens asynchronously.

Immutable State Transitions

Once a Promise leaves the pending state, it becomes settled and its state is immutable. According to the test at lines 37-46 of the promises test file, subsequent calls to resolve() or reject() are silently ignored:

new Promise((resolve, reject) => {
  resolve('first');
  resolve('second');   // Ignored - state already fulfilled
  reject(new Error()); // Ignored - cannot transition to rejected
});

This immutability prevents race conditions and ensures that a Promise represents a single, unchanging outcome.

The Microtask Queue and Reaction Handlers

When a Promise settles, any callbacks registered via .then(), .catch(), or .finally() do not execute immediately. Instead, the JavaScript engine places these reactions on the microtask queue. As shown in lines 73-84 of the test suite, microtasks always execute after the current synchronous code completes but before macrotasks like setTimeout:

const order = [];
order.push('1');
Promise.resolve().then(() => order.push('3'));
order.push('2');
// order is currently ['1', '2']
// Microtask runs later, making it ['1', '2', '3']

This queuing mechanism ensures that Promise reactions run in a predictable order separate from the main execution flow.

Promise Chaining and Unwrapping

The .then() method always returns a new Promise instance, enabling method chaining. This new Promise adopts the state of whatever value the handler returns. As illustrated in lines 100-106 of promises.test.js, if you return a Promise from a .then() callback, the outer Promise unwraps it and adopts its eventual state:

Promise.resolve(1)
  .then(x => Promise.resolve(x + 1)) // Returns a Promise
  .then(x => x * 2);                 // x is 4 (unwrapped value)

If the callback returns a plain value, the new Promise fulfills with that value. If the callback throws an exception, the new Promise rejects with that error.

Error Propagation

Unhandled errors in Promise chains propagate downward until caught by a .catch() handler. If the executor function throws an exception synchronously (lines 48-53), the Promise automatically transitions to the rejected state with the thrown error as the reason. Similarly, errors thrown within .then() callbacks cause the returned Promise to reject, allowing errors to bubble through the chain until handled (demonstrated in lines 45-58).

Common Patterns and Pitfalls

Understanding these internals helps avoid common mistakes demonstrated in the 33-js-concepts test suite.

The Forgotten Return Bug

A frequent error involves forgetting to return a value inside a .then() callback. As shown in lines 57-66 of the tests, omitting the return statement causes the Promise to fulfill with undefined:

Promise.resolve('start')
  .then(v => { Promise.resolve(v + ' middle'); }) // Missing return
  .then(v => console.log(v)); // Logs: undefined

Always explicitly return values or Promise instances from .then() handlers to maintain the chain's data flow.

Utility Combinators

The repository also demonstrates static methods like Promise.all(), which takes an iterable of Promises and returns a new Promise that fulfills when all input Promises fulfill (lines 26-35). Internally, these combinators attach handlers to each input Promise and manage a shared state object to determine when the aggregate operation completes.

Summary

  • JavaScript Promises implement a state machine with three states: pending, fulfilled, and rejected.
  • The executor function runs synchronously during construction, while resolution handlers execute asynchronously via the microtask queue.
  • Promise states are immutable once settled; additional calls to resolve() or reject() have no effect.
  • The .then() method returns a new Promise that unwraps returned Promises or adopts thrown errors, enabling sophisticated chaining patterns.
  • Errors propagate through chains until caught, and synchronous exceptions in executors automatically trigger rejection.

Frequently Asked Questions

What happens if you call resolve() multiple times on the same Promise?

Only the first call to resolve() has any effect. As verified in tests/functions-execution/promises/promises.test.js lines 37-46, once a Promise transitions from pending to fulfilled or rejected, its state becomes immutable and subsequent resolver calls are silently ignored.

Why do Promise.then() callbacks run after synchronous code but before setTimeout?

Promise reactions are scheduled as microtasks, which have higher priority than macrotasks like setTimeout or setInterval. The JavaScript event loop processes all microtasks immediately after the current call stack clears but before rendering or executing the next macrotask, ensuring Promise handlers run before timers.

Can a fulfilled Promise ever become rejected?

No. A Promise's state transition is one-way and permanent. Once a Promise enters either the fulfilled or rejected state, it is considered settled and cannot change states. This immutability guarantee prevents race conditions and makes asynchronous results predictable.

How does throwing an error inside the Promise constructor affect the Promise?

If the executor function throws an exception before calling resolve() or reject(), the Promise automatically transitions to the rejected state with the thrown error as the rejection reason. This behavior, confirmed in lines 48-53 of the test file, ensures that synchronous errors during initialization do not remain uncaught.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →