Skip to content

Promises in JavaScript - from callback hell to the microtask queue

Profile photo of Adrian Zawadzki

Adrian Zawadzki

25 min read

Async in JS without Promises is callback hell - the code has no way to refer to the in-flight operation, and error handling sits separately inside every callback. A Promise stands in for a value that will arrive later - the code reads flat, and you catch errors in one place.

Asynchronous JavaScript is something we usually treat as something that just works. We type await fetch(...), get a response, move on. We don’t stop to wonder where exactly that function “slept”, what was waiting in the queue, or why the result is available on the next line even though the request just flew off to the internet.

Underneath, one object is responsible for all of this - a Promise. Not a marketing label, but a concrete piece of the engine that holds a slot for a value that doesn’t exist yet. This post is about how that slot is held, when it gets swapped for a real value, and why a .then() callback fires before a setTimeout(0) callback.

#Life before Promises

To understand what Promises actually fixed, it helps to remember what writing asynchronous code looked like before they showed up in the language - because it wasn’t just an uglier syntax, it was a different way of seeing how JS talks to the browser.

First problem: most async operations don’t run inside JavaScript. A network request, a timer, a click listener - all of those live in the browser (specifically in Web APIs). JS hands off, the browser does the work, the callback comes back. From the language’s perspective: I gave somebody something to do and I have no idea what’s happening over there. I can hand over a callback and wait for the browser to fire it - but until that happens, there’s nothing in JS itself. No reference, no object, no identifier I could track.

console.log("start");
setTimeout(() => console.log("after a second"), 1000);
console.log("next");

setTimeout returns some timer id, but that id is on the browser side. In JS we don’t have access to “what’s happening for that one second” - we just have faith that a callback will arrive in a second. That isn’t a language design bug; it’s simply a consequence of the fact that the event loop and most Web APIs live outside JS itself.

The second problem is putting multiple operations in order. When you need to make a request, wait for the result, make a second request that depends on the first, then a third - you end up in the pyramid known as callback hell:

// Fetch user → their posts → comments on the first post
getUser(
  1,
  (user) => {
    getPosts(
      user.id,
      (posts) => {
        getComments(
          posts[0].id,
          (comments) => {
            render(comments);
          },
          errComments,
        );
      },
      errPosts,
    );
  },
  errUser,
);

// three error paths, three levels of indentation, no chance for try/catch

Each level of nesting is a new callback and a separate error handler. Try catching an exception from the third request at the level of the first one - you have to manually marry three error paths or write a shared handleError that sees all three contexts. A trivial refactor (like splitting this across files) becomes hostile territory, because the whole thing is held together by hidden closures - every callback remembers variables from its parent, but you don’t see that mechanism in the code.

The third problem - error handling - is a hell of its own. try/catch doesn’t catch errors from callbacks, because the callback has long left the call stack by the time it actually runs. Every level needs its own onerror, and if you forget - the error silently disappears.

#What fetch() actually returns

ES6 introduced Promises. fetch('https://api.github.com/users/octocat') returns exactly this kind of object - it exists immediately, but starts in the pending state because the value hasn’t arrived yet. Click below to see the transition to fulfilled (success, there’s a value) or rejected (failure, there’s a reason):

Promise·

Click a button to see a Promise state transition.

It’s a placeholder object: there immediately, with an identifiable state, ready to be assigned to a variable, passed around, hooked up with handlers. When the operation finishes - it changes state on its own and reveals the result.

Here a concept appears that Will Sentance uses in his lectures - a two-pronged facade. A regular function in JS does something on the JS side and returns the result. fetch does two things at once: on the browser side it kicks off a network request (something JS can’t do on its own), and on the JS side it immediately returns an object whose state will mirror what the browser does. This is the first time JS got a handle to an asynchronous operation that the browser is performing. Before that, JS was blind: it gave the browser a job and waited for a callback, with no way to refer to the in-flight operation.

