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

> Explore how JavaScript Promises work internally. Understand their three states pending fulfilled or rejected and how they manage asynchronous operations.

- Repository: [Leonardo Maldonado/33-js-concepts](https://github.com/leonardomso/33-js-concepts)
- Tags: internals
- Published: 2026-03-03

---

**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`](https://github.com/leonardomso/33-js-concepts/blob/main/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`](https://github.com/leonardomso/33-js-concepts/blob/main/tests/functions-execution/promises/promises.test.js) lines 22-34, the executor executes immediately within the same call stack:

```javascript
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:

```javascript
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`:

```javascript
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`](https://github.com/leonardomso/33-js-concepts/blob/main/promises.test.js), if you return a Promise from a `.then()` callback, the outer Promise **unwraps** it and adopts its eventual state:

```javascript
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`:

```javascript
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`](https://github.com/leonardomso/33-js-concepts/blob/main/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.