JavaScript Promises: Handling Asynchronous Code Cleanly

A Promise represents a value that isn't ready yet — like data from a server. Learn how Promises work, how to chain them with .then and .catch, and how they tame the callback mess.

Published August 10, 202611 min readBy ACY Partner Indonesia
JavaScript Promises — handling asynchronous results with then and catch
300 × 250Ad Space AvailablePlace your ad here

Most of the code you’ve written so far runs top to bottom, one line finishing before the next begins. But the moment your program has to talk to the outside world — fetch data from a server, read a file, wait for a timer — it hits a problem. That work takes time, and JavaScript can’t just freeze the whole page waiting for the answer. Instead it kicks the task off, keeps running, and circles back when the result is ready. That “come back later” style is called asynchronous code, and a Promise is the object JavaScript gives you to manage it without your code turning into a tangled mess.

If you’ve already met functions and worked through error handling, you have everything you need here. Promises tie both of those ideas together.

What “asynchronous” actually means

Imagine you order coffee at a café. You don’t stand frozen at the counter staring at the machine until it’s done — you get a buzzer, step aside, and the buzzer goes off when your drink is ready. Meanwhile the café keeps serving other people.

That’s asynchronous work in a nutshell. JavaScript starts something slow (the coffee), hands you a placeholder for the eventual result (the buzzer), and moves on. When the slow thing finishes, it lets you know. The alternative — synchronous code that blocks everything until it’s done — would be like every customer freezing the entire café until their order is ready. Unworkable.

A Promise is that buzzer. It’s an object that stands in for a value you don’t have yet, but will have at some point — either a success or a failure.

Life before Promises: callback hell

Before Promises existed, the way to handle “do this when the slow thing finishes” was a callback — a function you pass in to be run later:

getUser(1, function (user) {
  getOrders(user, function (orders) {
    getOrderDetails(orders[0], function (details) {
      console.log(details);
    });
  });
});

Each step depends on the one before it, so the functions nest deeper and deeper. With error handling sprinkled in, this quickly becomes an unreadable staircase that developers nicknamed “callback hell” (or the “pyramid of doom”). Promises were invented to flatten this back out into something you can actually read.

The shape of a Promise

A Promise is always in exactly one of three states:

  • Pending — the work is still going; no result yet. (The coffee is being made.)
  • Fulfilled — the work finished successfully, and there’s a value. (Your drink is ready.)
  • Rejected — the work failed, and there’s a reason (usually an error). (They’re out of beans.)

A Promise starts pending and then settles exactly once — into either fulfilled or rejected. Once it settles, it’s locked in; it can never change state again or fire twice. That one-time guarantee is a big part of why Promises are easier to reason about than raw callbacks.

You'll usually consume Promises, not build them

Here’s the reassuring part: in everyday code, you rarely create Promises from scratch. The browser and most libraries already hand you Promises ready to go — fetch() returns one, as do countless APIs. Your real job, most of the time, is knowing how to consume a Promise: how to grab its result when it succeeds and handle the error when it doesn’t. So focus your energy on .then() and .catch() first; building your own Promises comes later and less often.

Consuming a Promise: .then() and .catch()

When you have a Promise, you attach handlers to it. The .then() method runs when the Promise fulfills, receiving the resulting value. The .catch() method runs when it rejects, receiving the error:

fetch("https://api.example.com/user/1")
  .then(function (response) {
    console.log("Got a response!", response);
  })
  .catch(function (error) {
    console.log("Something went wrong:", error);
  });

Read it almost like a sentence: do the fetch, then when it succeeds run this, but if it fails catch the error here. The code after this block keeps running immediately — the .then() and .catch() only fire later, once the network call settles.

With arrow functions it reads even cleaner, and that’s how you’ll usually see it written:

fetch("https://api.example.com/user/1")
  .then((response) => console.log("Got a response!", response))
  .catch((error) => console.log("Something went wrong:", error));

.finally() — run no matter what

There’s a third handler, .finally(), that runs once the Promise settles either way — success or failure. It’s perfect for cleanup that should always happen, like hiding a loading spinner:

showSpinner();

fetch("https://api.example.com/data")
  .then((response) => console.log("Done:", response))
  .catch((error) => console.log("Failed:", error))
  .finally(() => hideSpinner());   // runs whether it succeeded or failed

Chaining: the real superpower

The single most important thing about .then() is this: it returns a new Promise. That means you can call .then() on the result of a .then(), stringing operations together one after another. Whatever you return inside a .then() becomes the value the next .then() receives:

fetch("https://api.example.com/user/1")
  .then((response) => response.json())   // returns a Promise for the parsed body
  .then((user) => {
    console.log("User:", user.name);
    return user.id;                      // a plain value, passed along
  })
  .then((id) => console.log("User ID:", id))
  .catch((error) => console.log("Error:", error));

