|

Day 7: JavaScript Callbacks, Promises, and Async/Await for Playwright

Compact diagram of JavaScript async evolution: callback to promise to async/await

This is Day 7 of the 21-day JS to Playwright Framework series. One lesson a day. JavaScript first. TypeScript next. Playwright after that. A production framework by Day 21.

On Day 1 we put values in boxes. On Day 4 we walked lists. On Day 5 we wrapped the walk in a function. On Day 6 we put those functions next to objects and tables. Today we wait.

A Playwright spec that cannot wait is a screenshot of a loading spinner. page.goto, locator.click, expect(locator).toBeVisible, request.get — every one of those returns a Promise. If you do not understand callback, Promise, and async/await, you will write page.waitForTimeout(5000), you will forEach an API list and wonder why the assertions finish before the responses, and you will fail the interview question that is sitting in 141_Promise_IQ.js.

I am Pramod Dutta. I teach this the same way I debug a failing pipeline: open the smallest file, print the value, then ask what Playwright would do with a wait of the same shape.

The labs are not invented. They live in my LearningPlaywrightBatch repo on main:

  • chapter_13_Callback/ — 126_Callback.js through 132_Py_of_DON.js
  • chapter_14_Promise/ — 133_Promise.js through 141_Promise_IQ.js
  • chapter_15_Async_Await/ — 142_Async_Await.js through 149_API_REAL_FLAKY.js

Three filenames in those folders are spelled the way they are spelled on GitHub. I will use those names. I will not invent 130_Callback_Ex01.js, 143_Converted_Code.js, or 147_Parallel_Execution.js. The files are 130_Call_Ex01_.js, 143_Coverted_Code.js, and 147_Parrallel_Execution.js. Lab 140_Promise.race.js has a dot in the filename.

Open those files. Run them with node. Then come back here and I will map every callback, every .then, and every await to a Playwright auto-wait or an APIRequestContext call you will write for the rest of this series.

*Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.*

Compact diagram of JavaScript async evolution: callback to promise to async/await

Contents

What you will be able to do after Day 7

By the end of this post you can:

  1. Explain a callback as “a function you pass so someone else can call it later” — and see that Playwright’s test() is that pattern.
  2. Tell a sync callback (forEach) from an async callback (setTimeout) and stop awaiting inside forEach.
  3. Recognise callback hell / the Pyramid of Doom as the VWO login you would write if Playwright had no Promises.
  4. Build a Promise with resolve / reject, then consume it with .then, .catch, and .finally.
  5. Pick Promise.all (fail fast), Promise.allSettled (report everyone), and Promise.race (first settler wins) on purpose.
  6. Rewrite a .then chain as async/await and handle errors with try/catch/finally.
  7. Run independent APIRequestContext calls in parallel, and dependent UI steps in sequence.
  8. Write a bounded retry around a flaky API — and know why a commented break keeps looping after success.

That is the skill. Not the syntax. The skill is waiting for a later value without nesting yourself into a corner, failing loud when it rejects, and choosing sequential versus parallel on purpose.

The labs we are actually using

From chapter_13_Callback on main:

  • 126_Callback.js
  • 127_Sync_Callback.js
  • 128_Async_Callback.js
  • 129_Callback_hell.js
  • 130_Call_Ex01_.js (filename is spelled that way, trailing underscore)
  • 131_Callback_Return.js
  • 132_Py_of_DON.js (Pyramid of Doom)

From chapter_14_Promise on main:

  • 133_Promise.js
  • 134_Promise_API.js
  • 135_Promise_Catch.js
  • 136_Promise_Finally.js
  • 137_REAL_Promise.js
  • 138_Promise_ALL.js
  • 139_Promise_AllSettled.js
  • 140_Promise.race.js (dot in the name)
  • 141_Promise_IQ.js

From chapter_15_Async_Await on main:

  • 142_Async_Await.js
  • 143_Coverted_Code.js (filename is spelled Coverted)
  • 144_AA.js
  • 145_Try_Catch.js
  • 146_Sequential_Execution.js
  • 147_Parrallel_Execution.js (filename is spelled Parrallel)
  • 148_IQ.js
  • 149_API_REAL_FLAKY.js

Clone the repo and stay on main:

git clone https://github.com/PramodDutta/LearningPlaywrightBatch.git
cd LearningPlaywrightBatch
git checkout main

