The Testing Academy · Class Notes Thursday, 13 August (IST)
Live class · study guide

Callbacks, promises, and async/await: why Playwright needs all three

Why Playwright actions are written with await. Callbacks and the pyramid of doom they create, promises and their three states, and the two keywords that let asynchronous code be read top to bottom.

By Pramod Dutta, The Testing Academy. Study notes from the live JavaScript class, rebuilt from the session recording. Every snippet on this page was executed in Node before publishing, and the outputs shown are the real ones.

01

The question behind the whole class

Open any Playwright test and the same two words are everywhere:

JavaScript
test("has title", async ({ page }) => {
  await page.goto("https://playwright.dev/");
  await expect(page).toHaveTitle(/Playwright/);
});

Why is the test body async, and why does every action carry an await? Answering that properly needs three concepts, in order: callbacks, then promises, then async/await. The first two are mostly here so the third makes sense, and so you can read older code that still uses them.

This is also the last stretch of plain JavaScript. After this the course moves to TypeScript and then to Playwright itself, so the effort now pays off twice.

02

Callbacks: call me when you are done

A callback is a function you hand to another function, to be run when that function finishes.

The analogy from class: you arrive at a busy restaurant and leave your phone number. You do not stand at the door waiting. You go shopping, and they call you when the table is ready. Your phone number is the callback.

The mechanics are ordinary: the second argument is just a function.

JavaScript
function placeOrder(item, callback) {
  console.log("Order placed for " + item);
  callback();
}

placeOrder("burger", function () {
  console.log("Order is ready, pick it up");
});

Output, in this order:

Text
Order placed for burger
Order is ready, pick it up

The same callback can be written three ways, and they behave identically:

Form Looks like Note
Named function placeOrder("a", named) Defined elsewhere, passed by name
Anonymous function placeOrder("a", function () { ... }) No name, written inline
Arrow function placeOrder("a", () => { ... }) Shortest, and the one Playwright uses

That last row is the point. Look again at the Playwright test at the top: async ({ page }) => { ... } is an arrow function passed as the second argument to test. Playwright's syntax is a callback you have already been writing without naming it.

callback is not a keyword. It is just the parameter name in these examples, and it can be called anything. What makes it a callback is the role it plays, not what it is called.

Callbacks can also return values, which surprises people:

JavaScript
function calculate(a, b, operation) {
  return operation(a, b);
}
calculate(10, 12, (x, y) => x + y);   // 22
03

Two flavours: synchronous and asynchronous

Not all callbacks wait for something. The distinction matters.

A synchronous callback runs immediately, line by line, and the program waits for it before moving on. Every map, filter and forEach callback is this kind:

JavaScript
const results = ["pass", "fail", "pass"];

results.forEach((result, index) => {
  console.log(`test ${index} ${result}`);
});
console.log("done");
Text
test 0 pass
test 1 fail
test 2 pass
done

done prints last because forEach finishes every iteration before the next line runs.

An asynchronous callback says "do this later, carry on for now". The classic vehicle is setTimeout:

JavaScript
console.log("first");
setTimeout(() => console.log("after timeout"), 50);
console.log("second");
Text
first
second
after timeout

The order is the lesson. second prints before after timeout, even though the timeout line comes first in the file. The callback was handed off and the program carried on rather than freezing.

setTimeout is only the simplest way to see the behaviour. It is the same behaviour you want for a network request or a page load, though Playwright does not expose it as timer callbacks: it hands you promises instead, which is the next section.

04

Callback hell, and why nobody writes this any more

One or two callbacks are fine. The problem starts when each step depends on the one before it, which is precisely what a test does: open the browser, then go to login, then enter credentials, then click.

JavaScript
openBrowser(function () {
  goToLogin(function () {
    enterCredentials(function () {
      clickLogin(function () {
        console.log("test complete");
      });
    });
  });
});

Four steps and the code already drifts to the right. This shape has a name, coined long before Playwright existed: the pyramid of doom, also called callback hell.

callback hell async and await openBrowser(function () { goToLogin(function () { enterCreds(function () { clickLogin(function () { done(); }); }); }); }); await openBrowser(); await goToLogin(); await enterCredentials(); await clickLogin(); done(); each step nests inside the one before it same order, read straight down
The same four steps. The only thing that changed is how much of it you can hold in your head.

At four steps it is ugly. At a real test with dozens of steps it is unmaintainable, and closing brackets like }); }); }); }); stop meaning anything.

Callbacks: good Callbacks: bad
Simple for one or two steps Nesting becomes an unreadable pyramid
No special syntax needed Every level needs its own error check
Work everywhere, lightweight Hard to debug
Built into the array methods Returning a value out is awkward

Error handling is the one that really hurts. There is no single place to catch a failure, so each level has to handle its own.

05

Promises: three states, one answer

A promise is JavaScript's way of saying "I will give you a result later, and it will either succeed or fail."

The analogy from class: you order food on a delivery app. Three things can be true. The order is still being prepared (pending), the food arrives (fulfilled, also called resolved), or the restaurant says the dish is unavailable (rejected). A promise is exactly those three states.

pending still being prepared fulfilled .then( ) runs rejected .catch( ) runs either way .finally( ) runs a promise settles exactly once: never both fulfilled and rejected
Pending is the starting state. Settling means moving to fulfilled or rejected, and it happens once.

You build one with new Promise and a function that receives two callbacks, resolve and reject:

JavaScript
const foodIsReady = true;

const order = new Promise((resolve, reject) => {
  if (foodIsReady) {
    resolve("pizza delivered");
  } else {
    reject("order cancelled");
  }
});

order
  .then(result => console.log(result))     // runs on resolve
  .catch(error => console.log(error))      // runs on reject
  .finally(() => console.log("done"));     // runs either way

With foodIsReady true you get pizza delivered then done. Flip it to false and you get order cancelled then done.

The three handlers, precisely:

  • .then runs when the promise resolves, and receives whatever was passed to resolve.
  • .catch runs when it rejects, and receives whatever was passed to reject.
  • .finally runs in both cases. It is the try/catch/finally idea in promise clothing.

resolve and reject are both just callbacks, which is why you had to learn callbacks first. A promise is a structured way of handing over two of them and being told which one got used.

Chained .then calls pass their return value along:

JavaScript
Promise.resolve(5).then(v => v * 10).then(v => console.log(v));   // 50
Promise.resolve(1).then(v => v + 1).then(v => v + 1);             // ends at 3

And a promise does fix the pyramid, up to a point:

JavaScript
openBrowser()
  .then(() => goToLogin())
  .then(() => enterCredentials())
  .then(() => clickLogin())
  .catch(err => console.log(err))
  .finally(() => console.log("test finished"));

Flat instead of nested, and one .catch for the whole chain instead of error handling at every level. Better. Still a lot of .then.

06

Combining promises

Three helpers come up often enough to know by name.

Helper Waits for Resolves when Use it for
Promise.all all to succeed, or the first rejection every promise succeeds auth, database and cache must all be up
Promise.allSettled all of them always, reporting each outcome a report where you want every result, pass or fail
Promise.race the first to settle it settles with that first result, fulfilled or rejected two sources, take whichever answers first
JavaScript
// all: one failure sends the whole thing to catch
Promise.all([Promise.resolve("auth ok"), Promise.reject("DB is down")])
  .then(r => console.log(r))
  .catch(e => console.log(e));          // "DB is down"

// allSettled: never rejects, tells you about each one
Promise.allSettled([Promise.resolve("passed"), Promise.reject("failed")])
  .then(r => console.log(r.map(x => x.status)));   // [ 'fulfilled', 'rejected' ]

// race: first to settle wins
const slow = new Promise(r => setTimeout(() => r("slow"), 300));
const fast = new Promise(r => setTimeout(() => r("fast"), 100));
Promise.race([slow, fast]).then(v => console.log(v));   // "fast"

finally and allSettled sound similar and are not. finally attaches to one promise and runs after it settles. allSettled takes many promises and waits for all of them to settle. Different jobs.

07

Interview questions from this material

These were worked through live. Cover the answers and try them first.

What prints? Promise.resolve(1).then(v => v + 1).then(v => v + 1).then(v => console.log(v))

3. Each .then receives the previous return value, so 1 becomes 2 becomes 3.

A promise resolves, but the first .then throws an error. Does the second .then run? Does .catch?

The second .then is skipped and .catch runs. Throwing inside a handler rejects the chain from that point, so control jumps to the nearest .catch. This one came up in a real interview at a Bangalore company.

A promise is rejected and the chain has .then, .catch and .finally. Which run?

.catch and .finally, in that order. .then is skipped. finally does not care how it settled.

Promise.all gets three promises and one rejects. Does .then run?

No, .catch runs. all demands that every promise succeeds. Use allSettled when you want the results either way.

08

Async and await: the same thing, readable

Chaining .then is better than nesting callbacks, and still noisy. So JavaScript added two keywords.

  • async goes in front of a function. That function now returns a promise.
  • await goes in front of any expression that returns a promise. It pauses at that line until the promise settles, then hands you the value.

The login flow, one more time:

JavaScript
async function runLoginFlow() {
  await openBrowser();
  await goToLogin();
  await enterCredentials();
  await clickLogin();
  console.log("test complete");
}

No nesting, no .then, no drift to the right. It reads like synchronous, top-to-bottom code and behaves asynchronously underneath. That is the whole trick.

Two rules that follow from the definitions:

  • In a Playwright test, await lives inside the async callback. Top-level await is also legal in ES modules, but inside a test the two always travel together.
  • Anything returning a promise can be awaited. You do not need to know how it was built.
09

Why Playwright looks the way it does

Everything a browser does takes time. Navigating to a URL, clicking a button, waiting for a network response. None of it is instant, so none of it can be synchronous without freezing. The same is true of work your test does outside the browser, such as opening a database connection.

That makes every Playwright action promise-returning, which is why the test body is an async arrow function and why each action is awaited:

JavaScript
test("has title", async ({ page }) => {
  await page.goto("https://playwright.dev/");
  await expect(page).toHaveTitle(/Playwright/);
});

Read it with today's vocabulary: test takes a callback, that callback is an arrow function marked async, and each line inside awaits a promise. Three concepts, one line of test code.

The standing instruction for the rest of the course:

  • Use async and await. This is the default and the house style.
  • Do not write callback pyramids. They exist so you can read old code, not so you can write new code.
  • Do not chain .then when an await will do.
  • Promises still appear, mostly inside helpers. Libraries such as Axios already return a promise, so a helper usually just returns it and the test awaits the helper. Reach for new Promise only when you are wrapping something that does not return one already.
10

Tasks and announcements

  • Today's task: practise. Around 147 exercises on callbacks and promises are being added to the class GitHub repository.
  • Tests are starting. Multiple MCQ tests covering the JavaScript parts, plus live coding tests. Attendance is mandatory; Deepak is organising them. The async and await test comes in a later session.
  • No class on 15 August, Independence Day.
  • Doubts go in the doubt thread, not direct messages, so the answers reach everyone. An evening doubt-clearing session is being planned.
  • Coming next: the remaining JavaScript topics, then TypeScript, then Playwright itself.
  • Today's code has been pushed to the class repository.

The honest summary of this class: callbacks and promises are here so you can read them and answer interview questions about them. async and await are what you will actually type. If only one thing sticks, make it the reason: a browser is slow, so Playwright's actions are asynchronous, so they are awaited.