This is the flat, readable replacement for the nested callback pyramid from earlier. Each step is one line at the same indentation level, reading top to bottom like a recipe. Notice response.json() itself returns a Promise — and the chain handles that automatically. If a .then() returns a Promise, the chain waits for that Promise to settle before moving on. That single rule is what makes sequencing async steps so clean.

One .catch() at the end catches the whole chain

You don’t need a .catch() after every single .then(). Errors in a Promise chain propagate downward — if any step rejects (or throws), JavaScript skips the rest of the .then() handlers and jumps straight to the next .catch(). So a single .catch() at the bottom of the chain handles a failure from any step above it. This is a genuine upgrade over callbacks, where you had to handle errors manually at every level.

Creating your own Promise

Sometimes you do need to make a Promise yourself — most often to wrap an older callback-based API, or to wait on something like a timer. You use the new Promise() constructor. It takes a function with two parameters, by convention named resolve and reject. Call resolve(value) to fulfill the Promise, or reject(error) to reject it:

function wait(ms) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(`Waited ${ms}ms`), ms);
  });
}

wait(1000).then((message) => console.log(message));   // after 1 second: "Waited 1000ms"

Here wait returns a Promise that stays pending until the timer fires, then resolves with a message. A more realistic example wraps an operation that can fail:

function checkAge(age) {
  return new Promise((resolve, reject) => {
    if (age >= 18) {
      resolve("Access granted");
    } else {
      reject(new Error("You must be 18 or older"));
    }
  });
}

checkAge(20)
  .then((message) => console.log(message))   // "Access granted"
  .catch((error) => console.log(error.message));

checkAge(15)
  .then((message) => console.log(message))
  .catch((error) => console.log(error.message));   // "You must be 18 or older"

Reject with an Error, and don't forget the .catch()

Two habits will save you real pain. First, when you reject, pass a proper new Error(...) rather than a bare string — Error objects carry a stack trace and a .message, which makes debugging far easier. Second, always attach a .catch() somewhere on the chain. A rejected Promise with no .catch() produces an “unhandled promise rejection” — the failure happens silently in the background, and you’re left staring at code that just… doesn’t work, with no obvious clue why. Every chain that can fail needs an error handler.

Running several Promises at once

Often you have multiple independent async tasks and you don’t want to wait for them one at a time. The Promise object has helper methods to coordinate a whole group. The four you should know:

Promise.all — wait for everything (fail fast)

Promise.all() takes an array of Promises and gives you back a single Promise that fulfills with an array of all their results — but only once every one of them has succeeded. If even one rejects, the whole thing rejects immediately:

const p1 = fetch("/api/user").then((r) => r.json());
const p2 = fetch("/api/posts").then((r) => r.json());
const p3 = fetch("/api/comments").then((r) => r.json());

Promise.all([p1, p2, p3])
  .then(([user, posts, comments]) => {
    console.log(user, posts, comments);   // all three, in order
  })
  .catch((error) => console.log("At least one failed:", error));

This is the go-to when you need all the data before you can continue, and the requests don’t depend on each other — they run in parallel, so it’s much faster than doing them in sequence.

Promise.allSettled — wait for everything (never fail)

Sometimes you want the outcome of every Promise even if some fail. Promise.allSettled() never rejects; it waits for all of them to settle and hands you an array describing each one — its status ("fulfilled" or "rejected") and either a value or a reason:

Promise.allSettled([p1, p2, p3]).then((results) => {
  results.forEach((result) => {
    if (result.status === "fulfilled") {
      console.log("OK:", result.value);
    } else {
      console.log("Failed:", result.reason);
    }
  });
});

Promise.race — first one to settle wins

Promise.race() settles as soon as the first Promise settles, with that Promise’s result — whether it fulfilled or rejected. A classic use is putting a timeout on a slow request:

const data = fetch("/api/slow");
const timeout = new Promise((_, reject) =>
  setTimeout(() => reject(new Error("Timed out")), 5000)
);

Promise.race([data, timeout])
  .then((response) => console.log("Got it in time"))
  .catch((error) => console.log(error.message));   // "Timed out" if fetch took too long

Promise.any — first one to succeed wins

Promise.any() is like race, but it ignores rejections and waits for the first one to fulfill. It only rejects if every Promise fails. Handy when you have several mirrors and just want whichever responds first:

Promise.any([fetch("/mirror1"), fetch("/mirror2"), fetch("/mirror3")])
  .then((response) => console.log("First success:", response))
  .catch((error) => console.log("All mirrors failed"));

Quick reference: all vs allSettled vs race vs any

A short way to keep them straight: all = “I need every result, and fail the moment any one fails.” allSettled = “Tell me how all of them turned out, good or bad.” race = “Whoever finishes first, success or failure.” any = “Whoever succeeds first.” Reach for all most of the time, allSettled when partial failure is acceptable, and race/any for timeouts and fallbacks.