You need Node.js 18 or newer. Today we still run files with node. Playwright mapping is in the prose. We do not invent a tests/day-07.spec.ts that is not in the repo.

Why Playwright is a Promise library first

Read a spec you have already seen in a batch:

test("login", async ({ page, request }) => {
  await page.goto("https://app.vwo.com");
  await page.getByPlaceholder("Email").fill("admin@example.com");
  const response = await request.get("/health");
  await expect(page.getByText("Dashboard")).toBeVisible();
});

Four awaits. Four Promises. test itself is a function that takes a callback. { page, request } is fixture injection — an object passed into that callback. If Day 6 taught you the object, today teaches you why the function is async and why every line pauses.

Playwright auto-wait is not magic. A locator action returns a Promise that resolves when the element is actionable, or rejects when the timeout wins. APIRequestContext.get returns a Promise that resolves to an APIResponse, or rejects on a network failure. expect(locator).toBeVisible() returns a Promise that retries until the assertion passes or the expect timeout fires.

If you skip this day you will still type await. You will not know what you are awaiting. That is how people write await page.waitForTimeout(8000) and call it a wait strategy.

Part 1 — Callbacks: you pass the next function

Lab 126: a callback is just a function you hand over

Lab: chapter_13_Callback/126_Callback.js

Exact file on main:

// Callback

function placeOrder(item, callback) {
    console.log("Placing order");
    callback(); // function call
}

// Define
function print() {
    console.log("Normal Fn - Done with the order");
}

// First Way
// placeOrder("Burger", print);

// Sencond Way Anoy
placeOrder("Burger", function () {
    console.log("Anoy Fn, I am also a function wihtout name!")
});

// Third Way - Arrow Fn
placeOrder("Burger", () => {
    console.log("Arrow Fn, I am also a function wihtout name!")
});

test('has title', async ({ page }) => {

});


function test(text, callback) {
    console.log("Hi, this is test");
    callback();
}

test("Verify that the login page is working", async (page) => {
    console.log("Running TC1")
});

test('has title', async ({ page }) => {

});

Three ways to pass the same idea. Named function (print). Anonymous function. Arrow function. The comments keep the classroom spelling — Sencond, Anoy, wihtout. I will not “fix” the file in this post. I quote it.

placeOrder does not know what “done” looks like. You tell it, by passing the function it should call. That is a callback.

Now look at the bottom half. I defined a toy function test(text, callback) so you can see the shape of Playwright’s test(). Playwright’s real test is a higher-order function. You pass a title and an async function. Playwright calls that function later, with fixtures. The first test('has title', async ({ page }) => {}) in this file is the empty spec you get from the getting-started docs. The second one logs "Running TC1". The third one is empty again.

Run it:

node chapter_13_Callback/126_Callback.js

You will see the burger lines, then "Hi, this is test" twice (the two live test(...) calls after the definition), and "Running TC1" once. The empty callbacks still run. They just have no body.

Playwright mapping. Every spec you write is lab 126. test(title, async ({ page, request }) => { ... }) is placeOrder("Burger", callback) with a better name. Event handlers are the same skin: page.on("dialog", (dialog) => dialog.accept()), page.on("console", (msg) => console.log(msg.text())). You pass the function. Playwright calls it when the event fires. { page } is fixture destructuring — Day 6’s object, arriving as an argument to today’s callback.

Lab 127: a sync callback finishes before the next line

Lab: chapter_13_Callback/127_Sync_Callback.js

Exact file:

let testResults = ["PASS", "FAIL", "PASS", "SKIP"];

testResults.forEach(function (result, index) {
    console.log("Test" + index + " -> " + result);
});

// "All done" prints LAST because forEach is synchronous — it finishes all 4 iterations first, then moves on.

The comment talks about "All done". That console.log is not in the file. I will not invent it. The comment is still the lesson: forEach is synchronous. All four iterations run now. Then the engine moves to the next statement.

Test0 -> PASS
Test1 -> FAIL
Test2 -> PASS
Test3 -> SKIP

Playwright mapping. Day 4 already banned await inside forEach. Today you know why. forEach will not wait for a Promise you return from the callback. If you walk tbody tr with forEach(async (i) => { await expect(rows.nth(i)).toBeVisible(); }), the test function continues. Assertions are still in flight. Use for...of or a plain for and await each step. forEach is lab 127. It is a sync callback over a list. It is not a wait.

