How to resolve a promise in JavaScript

How to resolve a promise in JavaScript

To truly understand the machinery of a Promise, you must first descend into the engine room of the JavaScript runtime itself. At its core, JavaScript is a single-threaded environment. This is not a design flaw; it’s a fundamental architectural choice that simplifies state management immensely. There’s one call stack, one memory heap. One instruction executes, then the next, in a relentless, linear march. If an operation takes a long time-say, fetching a large file from a server-and we were to execute it synchronously, the entire world would stop. The browser would freeze. Clicks would go unanswered, animations would halt, and the user would be left staring at an unresponsive screen. This is the cardinal sin of UI programming.

To sidestep this roadblock, the runtime employs a clever sleight of hand: the event loop. When you initiate a non-blocking operation, like a network request via fetch or a timer with setTimeout, you’re not actually executing it on the main thread. Instead, you’re handing it off to the browser’s Web APIs or the Node.js C++ APIs. The JavaScript engine continues its work, unblocked. When the long-running task completes, its associated callback function isn’t executed immediately. It’s placed in a message queue. The event loop’s sole, tireless job is to check one thing: is the call stack empty? If it is, and only if it is, it will take the first message from the queue and push it onto the stack for execution. This is how JavaScript achieves concurrency without parallelism-an elegant dance of scheduling and delegation.

The earliest and most direct pattern for managing this asynchronous flow was the humble callback function. The concept is simple: you pass a function as an argument to another function, with the expectation that it will be invoked later when a certain task is complete. It’s a direct expression of ‘do this, and when you’re done, run that.’

console.log('Task A: Start');

setTimeout(function onComplete() {
  console.log('Task B: Complete (after 2 seconds)');
}, 2000);

console.log('Task C: Continue immediately');

// Output:
// Task A: Start
// Task C: Continue immediately
// Task B: Complete (after 2 seconds)

This works beautifully for a single asynchronous step. But reality is rarely so simple. We often need to perform a sequence of asynchronous operations, where each step depends on the result of the previous one. Fetch a user ID, then use that ID to fetch the user’s profile, then use the profile to fetch their recent posts. With callbacks, each successive step must be nested inside the callback of the one before it. The result is a syntactic nightmare, a structure that grows horizontally instead of vertically, often called ‘Callback Hell’ or the ‘Pyramid of Doom’.

Consider this simulated sequence of API calls. We’re not even introducing error handling yet, and the code’s logic is already becoming obscured by its own structure.

function getUser(callback) {
  setTimeout(() => {
    console.log('Fetched user.');
    callback(null, { id: 1, name: 'John Doe' });
  }, 500);
}

function getPosts(userId, callback) {
  setTimeout(() => {
    console.log('Fetched posts for user:', userId);
    callback(null, [{ id: 101, title: 'First Post' }, { id: 102, title: 'Second Post' }]);
  }, 500);
}

function getComments(postId, callback) {
  setTimeout(() => {
    console.log('Fetched comments for post:', postId);
    callback(null, [{ content: 'Great read!' }, { content: 'Interesting perspective.' }]);
  }, 500);
}

getUser((err, user) => {
  if (err) {
    console.error(err);
  } else {
    getPosts(user.id, (err, posts) => {
      if (err) {
        console.error(err);
      } else {
        getComments(posts[0].id, (err, comments) => {
          if (err) {
            console.error(err);
          } else {
            console.log('Displaying comments:', comments);
          }
        });
      }
    });
  }
});

The readability problem is obvious, but a more subtle and dangerous issue lurks beneath the surface: inversion of control. When you pass a callback to getUser, you are handing over the reins of your program’s execution. You are trusting the getUser function to call your continuation logic. You trust it will call it exactly once. You trust it won’t call it too early or too late, and that it will pass the expected arguments. You’ve given up control, and your program’s flow is now at the mercy of an external, potentially untrustworthy piece of code. This loss of control and the unmanageable nesting of logic are the fundamental problems that Promises were designed to solve. They provide a mechanism to reclaim control and flatten the pyramid back into a linear, comprehensible sequence of operations.

