Skip to main content

Command Palette

Search for a command to run...

JavaScript Promises Explained

From callback hell to clean async code — a practical guide to Promise states, chaining, error handling, and the microtask queue.

Updated
JavaScript Promises Explained
M

👋 Hey, I'm Mohd Kaif – a student documenting my journey through code. I write about what I'm learning in real-time – the wins, the struggles, and the "aha!" moments. From JavaScript and React to backend systems with Node.js, databases, DevOps, TypeScript, and AI integrations. This blog is my public learning journal: honest, evolving, and always exploring. If you're curious about any of these topics, let's learn and build together!

You know that feeling when your code looks fine — but the data shows up empty, the function runs too early, or nothing happens at all — and you have no idea why?

  • Have you ever nested a callback inside a callback inside another callback, only to lose track of which closing brace belongs where?

  • Do you sometimes feel like async JavaScript is following a set of rules nobody told you about?

  • Have you ever copy-pasted a .then() chain without really understanding what the .then() was doing?

  • Have you googled "JavaScript callback hell" at 11pm wondering if there's a better way?

If any of that sounds familiar, this article is for you.

The problem isn't that you're bad at JavaScript. The problem is that async code behaves differently from everything else you've learned — and most tutorials don't explain why deeply enough to make it actually click.

This guide will change that.


✅ What You'll Learn

  • Why callbacks fail at scale and what problem Promises were specifically designed to solve

  • How a Promise works internally — including the microtask queue that most tutorials skip

  • When a Promise is pending, fulfilled, or rejected — and what that means for your code

  • How to write clean, chainable async code that's easy to read and debug

  • What the most common Promise mistakes look like — and how to avoid them

  • How to recognize Promises in the wild: fetch, fs.promises, async/await, and more

No prerequisites required beyond a basic understanding of JavaScript functions.


The Problem: When Callbacks Start Eating Each Other

JavaScript is single-threaded. That means when you ask it to do something that takes time — fetch data from an API, read a file, wait for a timer — it can't just pause and wait. It has to move on and come back later.

The original solution to this was callbacks: you pass a function as an argument, and JavaScript calls it back when the work is done.

That works fine for one thing. But real applications rarely need to do just one thing.

// Step 1: Get the user
getUser(userId, (user) => {

  // Step 2: Get their posts
  getPosts(user.id, (posts) => {

    // Step 3: Get comments on the first post
    getComments(posts[0].id, (comments) => {

      // Step 4: Display them... finally
      console.log(comments);

    }, handleError);
  }, handleError);
}, handleError);

This pattern has a name — callback hell — and it's not just an aesthetic problem. It creates real engineering pain:

  • Error handling is duplicated or forgotten at every level

  • Adding a new step means restructuring the entire nesting

  • Debugging means mentally unwinding layers of indented logic

  • Testing becomes nearly impossible

The fundamental issue is this: callbacks give you no object to hold on to. You can't inspect what's happening, pause it, pass it around, or react to it later. You just fire and pray.


What Is a Promise? The Mental Model That Actually Sticks

A Promise is an object that represents the eventual result of an async operation — whether that result is a value or an error.

Think of it like placing a food order at a restaurant:

  • You order your food → pending (the kitchen is working on it)

  • Your food arrives → fulfilled (you have a value: your meal)

  • The kitchen runs out of ingredients → rejected (you have an error: an explanation)

The key insight is this: you get a ticket the moment you place the order. That ticket is the Promise. You can hand it to your friend, put it in your pocket, or check on it later — and it will always represent the same outcome of that one operation.

Before promises existed, there was no "ticket." You just had to stay at the counter and wait.


The Three States of a Promise

Every Promise lives in exactly one of three states at any given time:

State Meaning Can change?
Pending Operation is still in progress ✅ Yes
Fulfilled Operation succeeded — a value is ready ❌ No
Rejected Operation failed — an error is available ❌ No

Once a Promise moves from pending to either fulfilled or rejected, it is settled — permanently. It will never change state again. This immutability is one of the most important properties Promises have, and it's what makes them safe to pass around your codebase.


Creating Your First Promise

You create a Promise using the Promise constructor, which takes a single function — called the executor — with two parameters: resolve and reject.

const myPromise = new Promise((resolve, reject) => {
  const success = true;

  if (success) {
    resolve("Here's your data!"); // Fulfills the Promise
  } else {
    reject("Something went wrong."); // Rejects the Promise
  }
});
  • Calling resolve(value) transitions the Promise to fulfilled with that value

  • Calling reject(error) transitions the Promise to rejected with that error

  • Only the first call matters — subsequent calls to either are silently ignored

Here's a more realistic example: simulating an API call with a timer.

function fetchUserData(userId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (userId > 0) {
        resolve({ id: userId, name: "Alice", role: "admin" });
      } else {
        reject(new Error("Invalid user ID"));
      }
    }, 1500);
  });
}

🛠️ Exercise 1: Write Your First Promise

