AI Computer Institute
Expert-curated CS & AI curriculum aligned to CBSE standards. A bharath.ai initiative. About Us

Promises: Understanding Asynchronous Operations

📚 JavaScript Advanced⏱️ 24 min read🎓 Grade 9
✍️ AI Computer Institute Editorial Team Updated: August 2026 CBSE-aligned · Peer-reviewed · 24 min read
Content curated by subject matter experts with IIT/NIT backgrounds. All chapters are fact-checked against official CBSE/NCERT syllabi.

Open the IRCTC app, tap "Search Trains," and watch what happens. A spinner appears. You can still scroll the results screen, still tap the back button, still switch to WhatsApp and come back — the app has not frozen while it waits for the server to reply with train timings. Compare that to a genuinely frozen app: buttons stop responding, the screen does not redraw, and you start wondering if you should force-close it. The difference between those two experiences is not luck. It is a specific piece of engineering in JavaScript called a Promise, and this chapter is about exactly how it works — not just how to use the syntax, but why it exists and what problem it solves.

The Problem: JavaScript Has Only One Worker

Here is a fact that surprises most students the first time they hear it: JavaScript can only do one thing at a time. It has a single "call stack" — think of it as a single desk where one task sits and gets worked on until it is finished, before the next task is even picked up. There is no second desk. If a task on that desk takes three seconds, the desk is occupied for three full seconds, and nothing else — no button click, no screen redraw, no other line of your code — can happen during that time.

You can watch this "freezing" happen yourself. The following program deliberately makes the single desk busy for three seconds by making it check the clock over and over in a loop, doing real (wasted) work the entire time:

console.log("Booking started");

function blockFor(ms) {
  const start = Date.now();
  while (Date.now() - start < ms) {
    // busy-wait: the CPU is stuck here, nothing else can run
  }
}

blockFor(3000);
console.log("Seat confirmed");

Trace it line by line: "Booking started" prints instantly. Then blockFor(3000) starts a loop that keeps re-checking Date.now() - start < ms — for three whole seconds, the JavaScript engine is trapped inside this loop, doing nothing else. If this were a webpage, the page would be completely unresponsive for those three seconds: no scrolling, no button clicks, nothing. Only after the loop condition finally turns false does control return, and "Seat confirmed" prints.

Now think about a real network request — asking a server "is this train seat available?" A reply from a server can take anywhere from 200 milliseconds to several seconds, and network requests are, at the hardware level, even slower than that busy-wait loop relative to what the CPU could otherwise do. If JavaScript handled network requests the same blocking way it handled blockFor, every single webpage that talks to a server — which is almost every webpage — would freeze solid every time it fetched data. That is unacceptable, and browser engineers solved it decades ago with a rule: slow operations (network calls, file reads, timers) are handed off to be run outside the single desk, by the browser itself, and JavaScript is only told about the result once it's ready. That handoff-and-notify pattern is what "asynchronous" means, and a Promise is the object JavaScript uses to represent "a result that isn't ready yet, but will be."

The Old Way: Callbacks, and Why They Get Messy

Before Promises existed (they were added to JavaScript in 2015, in a version called ES6), asynchronous results were handled with plain functions passed as arguments, called callbacks. The idea: "here is a function — call it once you're done, and pass the result into it." This works, but it has a structural problem that shows up the moment you need to do several async steps in sequence, where each step depends on the previous one's result — exactly the shape of a real train booking: first check seats, then reserve one, then charge the payment.

function checkSeatAvailability(train, callback) {
  setTimeout(() => callback(null, 4), 1000); // 4 seats found, after 1s
}
function bookSeat(seats, callback) {
  setTimeout(() => callback(null, { id: "PNR482" }), 1000);
}
function makePayment(booking, callback) {
  setTimeout(() => callback(null, { id: "RCPT991" }), 1000);
}