Chaining futures together one link at a time

A Promise is not a value; it is a proxy for a value not yet known. It’s an object that represents the eventual completion-or failure-of an asynchronous operation. Think of it as a receipt you receive when you place an order. You don’t have your item yet, but you have a token that guarantees you will eventually receive either the item or a notification that the order couldn’t be fulfilled. This token, this Promise object, has one primary method for interacting with that future result: .then().

The .then() method is where the magic happens. It takes one or two arguments: a callback for success (fulfillment) and an optional callback for failure (rejection). For now, we’ll focus on the success path. When you call .then() on a Promise, you are registering your interest. You are telling the system, “When you finally have the value this Promise represents, execute this function I’m giving you, and pass the value to it as an argument.” The crucial difference from the raw callback pattern is that you are not passing your continuation function *into* the asynchronous function. Instead, you are attaching it to the return value, the Promise object. Control is no longer inverted; you decide what to do with the result, when you want to.

function getUserPromise() {
  // The new Promise constructor takes a function (the "executor")
  // which is run immediately. The executor is passed two functions:
  // resolve and reject.
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('Fetched user.');
      // When the async work is done, we call resolve()
      // to fulfill the promise with a value.
      resolve({ id: 1, name: 'John Doe' });
    }, 500);
  });
}

const userPromise = getUserPromise();

userPromise.then(user => {
  // This code runs only after the promise is resolved.
  console.log('Processing user:', user.name);
});

This is cleaner, but the true paradigm shift becomes apparent when you realize what .then() returns. It does not return the original promise, nor does it return the value from within the promise. Calling .then() returns an entirely *new* Promise. This is the fundamental mechanism that enables chaining. Each link in the chain is a new Promise, waiting for the previous one to complete.

This new promise resolves with the return value of the callback you passed to .then(). If your callback returns the number 42, the new promise resolves with 42. This allows you to transform data in a sequence of steps. But the most powerful behavior occurs when your callback returns *another promise*. In this case, the promise returned by .then() doesn’t resolve immediately with that inner promise object. Instead, it waits. It effectively adopts the state of the promise you returned. It will only resolve when that inner promise resolves, and it will resolve with that inner promise’s value. This is how asynchronous dependencies are flattened from a nested pyramid into a linear, readable chain.

Let’s refactor our nested API call nightmare into a sequence of promise-returning functions. Each function now encapsulates its own asynchronous logic and returns a promise, a token for its future result.

function getPostsPromise(userId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('Fetched posts for user:', userId);
      resolve([{ id: 101, title: 'First Post' }, { id: 102, title: 'Second Post' }]);
    }, 500);
  });
}

function getCommentsPromise(postId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('Fetched comments for post:', postId);
      resolve([{ content: 'Great read!' }, { content: 'Interesting perspective.' }]);
    }, 500);
  });
}

// Now, we can chain them.
getUserPromise()
  .then(user => {
    // The 'user' object is the resolved value from getUserPromise.
    // We return a *new* promise from this 'then' block.
    return getPostsPromise(user.id); 
  })
  .then(posts => {
    // The 'posts' array is the resolved value from getPostsPromise.
    // The chain waited for it to complete.
    return getCommentsPromise(posts[0].id);
  })
  .then(comments => {
    // The 'comments' array is the resolved value from getCommentsPromise.
    console.log('Displaying comments:', comments);
  });

The structure is transformed. It’s no longer a rightward-drifting pyramid but a clean, vertical sequence of operations. Each .then() block represents a discrete, asynchronous step in a larger workflow. The code reads almost like a synchronous script: get user, then get posts, then get comments. The complexity of the event loop, the message queue, and the callback scheduling is abstracted away behind this elegant facade. The state of the entire asynchronous operation is encapsulated within the promise chain itself. Each link in the chain is a pending promise that holds the entire operation in stasis until the preceding link resolves and provides the necessary data to proceed.

When the callback inside a .then() executes, the promise returned by that .then() is locked to the fate of whatever the callback returns. If it’s a simple value, the resolution is immediate. If it’s another promise, the chain pauses, transparently waiting for this new piece of asynchronous work to finish before moving to the next .then(). This process of a promise resolving to another promise is called assimilation, and it’s the core engine of asynchronous composition in modern JavaScript. The control flow is no longer inverted; it’s explicitly defined by the structure of the chain you build. Each step passes control to the next in a well-defined, predictable manner, without ever needing to nest the logic itself. The value passed down the chain can be transformed at each step, building up the context needed for the final operation. This is a profound shift from the fragile trust model of callbacks. The chain’s integrity is maintained by the

Promise specification itself, not by the implementation details of the functions you are calling. The flow is deterministic. When a promise resolves, its dependent .then() callbacks are scheduled as microtasks in the event loop, ensuring they run as soon as possible after the current synchronous block of code finishes executing, but before any macrotasks like timers or I/O. This microtask queue has priority over the main callback queue, which means promise resolutions are handled with a sense of immediacy that keeps applications feeling responsive. The chain progresses link by link, each one a self-contained unit of work that receives an input and, after some time, produces an output for the next link. The beauty of this model is its composability. Any function that returns a promise can be seamlessly inserted into any promise chain, allowing for the construction of complex asynchronous workflows from simple, reusable, promise-aware building blocks. This is a far cry from the tangled web of single-purpose callbacks. But this idyllic picture of a smoothly flowing chain of successes is only half the story.

Asynchronous operations, particularly those involving networks and filesystems, are fraught with peril. Servers go down, files are missing, network connections drop. An operation may not complete successfully; it may fail. A robust system must not only handle the eventual success of an operation but also gracefully manage its potential failure. A promise can exist in one of three states: pending, fulfilled, or rejected. So far, we have only traced the path from pending to fulfilled. The journey to rejection is an equally critical part of the promise contract, and it introduces its own set of rules for how control flows through the chain. Failure is not an exception that crashes the program; it’s a state, just like success, that can be handled and propagated in a structured way.

The inevitable path to rejection

The path to fulfillment is paved with the resolve function. Its counterpart, the dark twin that lurks within every Promise executor, is reject. When an asynchronous operation cannot complete its designated task, the executor calls reject instead of resolve. This immediately transitions the promise from the ‘pending’ state to the ‘rejected’ state. The value passed to reject, typically an Error object, is then known as the “rejection reason.” This isn’t an uncaught exception in the traditional sense; it’s a controlled, predictable state change. The promise machinery is designed to capture this rejection and propagate it through the chain in an orderly fashion.

Let’s modify one of our data-fetching functions to simulate a failure. Perhaps the requested user ID doesn’t exist in our imaginary database.

function getUserPromise(userId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (userId === 1) {
        console.log('Fetched user.');
        resolve({ id: 1, name: 'John Doe' });
      } else {
        // The operation failed. Reject the promise.
        reject(new Error('User not found.'));
      }
    }, 500);
  });
}

// This will now fail.
getUserPromise(99)
  .then(user => {
    // This part will never run.
    console.log('Processing user:', user.name);
    return getPostsPromise(user.id);
  })
  .then(posts => {
    // This part will also be skipped.
    console.log('Displaying posts:', posts);
  });
// Output: Uncaught (in promise) Error: User not found.

Executing this code results in an “Uncaught (in promise)” error in the browser console. The system is telling us that a promise was rejected, but we made no arrangements to handle that outcome. The rejection propagated down the chain, found no interested party, and surfaced as a top-level error. To manage this, we must provide a rejection handler. The most direct way is using the second argument to .then().

The full signature of .then() is .then(onFulfilled, onRejected). The onRejected function is a callback that will be executed only if the promise it’s attached to enters the rejected state. The rejection reason (the Error object we passed to reject) will be passed as its sole argument.

getUserPromise(99)
  .then(
    user => {
      // onFulfilled: This is skipped.
      console.log('Processing user:', user.name);
    },
    error => {
      // onRejected: This runs.
      console.error('An error occurred in getUserPromise:', error.message);
    }
  );
// Output: An error occurred in getUserPromise: User not found.

This works, but it’s often not the ideal structure. If you have a long chain, do you need to add an error handler to every single .then()? That would be verbose and repetitive. The true power of promise-based error handling comes from its ability to bubble up. A rejection will travel down the chain, skipping all subsequent onFulfilled handlers, until it finds an onRejected handler to deal with it. This allows you to place a single error handler at the end of a chain to catch any failure that might occur in any of the preceding steps.

For this purpose, the .catch() method provides a much cleaner and more readable syntax. The call promise.catch(onRejected) is nothing more than syntactic sugar for promise.then(null, onRejected). It’s a declaration of intent: “I am only interested in handling failure at this point.”

getUserPromise(1) // Start with a valid user
  .then(user => {
    console.log('User step successful.');
    // Let's introduce a failure in a later step.
    return getPostsPromiseWithError(user.id); 
  })
  .then(posts => {
    // This will be skipped because getPostsPromiseWithError will reject.
    console.log('Posts step successful.');
    return getCommentsPromise(posts[0].id);
  })
  .then(comments => {
    // This will also be skipped.
    console.log('Comments step successful.');
  })
  .catch(error => {
    // The rejection from getPostsPromiseWithError is caught here.
    console.error('The operation failed at some point in the chain!');
    console.error('Reason:', error.message);
  });

function getPostsPromiseWithError(userId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject(new Error('Database connection lost while fetching posts.'));
    }, 500);
  });
}

This flow is remarkably similar to a synchronous try...catch block. The entire sequence of .then() calls is analogous to the code inside a try { ... } block. The final .catch() is the corresponding catch (e) { ... } block. Any failure in the “try” block immediately transfers control to the “catch” block. The promise chain behaves in exactly the same way. The rejection signal bypasses all intermediate success handlers and flows directly to the nearest rejection handler down the line.

An interesting and critical aspect of .catch() is what it returns. Just like .then(), .catch() also returns a new promise. If the code inside the .catch() handler runs to completion without throwing another error, the promise returned by .catch() will be *fulfilled*. The error is considered “handled.” This allows you to recover from a failure and continue the chain. For example, if a network request fails, your .catch() block could provide a default value from a local cache. Subsequent .then() handlers will then execute, receiving that default value as if the original operation had succeeded.

getUserPromise(99) // This will reject
  .catch(error => {
    console.warn('Could not fetch user. Using a default guest user.');
    // By returning a value here, we "repair" the chain.
    // The promise returned by this .catch() will be FULFILLED with this object.
    return { id: 0, name: 'Guest' };
  })
  .then(user => {
    // This .then() now runs, because the previous .catch() handled the error.
    console.log('Welcome,', user.name);
  });

// Output:
// Could not fetch user. Using a default guest user.
// Welcome, Guest

If you don’t want to handle the error but merely log it, and then allow the failure to continue propagating to other potential error handlers further down the line, you must re-throw the error from within your .catch(). This causes the promise returned by that .catch() to be rejected with the new error, preserving the failure state for the rest of the chain. This structured propagation of both success and failure states is the bedrock of robust asynchronous programming. It transforms the chaotic, ad-hoc error checking of callback pyramids into a clean, predictable, and composable system. But even this explicit chaining can feel verbose when the logic is complex. It still requires us to think in terms of callbacks, of passing functions to .then() and .catch(). The ultimate goal is to make asynchronous code look, feel, and behave almost identically to its synchronous counterpart.

The elegant illusion of synchronous flow

The promise chain is a monumental leap forward from the pyramid of doom. It flattens asynchronous logic and provides a robust, standardized channel for both success values and error reasons. Yet, for all its power, it still forces the programmer’s mind into a slightly unnatural state. We are still writing code inside callback functions passed to .then() and .catch(). The flow is linear, yes, but it’s a linearity of chained callbacks, not the simple, top-to-bottom procedural flow we take for granted in synchronous code. The ultimate abstraction would be to write asynchronous code that looks, reads, and is structured exactly like synchronous code, while retaining its non-blocking nature. This is not a fantasy; it is the reality delivered by the async and await keywords.

