Promises: A Mostly Complete Guide to JavaScript Promises I

Picture a scenario where you order a pizza delivery. You place your order, but the pizza shop can’t magically teleport the pizza to your table in an instant—it takes time to bake. Instead of sitting in the pizza shop’s kitchen, you continue your day. As soon as your pizza is ready, someone knocks at your door. That’s the essence of a JavaScript Promise: it’s a commitment that you’ll eventually receive a value (the pizza) or an error (if the pizza can’t be delivered) without freezing your program while you wait.

In 2015, JavaScript (ECMAScript 2015) formally introduced the Promise object to help programmers write asynchronous code that feels more synchronous. By the end of this guide, you should be able to:


Why Promise-Based Code Is Easier to Maintain

Traditionally, asynchronous JavaScript code relies heavily on callbacks. You pass a function that runs “later,” once a file is read or a network request completes. However, these callbacks can quickly create “nested pyramids” of code—commonly known as callback hell.

As your code grows—maybe reading one file, then a list of files, then counting all their characters—you soon discover that keeping track of when everything is done becomes complicated and error-prone. You end up with deeply nested functions, tricky debugging, and lots of housekeeping.

Promises solve these issues by letting you “chain” asynchronous steps in a linear way, plus unify your error handling. This approach is not only more elegant but also drastically reduces the mental overhead of reading and maintaining the code months later.


A Quick Review of Function Declarations

JavaScript’s behavior with function declarations is key to understanding callbacks (and eventually Promises). For example:

function loudLog(message) {
  console.log(message.toUpperCase());
}

When JavaScript sees this code, it doesn’t run loudLog immediately. Instead, it creates a Function object and assigns it to the variable loudLog. You (or some other code) can call loudLog("error occurred"); later, but the declaration itself doesn’t trigger execution.

Sometimes, you might define a function with no name, known as an anonymous function. For example:

function () {
  console.log('How did you call me?');
}

This function has no identifier, so if you don’t store it in a variable or pass it as a parameter, it disappears. In asynchronous coding, it’s common to supply anonymous functions as callbacks directly where needed, rather than naming them separately.


The Callback Struggle

Below is a snippet of Node.js-style code. You call readFile with a path, an encoding, and a callback. Once the file finishes reading, readFile calls your callback with the result (or an error).

readFile(path, encoding, callback)

A typical call might be:

readFile('~/Documents/todos.txt', 'utf8', (err, content) => {
  console.log("YOUR FILE CONTAINS:");
  console.log(content);
});

This pattern works fine for a single file read. But if you need to chain multiple reads—like a manifest.txt listing other files, which you then read individually to count characters—things get complicated:

readFile('manifest.txt', 'utf8', (err, manifest) => {
  const fileNames = manifest.split('\n');
  const characterCounts = {};

  for (let fileName of fileNames) {
    readFile(fileName, 'utf8', (err, content) => {
      countCharacters(characterCounts, content);
      // Where do we print the results? 
    });
  }
  // If we print results here, the for loop's reads haven't finished yet!
});

You soon realize you need to track how many files have finished reading, and only print once they’re all done. That might look like:

readFile('manifest.txt', 'utf8', (err, manifest) => {
  const fileNames = manifest.split('\n');
  const characterCounts = {};
  let numberOfFilesRead = 0;

  for (let fileName of fileNames) {
    readFile(fileName, 'utf8', (err, content) => {
      countCharacters(characterCounts, content);
      numberOfFilesRead += 1;
      if (numberOfFilesRead === fileNames.length) {
        console.log(characterCounts);
      }
    });
  }
});

This “nested nesting” plus extra counters can be error-prone and quickly becomes unwieldy. Now imagine adding more features or error handling—your code can devolve into chaos.


Designing a Better Solution

In truly asynchronous code, statements don’t always run top-to-bottom in the order they appear in the file. For instance:

console.log('Q'); // 1
setTimeout(() => {
  console.log('E'); // 3
  setTimeout(() => {
    console.log('T'); // 5
  }, 100);
  console.log('R'); // 4
}, 200);
console.log('W');   // 2

The execution order is Q -> W -> E -> R -> T. In large programs, nested setTimeout calls or callbacks can turn your code into a swirling tangle of “delayed logic.”

The JavaScript community wanted to avoid deeply nested code and offer a chainable approach. Something like:

log('Q')
  .then(() => log('W'))
  .then(() => pause(200))
  .then(() => log('E'))
  .then(() => log('R'))
  .then(() => pause(100))
  .then(() => log('T'));

That’s the idea behind Promises: a new abstraction that organizes asynchronous steps in a chain of then calls rather than deeply nested callbacks.


So, What Is a Promise?

A Promise is an object that represents an operation that hasn't finished yet but is expected to complete in the future. Eventually, you’ll either get a successful result or an error—like reading a file or fetching data from an API.

A promise can be in one of three states:

Once a promise is fulfilled or rejected, it’s considered settled and won’t change states again.


Methods on a Promise

then(successHandler, errorHandler) – Attaches callbacks for fulfillment (success) and rejection (error).

catch(errorHandler) – Attaches a callback for rejection only, effectively the same as then(null, errorHandler).

A “Success Handler” receives a single parameter: the value that the Promise resolves with. An “Error Handler” receives the reason for rejection.

In practice, you often see:

someAsyncOperation()
  .then(value => {
    console.log('Success!', value);
  })
  .catch(error => {
    console.error('Failed:', error);
  });

This chaining approach helps keep your code readable and your error handling consistent.


What You’ve Learned

JavaScript Promises allow you to write asynchronous code that’s much more manageable. You’ve seen how callbacks can become messy as your logic grows, requiring complicated tracking just to know when all tasks are finished. Promises, by contrast, turn that complexity into a linear chain of actions with unified error handling.

Key takeaways:

In the next part of this guide, you’ll learn more about how to create and chain promises, plus how advanced features like Promise.all or async/await can further simplify your async code.