That changes the narrative. Instead of “I dispatch and wait for someone to find me” we get “I dispatch, get a handle, and do whatever I want with it”.

#Three states and a one-way ticket

A Promise is in one of three states:

  • pending - the operation is in flight, no value yet
  • fulfilled - the operation succeeded, there’s a value (value)
  • rejected - the operation failed, there’s a reason (reason)

And here’s the key thing: the transition is one-time and irreversible. From pending you can move to fulfilled or to rejected, but no further - there’s no “fulfill again”, “undo the fulfillment”, “change the value”. This simplification has its consequences (e.g. why you can’t cancel a Promise - more on this in a bit).

The transitions pending → fulfilled or pending → rejected are called settling, and a Promise is settled from the moment it resolves either way.

#Anatomy: new Promise(executor)

fetch is a freebie. We make our own Promise manually with new Promise(...) and pass it a single function - the so-called executor. The engine will immediately hand that function two arguments: resolve and reject. Call resolve(value) - the Promise flips to fulfilled. Call reject(reason) - it flips to rejected.

The simplest example - a Promise that fulfills with "done" after a second:

const p = new Promise((resolve, reject) => {
  // pretend an operation that takes a second
  setTimeout(() => resolve("done"), 1000);
});

p.then((value) => console.log(value));
// → after a second the console says: done

Step by step:

  1. new Promise(...) creates the object p. It starts in the pending state right away.
  2. Inside, we schedule setTimeout(() => resolve("done"), 1000) - in one second, resolve("done") will be called.
  3. .then(...) registers a callback - “when the Promise fulfills, give me the value”.
  4. After a second, setTimeout fires its callback, which calls resolve("done"). The Promise flips to fulfilled. The .then callback receives the value and prints it.

Notice we don’t use reject here - setTimeout has no way to fail. You reach for reject where the operation actually can fail: a network request, reading a file, a permission error.

Three things that are easy to forget:

  • The body runs immediately. The function you pass to new Promise(...) runs synchronously, before the constructor returns anything. Asynchrony only enters when resolve or reject is eventually called (e.g. scheduled by setTimeout).
  • A second resolve (or reject) is silently ignored by the engine. A Promise can transition from pending to fulfilled or rejected exactly once - after that its state and value are frozen. Subsequent calls to resolve/reject do nothing.
  • throw inside acts like reject. If you throw an exception inside the function (e.g. throw new Error("boom")), the Promise rejects itself with that exception as the reason. That’s why an ordinary try/catch around await (in async/await code, coming up next) finally catches asynchronous errors.

#.then, .catch, .finally

A Promise on its own isn’t useful for much - we need a way to know when it’s ready and what it has inside. That’s what these three methods are for.

fetch("https://api.github.com/users/octocat")
  .then((response) => response.json())
  .then((user) => console.log(user.name)) // → "The Octocat"
  .catch((err) => console.error("failed:", err))
  .finally(() => setLoading(false));
  • .then(onFulfilled, onRejected) - registers a callback for when the Promise becomes fulfilled (first argument) or rejected (second argument). The second argument behaves almost like .catch, but with a subtle difference: the rejection handler in .then(f, r) will not catch an exception thrown inside f, while a separate .catch after .then(f) will. That’s another reason almost nobody uses the second argument - a .catch at the end of the chain is cleaner and catches errors from any earlier link, including the previous .then.
  • .catch(onRejected) - shorthand for .then(undefined, onRejected). Catches an error from any earlier link in the chain.
  • .finally(callback) - runs regardless of the outcome, doesn’t receive a value, doesn’t change the state. Perfect for cleanup (setLoading(false), etc.).

.then does two things at once:

  • The callback inside receives the value when the Promise is ready. That’s the “then” in the name .then.
  • The .then(...) call itself returns a new Promise - which is why you can chain them one after another.

Same with .catch and .finally - each returns another Promise.

#Chaining - a chain instead of nested callbacks