A common gotcha: forgetting to return

In a .then(), if you start an async operation but forget to return its Promise, the chain won’t wait for it. It charges ahead, and your timing falls apart in confusing ways:

// BROKEN — the chain doesn't wait for the inner fetch
fetch("/api/user")
  .then((response) => response.json())
  .then((user) => {
    fetch(`/api/orders/${user.id}`);   // no return! chain moves on immediately
  })
  .then(() => console.log("Done"));    // logs before orders are fetched

// FIXED — return the Promise so the chain waits
fetch("/api/user")
  .then((response) => response.json())
  .then((user) => {
    return fetch(`/api/orders/${user.id}`);   // returned → chain waits
  })
  .then(() => console.log("Done"));

Whenever you do async work inside a .then(), return its Promise. Missing returns are one of the most common Promise bugs, and they fail quietly rather than throwing an obvious error.

Where Promises go next: async/await

Promise chains are a huge improvement over callbacks, but long chains of .then() can still get a little dense. Modern JavaScript adds a layer of syntax on top of Promises called async/await that lets you write asynchronous code that reads like ordinary synchronous code:

async function loadUser() {
  try {
    const response = await fetch("/api/user/1");
    const user = await response.json();
    console.log(user.name);
  } catch (error) {
    console.log("Error:", error);
  }
}

Notice there are no .then() calls here at all — await pauses the function until the Promise settles and hands back its value directly. But here’s the key thing: async/await is built entirely on Promises. await only works on a Promise, and an async function always returns one. Everything you learned here still applies underneath — which is exactly why Promises had to come first. That’s the topic of the next article.

Wrapping up

You now understand how JavaScript handles work that takes time:

  • Asynchronous code starts a slow task, moves on, and comes back when it’s done — instead of freezing everything. A Promise is the placeholder for that eventual result.
  • A Promise is always pending, fulfilled, or rejected, and it settles exactly once.
  • Consume a Promise with .then() (success), .catch() (failure), and .finally() (always). You’ll do this far more often than you’ll create Promises.
  • Chaining works because .then() returns a new Promise. Return a value (or a Promise) to pass it down the chain, and one .catch() at the end handles errors from any step.
  • Create a Promise with new Promise((resolve, reject) => ...) — usually to wrap older callback APIs. Reject with a real Error, and never leave a chain without a .catch().
  • Coordinate groups with Promise.all (all succeed), allSettled (all outcomes), race (first to settle), and any (first to succeed).

Promises are the foundation of every modern asynchronous pattern in JavaScript. Get comfortable with .then(), .catch(), and chaining here, and the async/await syntax in the next article will feel like a natural, friendlier coat of paint over the very same machinery.

Tags:javascriptfrontendpromisesasynchronousintermediate
728 × 90Ad Space AvailablePlace your ad here

Related Articles

See All Articles

You Might Also Like

Frontend best practices overview cover
Frontend / Fundamentals

Frontend Best Practices: An Overview

A friendly map of what good frontend really means — semantic HTML, clean CSS, unobtrusive JavaScript, responsive layouts, performance, and progressive enhancement, with pointers to go deeper.

Sep 20, 20268 min read
A Frontend Developer Roadmap cover with the path HTML to CSS to JavaScript
Frontend / Fundamentals

A Frontend Developer Roadmap: What to Learn, and in What Order

A calm, step-by-step learning path for beginners: HTML, CSS, JavaScript, developer tools, responsive and accessible design, a framework, TypeScript, and deployment, with the reasoning behind each step.

Sep 20, 202611 min read
Title card reading Styling and Interactivity over a dark blue ACY Partner background
Frontend / Fundamentals

How Styling and Interactivity Work

A beginner-friendly look at how CSS turns plain HTML into a designed layout, and how JavaScript adds behavior — the structure, style, behavior mental model for building web pages.

Sep 20, 20269 min read
Three front-end layers combining on one web page
Frontend / Fundamentals

How HTML, CSS, and JavaScript Work Together

A hands-on walkthrough for beginners: build one small button, give it structure with HTML, looks with CSS, and behavior with JavaScript, and watch the three layers cooperate on a real page.

Sep 20, 20268 min read
The Frontend Developer Toolkit cover with editor, browser, and CLI motifs
Frontend / Fundamentals

The Frontend Developer Toolkit

A friendly tour of the everyday tools a frontend developer relies on — code editor, browser and DevTools, terminal, version control, package managers, and a dev server — and what each one is actually for.

Sep 20, 202610 min read
Cover illustration for What Frontend Developers Do
Frontend / Fundamentals

What Frontend Developers Do: A Beginner's Guide to the Role

A clear, beginner-friendly look at what frontend developers actually do every day: turning designs into working interfaces, building reusable UI, and caring about responsiveness, accessibility, and speed.

Sep 20, 202610 min read