Create a Promise called coinFlip that:

  • Waits 500ms (use setTimeout)

  • Fulfills with "heads" or "tails" randomly (hint: Math.random() > 0.5)

Then call it and console.log the result. You'll use .then() — which you'll learn in the very next section.


Handling Results: .then(), .catch(), and .finally()

Once you have a Promise, you attach handlers to it using built-in methods.

.then() — for success

.then() receives the fulfilled value and lets you do something with it.

fetchUserData(1)
  .then((user) => {
    console.log("Got user:", user.name); // "Got user: Alice"
  });

.catch() — for errors

.catch() intercepts any rejection that happens in the chain — including errors thrown inside .then() handlers.

fetchUserData(-1)
  .then((user) => {
    console.log("Got user:", user.name);
  })
  .catch((error) => {
    console.error("Failed:", error.message); // "Failed: Invalid user ID"
  });

.finally() — for cleanup

.finally() runs regardless of success or failure — perfect for hiding a loading spinner, closing a connection, or resetting UI state.

fetchUserData(1)
  .then((user) => console.log("Got user:", user.name))
  .catch((error) => console.error("Failed:", error.message))
  .finally(() => console.log("Request complete — hide the spinner"));

This separation of concerns is a major upgrade over callbacks, where you either duplicated error handling at every level or forgot it entirely.


Promise Chaining: Writing Async Code That Reads Like a Story

Here's where Promises genuinely shine. Each .then() call returns a new Promise, which means you can chain them — and each step receives the result of the previous one.

// Callback hell version — nested, hard to follow
getUser(1, (user) => {
  getPosts(user.id, (posts) => {
    getComments(posts[0].id, (comments) => {
      console.log(comments);
    });
  });
});

// Promise version — flat, sequential, readable
getUser(1)
  .then((user) => getPosts(user.id))
  .then((posts) => getComments(posts[0].id))
  .then((comments) => console.log(comments))
  .catch((error) => console.error("Something failed:", error));

The same logic. Dramatically more readable. And with a single .catch() that handles any failure at any point in the chain.

The Golden Rule of Chaining

Always return from inside .then(). This is the most common beginner mistake, and it silently breaks everything.

// ❌ Broken — getComments() fires but the chain doesn't wait for it
.then((posts) => {
  getComments(posts[0].id); // Missing return!
})

// ✅ Correct — the chain waits for getComments() to resolve
.then((posts) => {
  return getComments(posts[0].id);
})

When you return a Promise from inside .then(), the chain automatically waits for it to settle before continuing. When you return a plain value, it gets wrapped in a resolved Promise automatically. When you return nothing — the chain continues immediately with undefined.


🛠️ Exercise 2: Build a Mini Async Pipeline

Using these three mock functions (copy them in):

const getUser = () => Promise.resolve({ id: 1, name: "Alex" });
const getScore = (user) => Promise.resolve({ user: user.name, score: 42 });
const formatResult = (data) => Promise.resolve(`\({data.user} scored \){data.score} points`);

Chain them together so the final .then() logs: "Alex scored 42 points".

Then deliberately remove a return from the middle step — what happens?


Under the Hood: Why Promise Callbacks Don't Run Immediately

Here's something that trips up almost every developer who first encounters Promises — even after they feel comfortable with the basics.

Run this code in your head first. What order do you expect?

console.log("1 — Start");

setTimeout(() => console.log("4 — Timeout"), 0);

Promise.resolve().then(() => console.log("3 — Promise"));

console.log("2 — End");

The output is:

1 — Start
2 — End
3 — Promise
4 — Timeout

The setTimeout has a delay of zero milliseconds — yet the Promise runs first. Why?

The Event Loop Has Two Queues

JavaScript's event loop processes tasks in a specific order after the current call stack is empty:

  1. Macrotask queue — things like setTimeout, setInterval, I/O events

  2. Microtask queue — Promise callbacks (.then(), .catch(), .finally())

The rule: the microtask queue is completely drained before the next macrotask runs. Every single time.

This means:

  • Your Promise callbacks run before any pending timers

  • If one .then() schedules another .then(), that new one also runs before any setTimeout

  • Only when there are zero microtasks left does the event loop pick up the next macrotask

console.log("Start");

setTimeout(() => console.log("Timeout"), 0);

Promise.resolve()
  .then(() => {
    console.log("Promise 1");
    return Promise.resolve();
  })
  .then(() => console.log("Promise 2")); // Still before Timeout!

console.log("End");

// Output:
// Start
// End
// Promise 1
// Promise 2
// Timeout

Understanding this isn't just trivia — it explains why Promise-based code feels "faster" than equivalent timer-based code, and it helps you predict execution order when debugging tricky async bugs.


The 5 Promise Mistakes That Catch Everyone

1. Missing return inside .then()

Already covered — but worth repeating, because it's the #1 bug in Promise chains.

2. Forgetting .catch()

An unhandled Promise rejection is a silent failure in many environments, and a crash in Node.js. Always attach a .catch().