In callback hell, every nested request added another level of indentation. With Promises you flatten it, because .then returns a Promise you can hang another .then on:

fetch("/api/users/1")
  .then((res) => res.json())
  .then((user) => fetch(`/api/posts?author=${user.id}`))
  .then((res) => res.json())
  .then((posts) => render(posts))
  .catch((err) => showError(err));

// compare with callback hell above:
// three nested levels → four lines side by side, one .catch for everything

The mechanism: if a .then callback returns a value, the next .then receives that value. If it returns a Promise, the chain waits for that Promise - the next .then receives its value, not the Promise itself. This “flattening” of Promises is what lets the chain look flat even though every step can be a different async operation.

The second perk: errors propagate through the chain. If a .then in the middle throws or returns a rejected Promise, every following .then is skipped and execution jumps to the nearest .catch. One handler at the end of the chain catches everything - and that’s exactly what couldn’t be done sensibly in callback hell.

#The microtask queue - why a Promise runs BEFORE setTimeout(0)

There’s one thing that surprises most people on first contact:

console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");

First you see 1 and 4 - those are plain synchronous console.log calls running top to bottom of the script, nothing asynchronous about them. (Why JS is synchronous in the first place, and what that means for it, I covered earlier in the post on execution context and call stack - that one shows why the event loop is the only way to run anything outside the main stack.) The surprise comes next: when the script finishes, the event loop turns to the asynchronous callbacks - and here the Promise.then callback (3) fires before the setTimeout callback (2), even though both have zero delay. Why?

Because Promise callbacks don’t land in the regular event loop task queue - they land in a separate queue: the microtask queue. And after every single task (e.g. running a script top to bottom, handling an event, running a setTimeout callback), the event loop first processes all microtasks, and only then picks up the next task from the task queue.

Use Next → / ← Back to step through manually, or hit Run and pause whenever you want:

Microtask queue0
empty
Task queue0
empty
Console

Practical consequence: if inside a .then you schedule another .then, that one also goes into the microtask queue during the same round. Only once the queue is empty does the event loop move on. That’s good - Promise chains are “atomic” with respect to outside events. That’s bad - an infinite chain of microtasks can block rendering (the microtask queue takes priority even over the renderer).

Promises aren’t the only mechanism using this queue. Callbacks from queueMicrotask() and MutationObserver land there too - and follow the same rule (the whole queue is processed before the event loop takes the next task).

#Four static methods for when you have many Promises at once

A single Promise plus .then is the foundation. But often you have N Promises and the question is: “what does their combined readiness mean?”. The standard gives four answers - and each is a different trade-off.

Promise.all - all or nothing
const [users, posts, tags] = await Promise.all([
  fetch("/users").then((r) => r.json()),
  fetch("/posts").then((r) => r.json()),
  fetch("/tags").then((r) => r.json()),
]);

Waits for all to fulfill and returns an array of values in the same order. If any rejects - the whole thing immediately goes rejected with that reason (fail-fast). The remaining Promises aren’t stopped, they keep running - but we’ve stopped caring about their results.

Good when you need all of the data to render anything.

Promise.allSettled - I want each result individually
const results = await Promise.allSettled(promises);
// [
//   { status: 'fulfilled', value: ... },
//   { status: 'rejected', reason: ... },
//   ...
// ]

Waits for all to settle (fulfilled or rejected), never rejects itself. Each item is an object with status and respectively value/reason.

Ideal for “try 5 endpoints, render the ones that succeeded, ignore the ones that died”.

Promise.race - first one wins
const data = await Promise.race([fetch("/fast"), fetch("/slow")]);

Returns the first Promise to settle - regardless of whether it’s fulfilled or rejected. If that “first” was rejected, race is rejected too.

Classic pattern: a request timeout via Promise.race([fetch(...), wait(5000).then(() => Promise.reject(new Error("timeout")))]). The actual request keeps going (Promises can’t be stopped - see below), but you effectively ignore the response if it arrives after the deadline.