Same trap on APIRequestContext. This is wrong:

["/health", "/ready", "/live"].forEach(async (path) => {
  const res = await request.get(path);
  expect(res.status()).toBe(200);
});

The spec can pass before the three GETs settle. Walk the list with for (const path of paths) { const res = await request.get(path); ... }.

Lab 128: an async callback runs after the rest of the file

Lab: chapter_13_Callback/128_Async_Callback.js

Exact file:

console.log("Test 1: started");

setTimeout(function () {
    console.log("Test 2 : API response received!")
}, 2000);

console.log("Test 3: Moving to next last");

Run it.

Test 1: started
Test 3: Moving to next last
Test 2 : API response received!

Test 2 is scheduled for 2000 ms later. The file does not stop. Test 3 prints now. Two seconds later the callback fires.

This is the event loop in seven lines. setTimeout does not pause the thread. It registers a callback and returns. Node keeps going.

Playwright mapping. This is why page.goto(url) without await is a lie. You scheduled the navigation. The next line — page.getByRole("button", { name: "Login" }).click() — runs against a page that is still about:blank. Playwright’s API is a Promise, not a setTimeout, but the mistake is the same: you assumed “I called it, so it finished.” You did not wait.

The fix is not setTimeout(..., 2000) inside a spec. The fix is await page.goto(url) so the next line runs after the Promise settles. Auto-wait on the locator then waits for the button to be enabled. That is two Promises. Zero hardcoded sleeps.

page.waitForTimeout(2000) is lab 128 wearing a Playwright jacket. I treat it as a defect in this series unless you are debugging a trace and you say so in the comment.

Lab 129: callback hell is a VWO login written as nests

Lab: chapter_13_Callback/129_Callback_hell.js

Exact file:

// Real QA Scenario: E2E Login Flow app.vwo.com


function openBrowser(callback) {
    console.log("opening the browser");
    setTimeout(function () {
        console.log("Step 1 - browser starting...");
        callback();
    }, 500);
}

function goToLoginPage(callback) {
    setTimeout(function () {
        console.log("Step 2: Login page loaded");
        callback();
    }, 500);
}

function enterCredentials(callback) {
    setTimeout(function () {
        console.log("Step 3: Credentials entered");
        callback();
    }, 500);
}
function clickLogin(callback) {
    setTimeout(function () {
        console.log("Step 4: Login button clicked");
        callback();
    }, 500);
}
// THIS IS CALLBACK HELL 👇

openBrowser(function () {
    goToLoginPage(function () {
        enterCredentials(function () {
            clickLogin(function () {
                console.log("Test Complete!")
            })
        })
    })
})

The comment is the lesson. Each step takes a callback. The next step lives inside the previous callback. Four steps, four indents, one pyramid. Error handling is not even in the file. If goToLoginPage failed, you would add another callback parameter and another nest.

This is a real VWO login, delayed with setTimeout so you can feel the wait. I use app.vwo.com in the batch because students already know the screen.

Run it. After about two seconds you get the four steps and Test Complete!. Each 500 ms is a fake auto-wait.

Playwright mapping. This is what your spec would look like if Playwright’s API were callback-based: goto takes a callback, fill takes a callback, click takes a callback. Playwright did not do that. Every one of those methods returns a Promise. Lab 137 chains those Promises. Lab 143 awaits them. Hold the pyramid. We flatten it twice today.

The other Playwright face of lab 129 is the wait-for-response sandwich done wrong:

// the nest people write when they have not met Promises
page.waitForResponse((r) => r.url().includes("/login"), function () {
  page.getByRole("button", { name: "Sign in" }).click(function () {
    // now assert
  });
});

The correct pairing is Promise.all (lab 138) or const pending = page.waitForResponse(...); await click; await pending. Same two actions. No pyramid.

Lab 130: callbacks with parameters, plus another sync forEach

Lab: chapter_13_Callback/130_Call_Ex01_.js

The filename is 130_Call_Ex01_.js. Trailing underscore. That is the blob on main.

Exact file:

function greetTester(name, callback) {
    console.log("Welcome, " + name);
    callback();
}

greetTester("Dev", function () {
    console.log("Let's start testing!");
});

// Callback with Parameters


function runTest(testName, callback) {
    let status = "PASS";
    callback(testName, status);
}

runTest("Login Test", function (name, result) {
    console.log(name + " → " + result);
});

// Sync Callback — forEach
let bugs = ["UI glitch", "API timeout", "Wrong redirect"];

bugs.forEach(function (bug, i) {
    console.log("Bug #" + (i + 1) + ": " + bug);
});

console.log("Total bugs: " + bugs.length);

Three patterns in one file.

  1. greetTester calls callback() with no arguments. Same as lab 126.
  2. runTest calls callback(testName, status). The callback receives the result. This is how a reporter works.
  3. forEach again — sync. "Total bugs: 3" prints after all three bugs.

Playwright mapping. test.afterEach(async ({ page }, testInfo) => { ... }) is a callback with parameters. Playwright calls it with the fixtures and the testInfo object — title, status, error, attachments. Your custom reporter’s onTestEnd(test, result) is lab 130’s runTest. You do not call it. The runner does. page.on("response", (response) => { ... }) is the same: Playwright passes the Response into your callback.

Lab 131: a callback that returns a value, then the event loop again

Lab: chapter_13_Callback/131_Callback_Return.js

Exact file:

function calculate(a, b, operation) {
    return operation(a, b);
}

let sum = calculate(10, 5, function (x, y) {
    return x + y;
});

console.log(sum);



console.log("A: Test suite started");

setTimeout(function () {
    console.log("B: Slow API test finished");
}, 1000);

console.log("C: Fast unit test finished");

Top half: the callback is the strategy. calculate does not add. You pass the add. That is a higher-order function from Day 5, now used as a callback that returns.

sum is 15.

Bottom half: the event loop again. A, then C, then B one second later. Same shape as lab 128. I put it here so you see the order next to a return value. Returning from a sync callback is immediate. Returning from a setTimeout callback is not how you get a value out — there is no return that reaches calculate. That missing return is why Promises exist.

Playwright mapping. A custom expect matcher, a fixture factory, a page.addLocatorHandler — they all take a function and may use its return. The A/C/B print is your suite: the runner starts, a fast unit assertion finishes, a slow request.get is still in flight. If you needed B’s body in C, you must wait. You cannot return from the timeout. You need an object that represents “later.” That object is a Promise.

request.get is B. Your next expect(status).toBe(200) is C if you forgot await. Lab 131 is the interview picture of a flaky API test: the assertion ran against last week’s value because this week’s GET had not come back.

Lab 132: the Pyramid of Doom, without the VWO comments

Lab: chapter_13_Callback/132_Py_of_DON.js

Exact file:

function step1(callback) {
    console.log("Open browser");
    callback();
}

function step2(callback) {
    console.log("Navigate to page");
    callback();
}

function step3(callback) {
    console.log("Click button");
    callback();
}

function step4(callback) {
    console.log("Click button");
    callback();
}

step1(function () {
    step2(function () {
        step3(function () {
            step4(function () {
                console.log("Done!");
            });
        });
    });
});

Same pyramid as lab 129. No setTimeout. The steps are sync. The shape is still hell: the next call is an argument of the previous call. step3 and step4 both log "Click button" — that is the file. I will not rename them.

This is the last callback lab. If your login has eight steps, you get eight closing }); at the bottom. One error path per level. That is enough. We need an object that represents “later.”

Playwright mapping. A Page Object that takes a callback per method is lab 132. login(page, function () { dashboard.assertWelcome(function () { ... }) }) is a smell. Return Promises. await login(page) then await dashboard.assertWelcome(). Day 8’s classes will return those Promises. Today we learn the object they return.

Part 2 — Promises: an object for a later value

A Promise is an object. It has three states: pending, fulfilled (resolved), rejected. You create one with new Promise(function (resolve, reject) { ... }). You consume one with .then, .catch, .finally. Or you await it. Same object.

page.goto(url) returns Promise<Response | null>. locator.click() returns Promise<void>. request.get(url) returns Promise<APIResponse>. expect(locator).toHaveText("Dashboard") returns a Promise that Playwright retries. You have been holding Promises since the first spec. Today we look at them.

Lab 133: a Promise is an object you can print

Lab: chapter_14_Promise/133_Promise.js

Exact file:

let order = new Promise(function (resolve, reject) {
    let foodready = true;
    if (foodready) {
        resolve("Pizza is delivered!");
    } else {
        reject("Order Cancelled!")
    }
})

console.log(order);
// A Promise is an OBJECT. It wraps a value that will be available later.

Run it.

Promise { 'Pizza is delivered!' }

foodready is true, so the executor calls resolve. By the time console.log(order) runs, the Promise is already fulfilled. The comment is the definition I want you to memorise: a Promise is an object. It wraps a value that will be available later.

Flip foodready to false yourself and run again. You will see a rejected Promise and an unhandled-rejection warning, because this file never calls .catch. That warning is the interview. A rejected Promise you ignore becomes a failed test you cannot read.

Playwright mapping. const navigation = page.goto(url) without await gives you the same kind of object lab 133 prints. console.log(navigation) is a Promise, not a Response. The later value is the response. await unwraps it. .then unwraps it. Logging the Promise does not unwrap it. I have watched SDETs assert on the Promise object and then ask why toBe(200) failed.

Lab 134: .then runs only on resolve

Lab: chapter_14_Promise/134_Promise_API.js

Exact file:

let apiCall = new Promise(function (resolve, reject) {
    resolve({ status: 200, body: "User Data" });
});

apiCall.then(function (response) {
    console.log(response);
    console.log(response.status);
    console.log(response.body);
})

// .then() runs ONLY when the promise resolves successfully.

The resolved value is an object — { status: 200, body: "User Data" }. .then receives that object. The comment is exact: .then runs only on success.

{ status: 200, body: 'User Data' }
200
User Data

Playwright mapping. This is APIRequestContext before async/await:

await request.get("https://restful-booker.herokuapp.com/booking/1").then(async (response) => {
  expect(response.status()).toBe(200);
  const body = await response.json();
  expect(body).toHaveProperty("firstname");
});

You will not write that in a new spec. You will write const response = await request.get(...). Same Promise. Lab 134 is the skin under await. response.status() on Playwright’s APIResponse is a method, not a field — the classroom object uses a field so you can print it without a browser.

Lab 135: .catch runs only on reject

Lab: chapter_14_Promise/135_Promise_Catch.js

Exact file:

let apiCall = new Promise(function (resolve, reject) {
    // I will make call...
    reject("500 Error");
});

apiCall.then(function (data) {
    console.log("Data is success!!")
}).catch(function (error) {
    console.log(error)
});

// .catch() runs ONLY when the promise is rejected.
//  .then() is completely skipped.

reject("500 Error"). The .then body does not run. .catch prints 500 Error. The comments in the file are the rule.

Playwright mapping. A 500 from the network layer, a timeout, a closed context — those reject. A 500 HTTP status from request.get does not reject by default. Playwright’s APIRequestContext resolves with an APIResponse whose status() is 500. You assert the status. You throw if you want the Promise chain to catch. That distinction fails interviews.

const response = await request.get("/admin/report");
if (response.status() >= 500) {
  throw new Error("500 Error"); // now it is lab 135
}

Locator timeouts *do* reject. await page.getByTestId("ghost").click() after 30 seconds is a rejected Promise. .catch / try/catch is how you attach a screenshot and rethrow. Do not swallow it and call the test green.

Lab 136: .finally always runs — like afterEach

Lab: chapter_14_Promise/136_Promise_Finally.js

Exact file:

let testRun = new Promise(function (resolve, reject) {
    reject("Assertion Failed");

});

testRun.then(function (data) { // Resolve
    console.log(data);
}).catch(function (error) { // Reject
    console.log(error);
}).finally(function () { // Always Executed!
    console.log("I will be executed anyhow!!");
});

// .finally() ALWAYS runs — whether the test passed or failed. Just like afterEach() in Cypress or Playwright.

Output:

Assertion Failed
I will be executed anyhow!!

The comment in the file already names Playwright. .finally is test.afterEach. Pass or fail, you close the extra context, you detach the listener, you write the HAR, you unlock the test user.

Playwright mapping. Prefer the runner hook when the cleanup belongs to the test. Use .finally / try/finally when the cleanup belongs to this one helper:

const context = await browser.newContext();
try {
  const page = await context.newPage();
  await page.goto(url);
} finally {
  await context.close(); // lab 136
}

Fixtures already do this for page. When you open a second context for a second role, you own the finally.

Lab 137: the VWO login as a Promise chain

Lab: