The Problem With Waiting

Suppose we wanted to build a New Year's countdown. In a typical multi-threaded programming language, we could write sequential code: start a sleep() timer for one second, then another, then another, pausing the program at each step. JavaScript is not like most programming languages.

JavaScript is single-threaded. It has only one thread to execute all of its work: handling events, managing network requests, updating the user interface. While modern browsers offer Web Workers for additional threads, those workers don't have access to the DOM, meaning they can't be used for most UI work.

If JavaScript had a blocking sleep() function, the main thread would be fully occupied while waiting. And it doesn't take much imagination to see why that's a problem: the browser can't process clicks, scrolling, or text selection while the thread is busy. This is exactly what happens with synchronous functions like window.prompt(). Open a prompt and the page locks up; the thread is stuck waiting for user input, making the entire UI unresponsive until it finishes.

Since we only have the one thread, and it must remain available for all page interactions, we need a way to handle delayed work without blocking. This is where asynchronous programming comes in.

Callbacks: The Original Solution

The earliest tool for handling asynchronous work is setTimeout(), which takes two arguments: a chunk of work as a function, and the number of milliseconds to wait before running it. This function-as-argument pattern is called a callback.

console.log('Start');

setTimeout(
  () => {
    console.log('After one second');
  },
  1000
);

setTimeout() is an asynchronous function: it doesn't block the thread. Instead of sitting in a waiting room until the work is ready, JavaScript registers the callback and continues about its day, only circling back when the timer fires. Events work in exactly the same way: window.addEventListener() registers callbacks that are invoked in response to detected events like pointer movement.

The tradeoff is that code no longer executes purely in written order. Consider three logs with an intermediate timeout:

console.log('1. Before setTimeout');

setTimeout(() => {
  console.log('2. Inside setTimeout');
}, 500);

console.log('3. After setTimeout');

You might expect the output to follow the source order: 1, 2, 3. But the thread doesn't wait. It logs 1, registers the timer, logs 3, and then — hundreds of milliseconds later — logs 2. The callback isn't part of the linear execution flow; it's scheduled for later.

For sequences of asynchronous tasks, developers historically nested callbacks:

console.log("3…");

setTimeout(() => {
  console.log("2…");

  setTimeout(() => {
    console.log("1…");

    setTimeout(() => {
      console.log("Happy New Year!!");
    }, 1000);
  }, 1000);
}, 1000);

Each setTimeout() call launches its own callback, so delays stack not vertically but horizontally, leading to the infamous pattern known as Callback Hell. While functional, the nested indentation makes code hard to read and awkward to reason about. Promises were designed to address these problems.

Promises: Chaining Instead of Nesting

The core idea is to break the nesting. Rather than embedding the next step inside the current callback, what if the result of one async operation could be passed to a chain of subsequent actions?

console.log('3');

setTimeout(1000)
  .then(() => {
    console.log('2');

    return setTimeout(1000);
  })
  .then(() => {
    console.log('1');

    return setTimeout(1000);
  })
  .then(() => {
    console.log('Happy New Year!!');
  });

Instead of stuffing the next piece of work into the current callback (creating the pyramid of nesting), we could daisy-chain them using a special .then() method. This is the fundamental concept behind Promises, which were introduced to JavaScript in 2015.

Unfortunately, old APIs like setTimeout() and addEventListener() still use the callback style because they predate Promises. Changing them retroactively would break countless existing websites, so backward compatibility prevents a conversion. Modern web APIs, however, are built natively on promises.

States and Consumption

The fetch() function is the canonical example. When called, it makes a network request and returns a response Promise. Internally, a Promise is always in one of three states:

  1. pending — the work is still in progress.
  2. fulfilled — the operation has completed successfully.
  3. rejected — something went wrong and the task couldn't complete.

While pending, a Promise is said to be unresolved. Once its task finishes, it becomes resolved, regardless of the outcome — a fulfilled promise resolves with the expected data, and a rejected one resolves with an error.

Since fetch() returns a Promise, we don't have the final network data yet; the Promise is essentially an IOU from the browser, a marker that tells us the data is coming. We attach an action for when the data arrives by calling .then():

fetch('/api/get-data')
  .then((response) => {
    console.log(response);
    // Response { type: 'basic', status: 200, ...}
  });

When the browser gets a response from the server, it calls the provided callback and passes the response object along. No nesting required — just a single .then() attached to the result of fetch(), letting us continue our work without blocking thought the rest of the page's execution.

Hand-Building a Promise

When working with the Fetch API, the Promise is created behind the scenes by fetch(). But many older APIs — like setTimeout — predate Promises and rely purely on callbacks. To keep our code from devolving into Callback Hell with those APIs, we need to create our own Promises.

The core syntax for creating a new Promise looks like this:

const demoPromise = new Promise((resolve) => {
  // Do some sort of asynchronous work, and then
  // call `resolve()` to fulfill the Promise.
});

demoPromise.then(() => {
  // This callback will be called when
  // the Promise is fulfilled!
})

Promises are generic containers. They don't perform any work on their own; the function we pass into new Promise() contains the specific asynchronous task we want to accomplish. That could be a network request, a timer, or anything else.

Once that work completes successfully, we invoke resolve(). This signals that the async operation finished as expected and flips the Promise into a fulfilled state.

Suppose we want a Promise-based version of setTimeout — a minimal sleep utility. Here's how such a wrapper would be built:

function wait(duration) {
  return new Promise((resolve) => {
    setTimeout(resolve, duration);
  });
}

const timeoutPromise = wait(1000);

timeoutPromise.then(() => {
  console.log('1 second later!')
});

Admittedly, this is a dense chunk of code on first encounter, but it decomposes into only a few moving parts:

  • The helper function wait accepts a single duration parameter.
  • Inside wait, we instantiate a new Promise and return it. Since a Promise doesn't act on its own, we supply the callback that contains the async work.
  • Within that callback, we start a timer with setTimeout, passing in resolve and the user-supplied duration.
  • When the timer elapses, it fires resolve, which marks the Promise as fulfilled. This, in turn, triggers the attached .then() handler.

Passing resolve directly to setTimeout works because JavaScript treats functions as "first class" citizens that can be passed around just like any other data structure. If it helps readability, we can equivalently wrap it in an inline function that internally calls resolve:

function wait(duration) {
  return new Promise((resolve) => {
    setTimeout(
      () => resolve(),
      duration
    );
  });
}

One-Shot, Then Chained

A critical constraint of Promises is that they resolve exactly once. Once a Promise is fulfilled or rejected, that state is permanent. This means they are a poor fit for recurring events, like this mouse-move handler:

window.addEventListener('mousemove', (event) => {
  console.log(event.clientX);
})

That callback would fire potentially hundreds of times per user gesture — not a pattern Promises support.

Our countdown timer, however, can be built by chaining. While we cannot re-trigger the same Promise, we can take the value of one and use it to spawn the next link in the chain:

wait(1000)
  .then(() => {
    console.log('2');
    return wait(1000);
  })
  .then(() => {
    console.log('1');
    return wait(1000);
  })
  .then(() => {
    console.log('Happy New Year!!');
  });

When the original Promise fulfills, the .then() callback runs and returns a fresh Promise. The whole sequence proceeds one step at a time.

Threading Data Through

So far we've called resolve() with no arguments, using it as a bare signal of completion. In many cases — such as when working with a callback-based database library — you'll have data to hand forward:

function getUser(userId) {
  return new Promise((resolve) => {
    // The asynchronous work, in this case, is
    // looking up a user from their ID
    db.get({ id: userId }, (user) => {
      // Now that we have the full user object,
      // we can pass it in here...
      resolve(user);
    });
  });
}

getUser('abc123').then((user) => {
  // ...and pluck it out here!
  console.log(user);
  // { name: 'Josh', ... }
})

Handling Failures

When working with the Fetch API, network failures and server errors mean the Promise may not be fulfilled. Depending on what goes wrong, the Promise will be rejected instead. The .catch() method handles this outcome:

fetch('/api/get-data')
  .then((response) => {
    // ...
  })
  .catch((error) => {
    console.error(error);
  });

When a Promise is fulfilled, the .then() handler runs. When it's rejected, .catch() runs in its place: two distinct paths based on the Promise's fate.

For hand-written Promises, we mark a task as failed by using a second callback parameter, reject:

new Promise((resolve, reject) => {
  someAsynchronousWork((result, error) => {
    if (error) {
      reject(error);
      return;
    }

    resolve(result);
  });
});

Calling reject() — usually with an error object — transitions that Promise to a rejected state. Whatever argument we pass along is received by the matching .catch() handler.

Async and Await

Modern JavaScript's async/await syntax brings us remarkably close to a purely synchronous-looking countdown structure:

async function countdown() {
  console.log("5…");
  await wait(1000);

  console.log("4…");
  await wait(1000);

  console.log("3…");
  await wait(1000);

  console.log("2…");
  await wait(1000);

  console.log("1…");
  await wait(1000);

  console.log("Happy New Year!");
}

This appears to be exactly what we said was impossible earlier: pausing a function mid-execution. In practice, this works because async/await quietly behaves as if we still wrote Promise chains by hand.

To see the connection in action, notice what happens when a async function returns a simple value:

async function addNums(a, b) {
  return a + b;
}

const result = addNums(1, 1);

console.log(result);
// -> Promise {<fulfilled>: 2}

Even though we expect 2, the function actually returns a Promise that resolves to 2. Slapping the async keyword on any function guarantees that it returns a Promise — regardless of whether it does asynchronous work or not. The snippet is essentially shorthand for:

function addNums(a, b) {
  return new Promise((resolve) => {
    resolve(a + b);
  });
}

Similarly, the await keyword compiles down to the same threading logic as a .then() handler:

// This code...
async function pingEndpoint(endpoint) {
  const response = await fetch(endpoint);
  return response.status;
}

// ...is equivalent to this:
function pingEndpoint(endpoint) {
  return fetch(endpoint)
    .then((response) => {
      return response.status;
    });
}

Promises supply the underlying infrastructure JavaScript needs to offer syntax that reads like synchronous code, while remaining genuinely asynchronous at runtime.