Promise.any - any one will do
const data = await Promise.any([
  fetch("/region-eu"),
  fetch("/region-us"),
  fetch("/region-asia"),
]);

Returns the first fulfilled Promise. Rejects are skipped. If all reject - it throws an AggregateError with the list of all reasons.

Good for “three mirror servers, just one needs to answer”.

#async/await - the same mechanism in a nicer syntax

async/await from ES2017 doesn’t introduce a new mechanism - it’s just another syntax over Promises, giving you code that reads synchronously but runs asynchronously.

// .then chain
function loadDashboard(userId) {
  return fetch(`/api/users/${userId}`)
    .then((res) => res.json())
    .then((user) => fetch(`/api/posts?author=${user.id}`))
    .then((res) => res.json());
}

// async/await - same thing
async function loadDashboard(userId) {
  const userRes = await fetch(`/api/users/${userId}`);
  const user = await userRes.json();
  const postsRes = await fetch(`/api/posts?author=${user.id}`);
  return postsRes.json();
}

Two rules worth keeping in mind:

  • An async-marked function always returns a Promise, no matter what you write in return. return 42 in an async function = Promise.resolve(42). throw err in an async function = Promise.reject(err). That “imposed” Promise is why await someAsync() in a non-async function is a syntax error - the syntax unwraps a Promise, so it has to live in a context that understands Promises.
  • await “pauses” the function for the duration of the Promise but doesn’t block the thread. Underneath it’s still a callback - JS records where it was, hands the thread back to the event loop, and returns here when the Promise settles. Everything after await is, in practice, a continuation registered as a microtask.

And the biggest win: try/catch works again.

async function loadDashboard(userId) {
  try {
    const user = await (await fetch(`/api/users/${userId}`)).json();
    const posts = await (await fetch(`/api/posts?author=${user.id}`)).json();
    return posts;
  } catch (err) {
    console.error("something failed:", err);
    throw err;
  }
}

Because if await unwraps a rejected Promise into a thrown exception, try/catch sees it naturally - just like any synchronous throw.

#What Promises don’t do

Three things worth knowing because they’re easy to trip over.

You can’t cancel one. Since the state is a one-time pending → fulfilled/rejected transition, there’s no “undo” operation. If the user changes their mind in the middle of a request, the Promise has no idea - the request keeps going and the .then callback fires anyway. The half-measure is AbortController, which cancels the underlying operation (for fetch - terminates the request on the browser side, and the Promise rejects with an AbortError). But that’s cancelling the operation, not the Promise.

The executor is synchronous. Whatever you write in new Promise((resolve, reject) => { ... }) runs immediately. Only resolve/reject can be called later. If you accidentally hold a heavy synchronous compute inside the executor - you’re blocking the thread just as if it were outside the Promise.

Unhandled rejections are a problem. A Promise that never gets a .catch or an await inside try/catch produces an “unhandled rejection” - in the browser it lands on window.onunhandledrejection, in Node.js on process.on('unhandledRejection'). The default behaviour differs (Node used to warn and keep going, more recently it crashes the process by default - the policy is configurable via a flag). The short version: every Promise chain needs a “floor” - either a .catch or a try/catch around await - otherwise the error silently disappears from the app, and possibly takes the process down.

#Takeaway

It comes down to three things:

  1. A Promise is a placeholder object for a value that doesn’t exist yet. It gives JS a handle to an in-flight operation - which callbacks on their own never did.
  2. The state transitions once: pending → fulfilled/rejected. All composition (.then, .catch, await, Promise.all) is built on this one simple mechanism.
  3. Promise callbacks run via the microtask queue, which drains before the task queue. Hence the “magical” ordering that surprises people in interview questions - and most of the setTimeout(0) traps in tests.

Most of what the API gives you around Promise - Promise.all, Promise.race, async/await, AbortController - sits on top of those three states and the microtask queue. It adds ergonomics, not new mechanics.

Comments

Loading comments…

Leave a comment