// ❌ If fetchData rejects, nothing happens — or the process crashes
fetchData().then(render);

// ✅ Always handle the failure path
fetchData().then(render).catch(showErrorMessage);

3. Wrapping an existing Promise in a new Promise()

This is called the explicit construction anti-pattern. If a function already returns a Promise, don't wrap it.

// ❌ Unnecessary — fetch() already returns a Promise
return new Promise((resolve) => {
  resolve(fetch("/api/data"));
});

// ✅ Just return it directly
return fetch("/api/data");

4. Treating Promises as synchronous

Even Promise.resolve() — the fastest possible Promise — is asynchronous. Its callback always runs after the current call stack clears.

let result = null;

Promise.resolve("data").then((val) => {
  result = val;
});

console.log(result); // null — not "data"!
// The .then() hasn't run yet

5. Swallowing errors in .catch()

A .catch() that doesn't re-throw or return a rejected Promise will recover the chain — the next .then() after it will run as if nothing went wrong.

fetchData()
  .catch((err) => {
    console.error(err); // Logs the error...
    // ...but returns undefined implicitly
    // So the next .then() runs as if it succeeded!
  })
  .then((data) => render(data)); // data is undefined here

If you want the chain to stay in an error state after .catch(), re-throw the error: throw err.


🛠️ Exercise 3: Spot the Bug

The following code has two of the five mistakes above. Find them and fix both.

function loadDashboard(userId) {
  return new Promise((resolve) => {
    resolve(fetchUser(userId));
  });
}

loadDashboard(5)
  .then((user) => {
    fetchPosts(user.id);
  })
  .then((posts) => {
    console.log("Posts:", posts);
  });

Where You'll See Promises in the Real World

Promises aren't just an abstract concept — they're the foundation of almost every async API in modern JavaScript.

The Fetch API

fetch("https://api.example.com/users/1")
  .then((response) => {
    if (!response.ok) throw new Error("Network response was not ok");
    return response.json(); // .json() also returns a Promise
  })
  .then((user) => console.log(user))
  .catch((error) => console.error("Fetch failed:", error));

File System in Node.js

const fs = require("fs");

fs.promises.readFile("config.json", "utf-8")
  .then((content) => JSON.parse(content))
  .then((config) => applyConfig(config))
  .catch((error) => console.error("Could not load config:", error));

async/await — Promises in Disguise

This is probably the most important thing to understand about async/await: it doesn't replace Promises, it's built on top of them.

// Promise chain version
function loadUser(id) {
  return fetchUser(id)
    .then((user) => fetchPosts(user.id))
    .then((posts) => ({ ...user, posts }));
}

// async/await version — same behavior, different syntax
async function loadUser(id) {
  const user = await fetchUser(id);
  const posts = await fetchPosts(user.id);
  return { ...user, posts };
}

An async function always returns a Promise. await pauses execution inside that function until the awaited Promise settles — but it doesn't block the rest of your program. Without a solid understanding of how Promises work, async/await becomes magic you copy and paste rather than a tool you control.


What to Learn Next

Now that you understand Promises, here's the natural path forward:

  1. async/await in depth — Learn the syntax, error handling with try/catch, and how to avoid common await mistakes like sequential awaits that should run in parallel.

  2. Promise.all, Promise.race, Promise.allSettled — These combinators let you coordinate multiple Promises at once and are essential for real-world performance.

  3. Error handling patterns — Deep dive into retry logic, fallbacks, and how to build resilient async pipelines that degrade gracefully.

  4. The Event Loop in depth — Jake Archibald's In The Loop talk (JSConf 2018) is the single best visual explanation of macrotasks, microtasks, and render timing that exists.


💬 Got Questions?

Drop a comment below — I'd love to hear what clicked for you, or what part still feels fuzzy. There's no such thing as a dumb question when it comes to async JavaScript.

Here are the topics coming up next in this series:

  • async/await Explained: Why await isn't just "promise with less typing" — and the parallel execution mistake almost everyone makes.

  • Promise Combinators: When to use Promise.all vs Promise.allSettled vs Promise.race, with real use cases.

  • The JavaScript Event Loop, Visualized: A deep, visual walkthrough of the call stack, microtask queue, and macrotask queue — with interactive examples.

  • Error Handling in Async JavaScript: Building async pipelines that fail gracefully, retry automatically, and never swallow errors silently.


Found this helpful? Share it with someone who's currently staring at a callback three levels deep. And if you bookmark one thing from this article, let it be this: a Promise is just a placeholder for a future value — and once you see it that way, everything else falls into place.

Happy coding! 🚀

Zero to Full Stack Developer: From Basics to Production

Part 19 of 50

Complete full-stack web development series from zero to production. Learn HTML, CSS, JavaScript, TypeScript, React, Next.js, Node.js, databases, Docker, AWS, and AI integration. Build real-world projects step-by-step.

Up next

JavaScript Callbacks Explained

Learn why callbacks exist, how async execution works in JavaScript, and when to move on to Promises — with hands-on exercises.