async/await is not a new feature that replaces Promises. Rather, it is syntactic sugar built directly on top of the Promise machinery. It provides a new syntax for consuming promises that allows for a radically more intuitive authoring experience. The first piece of this syntax is the async keyword. When you place async before a function declaration, you transform it into an async function. This has two immediate effects. First, the function is now guaranteed to return a promise. If you explicitly return a value from an async function, the function will return a promise that is already fulfilled with that value. If you throw an error, the function returns a promise that is already rejected with that error. Second, and most importantly, it enables the use of the await keyword within that function’s body.

The await keyword is the core of the illusion. It can only be used inside an async function, and it is placed before an expression that evaluates to a Promise. When the JavaScript engine encounters an await, it performs a remarkable trick. It pauses the execution of the async function right there. It does *not* block the main thread; the event loop is free to continue running other tasks. The engine essentially registers the rest of the async function as a continuation to be executed once the awaited promise settles. If the promise fulfills, the await expression evaluates to the fulfilled value. If the promise rejects, the await expression throws the rejection reason. It effectively unwraps the promise, giving you either the value or an exception, just as if you had called a synchronous function.

Let’s revisit our chained API call sequence. With async/await, the transformation is stunning. The chain of .then() calls melts away, replaced by what appears to be a simple, synchronous script.

async function displayUserContent() {
  console.log('Starting the process...');

  const user = await getUserPromise(1);
  console.log('Step 1 Complete. Got user:', user.name);
  
  const posts = await getPostsPromise(user.id);
  console.log('Step 2 Complete. Got posts.');

  const comments = await getCommentsPromise(posts[0].id);
  console.log('Step 3 Complete. Got comments.');

  console.log('Displaying comments:', comments);
}

displayUserContent();

The code is executed line-by-line, but the “pauses” between lines are non-blocking waits for asynchronous operations to complete. The mental overhead of chaining callbacks is gone. The logic is expressed in the most direct way possible. The variable user isn’t a promise; it’s the actual user object, unwrapped from the promise by await. The same is true for posts and comments. This is the elegant illusion: the syntax of synchronous code driving the non-blocking engine of promises.

This illusion extends beautifully to error handling. Since a rejected promise causes await to throw an exception, we no longer need .catch(). We can use the standard, familiar try...catch block that has been a part of the language for decades. This unifies synchronous and asynchronous error handling into a single, coherent model.

async function displayUserContentSafe() {
  try {
    const user = await getUserPromise(1);
    // Let's use the function that we know will fail.
    const posts = await getPostsPromiseWithError(user.id); 
    const comments = await getCommentsPromise(posts[0].id); // This line is never reached.

    console.log('Displaying comments:', comments);
  } catch (error) {
    // The exception thrown by the awaited rejected promise is caught here.
    console.error('An error occurred during the operation:');
    console.error(error.message);
  } finally {
    console.log('Operation finished, successfully or not.');
  }
}

displayUserContentSafe();

// Output:
// An error occurred during the operation:
// Database connection lost while fetching posts.
// Operation finished, successfully or not.

This is more than just a cosmetic improvement. It fundamentally changes how we reason about asynchronous control flow. The logic is no longer split between a “happy path” of .then() callbacks and a separate “error path” defined by .catch(). It’s all integrated into one procedural block, just as it would be for synchronous code. Under the hood, the JavaScript engine is performing a complex transformation. An async function is essentially compiled into a state machine. Each await represents a potential yield point. When the function is called, it executes synchronously until the first await. At that point, it returns a pending promise and yields control back to the event loop. When the awaited promise settles, the engine schedules the rest of the function to run, resuming execution from its last stopping point with the promise’s result now available. This cycle repeats for every await in the function. It is a masterful piece of compiler engineering, providing a high-level, intuitive abstraction over the low-level mechanics of the promise-based event loop model. The function’s local variables, its entire execution context, must be preserved across these asynchronous pauses, which is precisely what the state machine manages.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *