What Happens If a JavaScript Promise Completes Before Calling .then()? Explained

JavaScript Promises revolutionized asynchronous programming by providing a clean, readable alternative to callback hell. They allow us to handle operations like API calls, file reads, or timers asynchronously, ensuring non-blocking code execution. But one common question arises: What if a Promise completes (either fulfills or rejects) before we call .then() or .catch()? Does the result get lost? Will the callback never run?

In this blog, we’ll demystify this behavior. We’ll start with a quick recap of Promises, explore their lifecycle, and dive deep into how JavaScript handles settled Promises when .then() is called late. By the end, you’ll understand why Promises are stateful, how they store results, and how to avoid common pitfalls.

Table of Contents#

  1. Understanding Promises: A Quick Recap
  2. The Lifecycle of a Promise
  3. What Happens When a Promise Completes Before .then()?
  4. How JavaScript Handles This Internally
  5. Practical Examples
  6. Common Pitfalls and Best Practices
  7. Conclusion
  8. References

1. Understanding Promises: A Quick Recap#

A Promise is an object representing the eventual completion (or failure) of an asynchronous operation and its resulting value. It acts as a "placeholder" for a future value, allowing you to write code that reacts to that value once it’s available.

Core Syntax:#

const promise = new Promise((resolve, reject) => {
  // Asynchronous operation (e.g., API call, timer)
  if (operationSuccess) {
    resolve(result); // Fulfill the Promise with a value
  } else {
    reject(error); // Reject the Promise with a reason (error)
  }
});
 
// Handle fulfillment or rejection
promise.then(onFulfilled, onRejected).catch(onRejected);
  • .then(): Attaches callbacks for fulfillment (onFulfilled) and/or rejection (onRejected).
  • .catch(): Shorthand for .then(null, onRejected) (handles only rejections).

2. The Lifecycle of a Promise#

A Promise exists in one of three states, and once settled, its state is immutable (it cannot change):

StateDescription
PendingInitial state: The operation is still in progress (neither fulfilled nor rejected).
FulfilledThe operation completed successfully. The Promise has a value (e.g., API response).
RejectedThe operation failed. The Promise has a reason (e.g., an Error object).

Key Transition: A Promise moves from pendingfulfilled or pendingrejected once the async operation completes. Once settled (fulfilled/rejected), it stays in that state forever.

3. What Happens When a Promise Completes Before .then()?#

The critical insight: Promises are stateful and remember their settled value/reason. Even if a Promise fulfills or rejects before you call .then() or .catch(), the callback will still execute with the stored result. Let’s break this down.

Case 1: Promise Fulfills Before .then()#

Suppose a Promise resolves immediately (e.g., Promise.resolve("Hello")). If you call .then() later (even seconds or minutes after), will the onFulfilled callback run?

Yes! Because the Promise stores its fulfilled value. When .then() is called, the JavaScript engine checks the Promise’s state. If it’s already fulfilled, the onFulfilled callback is queued to run with the stored value.

Example: Fulfilled Promise with Late .then()#

// Create a Promise that fulfills immediately
const quickPromise = Promise.resolve("Done!");
 
// Simulate "later" with setTimeout (1 second delay)
setTimeout(() => {
  // Attach .then() AFTER the Promise is already fulfilled
  quickPromise.then(value => {
    console.log("Value received:", value); // Logs "Value received: Done!"
  });
}, 1000);

Why this works: The quickPromise stores "Done!" when it fulfills. When .then() is called later, the engine sees the Promise is fulfilled and immediately schedules the callback to run with the stored value.

Case 2: Promise Rejects Before .then()#

Rejected Promises behave similarly: they store their rejection reason (e.g., an Error). If you call .catch() or .then(null, onRejected) later, the onRejected callback will still execute with the stored reason.

Example: Rejected Promise with Late .catch()#

// Create a Promise that rejects immediately
const faultyPromise = Promise.reject(new Error("Something broke!"));
 
// Simulate "later" with setTimeout (1 second delay)
setTimeout(() => {
  // Attach .catch() AFTER the Promise is already rejected
  faultyPromise.catch(error => {
    console.log("Caught error:", error.message); // Logs "Caught error: Something broke!"
  });
}, 1000);

Caveat: Some environments (browsers, Node.js) may warn about "unhandled rejections" if no rejection handler is attached at the time of rejection. Even if you add .catch() later, the warning might still appear. We’ll cover this in Common Pitfalls.

4. How JavaScript Handles This Internally#

To understand why late .then() calls work, we need to peek under the hood:

1. Promise State Storage#

Every Promise object internally stores:

  • Its current state (pending, fulfilled, or rejected).
  • A value (if fulfilled) or reason (if rejected).
  • A list of pending .then()/.catch() callbacks.

2. Callback Queueing#

When a Promise settles (fulfills/rejects):

  • All attached callbacks are added to the microtask queue (a priority queue for lightweight async operations).
  • If a callback is added after the Promise settles (e.g., via a late .then()), the engine checks the Promise’s state and queues the callback immediately.

3. Microtasks vs. Macrotasks#

Microtasks (e.g., Promise callbacks) run after the current synchronous code completes but before the next macrotask (e.g., setTimeout, DOM events). This ensures Promise callbacks execute as soon as possible, even if added late.

Example: Microtask Execution Order#

const promise = Promise.resolve("Microtask");
 
console.log("Start (sync)");
 
// Attach .then() to a fulfilled Promise
promise.then(value => console.log(value)); // Microtask
 
console.log("End (sync)");
 
// Output:
// Start (sync)
// End (sync)
// Microtask (runs after sync code, as a microtask)

5. Practical Examples#

Let’s explore real-world scenarios where a Promise might settle before .then() is called.

Example 1: Cached Data#

Imagine fetching data from an API, but caching the result to avoid redundant calls. If the cache exists, the Promise fulfills immediately with the cached value. Even if .then() is added later (e.g., when a component mounts), it will receive the cached data.

let cachedUser = null;
 
// Simulate API call with caching
function fetchUser() {
  if (cachedUser) {
    // Return cached data (fulfills immediately)
    return Promise.resolve(cachedUser);
  } else {
    // Fetch from API (simulate delay)
    return new Promise(resolve => {
      setTimeout(() => {
        const user = { id: 1, name: "Alice" };
        cachedUser = user; // Cache the result
        resolve(user);
      }, 1000);
    });
  }
}
 
// First call: fetches from API (takes 1s)
fetchUser().then(user => console.log("First call:", user));
 
// Second call: uses cache (fulfills immediately)
setTimeout(() => {
  fetchUser().then(user => console.log("Second call (cached):", user));
}, 2000);
 
// Output:
// First call: { id: 1, name: "Alice" } (after 1s)
// Second call (cached): { id: 1, name: "Alice" } (after 2s, but resolves instantly)

Example 2: Accidentally Late .catch()#

If a Promise rejects and you attach .catch() later, the rejection will still be handled—but beware of unhandled rejection warnings in some environments.

// Reject immediately
const badPromise = Promise.reject(new Error("Oops!"));
 
// Add .catch() 1 second later
setTimeout(() => {
  badPromise.catch(error => console.log("Caught late:", error.message)); // Logs "Oops!"
}, 1000);

Warning: In browsers/Node.js, if no .catch() is attached at the time of rejection, you may see an "Unhandled Rejection" warning. Adding .catch() later will handle the error, but the warning might still appear (more on this below).

6. Common Pitfalls and Best Practices#

Pitfall 1: Unhandled Rejection Warnings#

If a Promise rejects and no .catch() is attached when it rejects, browsers/Node.js may log an "Unhandled Rejection" warning. Even if you add .catch() later, the warning might persist because the environment detected the rejection before the handler was attached.

Fix: Always attach .catch() immediately when creating the Promise, or use process.on('unhandledRejection') (Node.js) to catch global unhandled rejections.

Pitfall 2: Assuming Synchronous Execution#

Even if a Promise is already fulfilled, .then() callbacks run as microtasks, not synchronously. This can lead to unexpected ordering if you forget async behavior.

Example:#

const promise = Promise.resolve(10);
let result;
 
promise.then(value => {
  result = value; // Runs as a microtask
});
 
console.log(result); // Logs "undefined" (sync code runs before microtask)

Fix: Place code dependent on the Promise result inside the .then() callback.

Best Practices#

  1. Attach Handlers Early: Always add .then()/.catch() immediately after creating a Promise to avoid unhandled rejections.
  2. Cache Wisely: Use settled Promises to cache async results (e.g., API responses) for performance.
  3. Handle All Rejections: Never leave a Promise without a .catch()—even if you think it can’t fail.

7. Conclusion#

JavaScript Promises are stateful and remember their settled value or reason. When you call .then() or .catch() after a Promise has already fulfilled or rejected, the callback will still execute with the stored result. This behavior is critical for use cases like caching, delayed handler attachment, and ensuring async code remains predictable.

By understanding that Promises retain their state, you can write more robust asynchronous code and avoid common pitfalls like unhandled rejections.

8. References#