checkSeatAvailability("12951", function (err, seats) {
  if (err) { console.log("Error checking seats"); return; }
  console.log(seats + " seats available");
  bookSeat(seats, function (err, booking) {
    if (err) { console.log("Error booking seat"); return; }
    console.log("Booked, PNR:", booking.id);
    makePayment(booking, function (err, receipt) {
      if (err) { console.log("Payment failed"); return; }
      console.log("Ticket confirmed, receipt:", receipt.id);
    });
  });
});
console.log("Waiting for booking to complete...");

setTimeout(fn, ms) is JavaScript's built-in way to say "hand this function to the browser, and call it back after roughly ms milliseconds have passed" — it is itself an asynchronous, callback-based tool, which is why it's a convenient stand-in for a real network call in examples. Trace the order of output here carefully, because it is the single most important habit this chapter is teaching you: setTimeout never runs its function immediately, even with a delay of 0. It always hands the function off and lets the rest of the synchronous code run first. So the engine reads checkSeatAvailability(...), immediately hands its inner setTimeout off to the browser, and moves to the very next line — console.log("Waiting for booking to complete...") — which prints before anything inside the callbacks. Only after about 1 second does "4 seats available" print, then about 1 second later "Booked, PNR: PNR482", then about 1 second after that, "Ticket confirmed, receipt: RCPT991". The final printed order is: Waiting for booking to complete..., 4 seats available, Booked, PNR: PNR482, Ticket confirmed, receipt: RCPT991.

The code works, but look at its shape: each new step is nested one level deeper inside the previous callback, drifting further right with every step. Programmers call this the "pyramid of doom," or more commonly "callback hell." With three steps it's merely ugly; real booking flows (seats, quota check, payment, SMS confirmation, ticket generation) can have six or seven steps, and error handling has to be repeated — by hand — at every single level, because each nested callback only knows about the error that belongs to its own level.

Meet the Promise

A Promise is an object that represents a value which does not exist yet, but is guaranteed to exist — or definitively fail to exist — at some point in the future. It is JavaScript's formal, built-in replacement for the callback pattern above, and it fixes the nesting problem by letting you write asynchronous steps as a flat, readable chain instead of a pyramid.

Every Promise is, at any given moment, in exactly one of three states:

  • Pending — the outcome isn't known yet (this is the starting state, always).
  • Fulfilled — the operation succeeded, and the Promise now holds a result value.
  • Rejected — the operation failed, and the Promise now holds a reason (usually an error).

Crucially, a Promise can only move from Pending to Fulfilled, or from Pending to Rejected — once it makes that move, it is called settled, and it stays that way permanently. It cannot flip from Fulfilled back to Pending, and it cannot go from Fulfilled to Rejected. That "settle exactly once, forever" guarantee is what makes chaining safe and predictable.

The three states of a JavaScript Promise A Promise is created in the Pending state. Calling resolve moves it to Fulfilled, handled by .then. Calling reject moves it to Rejected, handled by .catch. Both paths converge into .finally. new Promise((resolve, reject) => { ... }) PENDING resolve(value) reject(error) FULFILLED REJECTED .then(onFulfilled) .catch(onRejected) .finally(() => { ... }) A Promise settles exactly once — Pending to Fulfilled, or Pending to Rejected. Never both, never back.

Building a Promise and Chaining It: The Rewrite

A Promise is created with new Promise((resolve, reject) => { ... }). The function you pass in — called the executor — receives two functions, resolve and reject, that you call yourself once the async work finishes: call resolve(value) on success, or reject(error) on failure. Here is the same three-step booking flow from earlier, rewritten so each step returns a Promise instead of taking a callback:

function checkSeatAvailabilityP(train) {
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve(4), 1000);
  });
}
function bookSeatP(seats) {
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve({ id: "PNR482" }), 1000);
  });
}
function makePaymentP(booking) {
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve({ id: "RCPT991" }), 1000);
  });
}

checkSeatAvailabilityP("12951")
  .then((seats) => {
    console.log(seats + " seats available");
    return bookSeatP(seats);
  })
  .then((booking) => {
    console.log("Booked, PNR:", booking.id);
    return makePaymentP(booking);
  })
  .then((receipt) => {
    console.log("Ticket confirmed, receipt:", receipt.id);
  })
  .catch((err) => {
    console.log("Something failed:", err);
  });
console.log("Waiting for booking to complete...");

The output order is identical to the callback version — Waiting for booking to complete... first, then the three results roughly a second apart — but notice what changed structurally: every step sits at the same indentation level, reading top to bottom instead of drilling rightward. This works because .then() always returns a brand-new Promise. When the function you give .then() returns another Promise (like bookSeatP(seats) does), the chain automatically waits for that inner Promise to settle before calling the next .then(). And a single .catch() at the end catches a rejection from any step above it — you no longer need to repeat error-handling logic at every level, which was the real cost of callback hell.

Handling Failure: .catch() and .finally()

Real bookings fail — sometimes there genuinely are zero seats left. Here's a version where the check rejects instead of resolving:

function checkSeatAvailabilityP2(train) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const seats = 0;
      if (seats === 0) {
        reject(new Error("No seats available on train " + train));
      } else {
        resolve(seats);
      }
    }, 1000);
  });
}

checkSeatAvailabilityP2("12951")
  .then((seats) => console.log(seats + " seats available"))
  .catch((err) => console.log("Booking failed:", err.message))
  .finally(() => console.log("Search complete"));

Trace it: after roughly 1 second, seats === 0 is true, so the executor calls reject(new Error(...)) instead of resolve. Once a Promise rejects, every .then() in the chain is skipped — the failure jumps straight to the nearest .catch(), which prints Booking failed: No seats available on train 12951 (reading err.message off the Error object). Finally, .finally() runs no matter what happened — fulfilled or rejected — so Search complete prints last, always. This is exactly why .finally() exists: for cleanup code (hiding a loading spinner, closing a connection) that has to run either way.

Common Misconception: "The Async Work Starts When You Call .then()"

Almost every student assumes this, and it's wrong in a way that matters for tracing exam-style "predict the output" questions. The executor function you pass to new Promise(...) does not wait for .then() to be attached — it starts running the instant the Promise object is constructed, synchronously, on the spot, as part of your regular top-to-bottom code. .then() doesn't launch the async operation; it only registers a callback for whenever the Promise already in progress eventually settles.

console.log("A");
const p = new Promise((resolve, reject) => {
  console.log("B"); // runs immediately, synchronously — right here
  resolve(42);
});
console.log("C");
p.then((value) => console.log("D", value));
console.log("E");

Trace this one very carefully, because it reveals two separate rules at once. First: "A" prints. Then the engine reaches new Promise(...) and immediately runs the executor — so "B" prints right there, before the line even finishes executing, and resolve(42) settles the Promise to Fulfilled with value 42 — all of this still perfectly synchronous, no waiting involved. Then "C" prints. Then p.then(...) is called — but even though the Promise is already fulfilled, the callback you gave to .then() is never run immediately. JavaScript's rule is that every .then() callback is deferred to a special queue called the microtask queue, and microtasks only run after all currently-running synchronous code finishes completely. So "E" prints next. Only once the whole script has finished — no more synchronous lines left — does the engine check the microtask queue, find the pending callback, and finally print "D 42". Full output, in order: A, B, C, E, D 42.

Microtasks vs. Macrotasks: Why setTimeout(fn, 0) Doesn't Run First

This distinction — between the microtask queue that Promises use and the "macrotask" (or callback) queue that setTimeout uses — is the source of one of the most common trace-the-output questions you'll meet, so it deserves its own careful look:

console.log("1");

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

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

console.log("4");

Many students guess the output is 1, 2, 3, 4, reasoning that a 0-millisecond timer should fire "immediately." It doesn't. Here's the actual rule the JavaScript engine follows, every single time: finish all synchronous code first, then drain the entire microtask queue (Promise callbacks), and only after both of those are completely empty does it take the next task from the macrotask queue (setTimeout callbacks) — one at a time, checking the microtask queue again after each one. So the trace is: "1" prints (synchronous). setTimeout hands its callback to the browser's timer system and moves on — it does not run yet, even with delay 0. Promise.resolve().then(...) creates an already-fulfilled Promise and queues its callback as a microtask. "4" prints (still synchronous, still ahead of both queues). Now the synchronous script is finished, so the engine drains the microtask queue: "3" prints. Only after the microtask queue is completely empty does the engine finally reach into the macrotask queue: "2" prints last. The true output is 1, 4, 3, 2. The takeaway rule worth memorizing: microtasks (Promises) always run before macrotasks (setTimeout), no matter what delay you give the timer.

async/await: The Same Promises, Easier to Read

Once you understand Promises properly, async/await is easy: it is not a different mechanism, it is purely rewritten syntax — "syntactic sugar" — sitting on top of the exact same Promise machinery you just traced. Marking a function async makes it always return a Promise, and await inside it pauses that function (only that function — nothing else in the program) until the Promise it's waiting on settles, then unwraps the value directly instead of forcing you into a .then() callback.

async function bookTicket() {
  try {
    const seats = await checkSeatAvailabilityP("12951");
    console.log(seats + " seats available");
    const booking = await bookSeatP(seats);
    console.log("Booked, PNR:", booking.id);
    const receipt = await makePaymentP(booking);
    console.log("Ticket confirmed, receipt:", receipt.id);
  } catch (err) {
    console.log("Something failed:", err);
  }
}

bookTicket();
console.log("Waiting for booking to complete...");

This produces the exact same output, in the exact same order, as the .then()-chain version — Waiting for booking to complete... first, then the three results in sequence — because it is the same underlying chain, just written to read like ordinary top-to-bottom, step-by-step code. Notice too that ordinary try/catch — the same error-handling syntax you already use for regular synchronous code — now catches Promise rejections directly, replacing .catch(). This is precisely why await is so easy to misread: it looks like it's blocking, freezing the program the way blockFor(3000) did at the start of this chapter. It is not. It only pauses the single async function it's written inside; the rest of your program — including that very next console.log("Waiting...") line, sitting right outside the function — keeps running immediately, which is exactly why that line prints before any of the booking steps. JavaScript is still, underneath all of this, single-threaded; async/await does not add extra threads, it just hides the microtask-queue mechanics behind more readable syntax.

Running Promises Together: Promise.all

Everything so far has been sequential — step 2 only starts after step 1 finishes. But sometimes steps don't depend on each other at all, and waiting for them one after another wastes time. Suppose you want to check seat availability on two different trains at once, say the Rajdhani (12951) and a second option (12301), and you only care once both results are back:

function checkTrain(trainNo, delay, seats) {
  return new Promise((resolve) => {
    setTimeout(() => resolve({ trainNo, seats }), delay);
  });
}

Promise.all([
  checkTrain("12951", 1000, 4),
  checkTrain("12301", 1500, 2)
]).then((results) => {
  results.forEach((r) => console.log(r.trainNo + ": " + r.seats + " seats"));
});

Promise.all takes an array of Promises and starts all of them running at essentially the same moment — it does not wait for the first to finish before starting the second. It then returns a single new Promise that fulfills only once every Promise in the array has fulfilled, with an array of all their results. Here, the 12951 check finishes after roughly 1000ms and the 12301 check after roughly 1500ms; since they run concurrently rather than back-to-back, the total wait is about 1500ms — the length of the slower one, not the sum of both (which would have been 2500ms if done sequentially with await twice in a row). The results array preserves the original input order regardless of which one actually finished first, so the output is always 12951: 4 seats followed by 12301: 2 seats. A closely related tool, Promise.race, settles as soon as the first Promise in the array settles rather than waiting for all of them — useful, for instance, for showing "still checking..." if a server takes longer than a fixed timeout Promise.

Check Your Understanding

  1. What will this code print, and in what order?

    console.log("start");
    new Promise((resolve) => {
      console.log("executor");
      resolve("done");
    }).then((v) => console.log(v));
    console.log("end");
    
    Show answer

    start, executor, end, done. The executor runs synchronously the moment the Promise is created (before "end"), but the .then() callback is always deferred to the microtask queue, so it only runs after all synchronous code — including "end" — has finished.

  2. A Promise starts Pending. After you call reject(new Error("timeout")) inside its executor, can a later line in the same executor call resolve("ok") and make the Promise fulfilled instead?

    Show answer

    No. A Promise settles exactly once. Once reject has been called, the Promise is permanently Rejected; any later call to resolve (or a second call to reject) is simply ignored by the JavaScript engine.

  3. Between a Promise's .then() callback and a setTimeout(fn, 0) callback that were both queued during the same synchronous block, which one runs first, and why?

    Show answer

    The .then() callback always runs first. It goes into the microtask queue, which the JavaScript engine fully empties before it ever looks at the macrotask queue that setTimeout callbacks wait in — regardless of the delay value given to setTimeout.

  4. Rewrite this .then() chain using async/await with a try/catch:

    function getStatus() {
      return checkSeatAvailabilityP("12951")
        .then((seats) => "Seats: " + seats)
        .catch((err) => "Error: " + err.message);
    }
    
    Show answer
    async function getStatus() {
      try {
        const seats = await checkSeatAvailabilityP("12951");
        return "Seats: " + seats;
      } catch (err) {
        return "Error: " + err.message;
      }
    }
    
  5. Why does Promise.all([checkTrain("A", 1000, 4), checkTrain("B", 1500, 2)]) take about 1500ms total instead of 2500ms?

    Show answer

    Because Promise.all starts every Promise in the array immediately, at essentially the same moment, rather than waiting for one to finish before starting the next. The total time is bounded by the slowest Promise in the group (1500ms here), not the sum of all of them — that's the whole benefit of running independent async operations concurrently instead of sequentially.

Summary

JavaScript runs on a single thread, so it cannot afford to freeze that one thread while waiting on something slow like a network request — that's the concrete problem this whole chapter has been solving. A Promise is the object JavaScript uses to represent a value that isn't ready yet: it starts Pending, and settles exactly once, either into Fulfilled (via resolve, handled with .then()) or Rejected (via reject, handled with .catch()), with .finally() running in either case. Promises replaced the older callback pattern specifically because chained .then() calls stay flat and readable where nested callbacks spiral into "callback hell," and because a single .catch() can handle errors from an entire chain instead of repeating error-checks at every nesting level. The executor function passed to new Promise(...) runs synchronously and immediately — not when .then() is attached — while every .then()/.catch() callback is deferred to the microtask queue, which the engine always empties completely before touching the macrotask queue that holds setTimeout callbacks, which is why setTimeout(fn, 0) never beats a Promise callback to the console. async/await is the same Promise machinery written in a more linear style, pausing only the function it's written inside — never the whole program — and letting you use ordinary try/catch for errors. And when async steps don't depend on each other, Promise.all runs them concurrently, cutting total wait time down to the slowest single step instead of the sum of all of them. Together, these tools are what let an app like IRCTC or a UPI payment screen stay alive and responsive while it waits on the network — and they are also exactly the kind of "predict the output" reasoning that CBSE Computer Science and Informatics Practices papers like to test, so being able to trace execution order line by line, the way this chapter has, is worth practicing until it's automatic.

← ES6 Classes: Object-Oriented Programming in JavaScriptAsync/Await: Writing Asynchronous Code →

Found this useful? Share it!

📱 WhatsApp 🐦 Twitter 💼 LinkedIn