Why any of this exists
Playwright is asynchronous from top to bottom, and the reason is physical rather than stylistic: some things take time. Network requests, timers and user input all finish later than the line that started them. Rather than freeze while it waits, JavaScript starts the work, keeps going, and comes back when the result is ready.
That is the whole idea. The chain the class has walked through, in order, is callbacks, then promises, then async/await. Each step solved a readability problem in the one before it, and async/await is where Playwright landed.
A promise is in exactly one of three states: pending, fulfilled, or rejected. The class analogy has stuck all term: the delivery either arrives, or it does not, and until one of those happens it is on its way.
A framing point that opened the class and is worth repeating. Everyone now has AI writing their code. The thing that separates two people using the same tools is whether they can read, debug and explain what came out. That is what interviews test, and it is the reason this session was spent on execution order rather than on more syntax.
Rule 1: the test callback is async
import { test, expect } from '@playwright/test';
test('has title', async ({ page }) => {
await page.goto('https://playwright.dev/');
await expect(page).toHaveTitle(/Playwright/);
});
test() takes two arguments: a title, and a callback. The callback is always async. Not sometimes, always. Everything inside it is assumed to be work that finishes later.
The { page } in the parameter list is a fixture, which gets its own treatment later. Ignore it for now.
Rule 2: await what returns a promise, and nothing else
This is the rule that can be checked mechanically, which is what makes it useful. Hover the method in your editor. If the return type says Promise, it needs await. If it says anything else, it does not.
await page.goto('https://example.com'); // returns Promise<Response|null> -> await
await page.getByRole('button').click(); // returns Promise<void> -> await
await expect(page).toHaveTitle('Example'); // returns Promise<void> -> await
const email = page.locator('#email'); // returns Locator -> NO await
const row = page.getByRole('row'); // returns Locator -> NO await
Locators are lazy: building one touches nothing, so there is nothing to wait for. The DOM is only queried when you act on it or assert against it, and that call is the one that returns a promise.
Adding a stray await to a non-promise is not an error and will not be flagged. await on a plain value just hands the value straight back, one microtask later. It is harmless, it is noise, and it tells a reviewer you were guessing. The cost of the wrong guess is only in the other direction.
What really happens when you drop an await
This is the point where the room split, and the majority answer was wrong, so it is worth being precise.
Without await, the promise starts and execution carries straight on to the next line. The work does not block. It does not queue. Two commands written one after another run at the same time, and whichever finishes first wins.
The class proved it with an ordering puzzle. Predict the output:
async function test() {
console.log('A');
console.log('B');
await Promise.resolve();
console.log('C');
}
test();
console.log('D');
The answer is A, B, D, C.
A and B are ordinary synchronous lines, so they print immediately. The await then suspends test() even though the promise is already resolved, control returns to the caller, D prints, and only once the call stack is empty does C follow.
Delete one word, and the order changes:
async function test() {
console.log('A');
console.log('B');
Promise.resolve(); // <- no await
console.log('C');
}
test();
console.log('D');
Now it is A, B, C, D. Nothing suspends, so test() runs to completion before D gets a turn.
Now put that back into a test. Two Playwright commands without awaits are two operations racing:
page.goto('https://example.com'); // takes maybe 800ms
page.getByRole('button').click(); // fires immediately, page not loaded yet
The click does not wait for the navigation. It runs against whatever is on screen, which is usually nothing. That is the synchronisation bug, and it is why every promise-returning line gets an await.
Forgetting await does not throw. There is no error, no warning, and often no failure on your machine, because your network happened to be fast enough. It surfaces later as a test that passes locally and fails in CI, which is the most expensive kind of bug to own. The typed rule above is the defence: if the signature says Promise, the await is not optional.
The event loop, and why promises beat timers
Everything so far has been about what await does. This is the machinery underneath it, and it is the part that explains flaky tests.
JavaScript runs on one thread. It never waits. When it meets something slow, it hands the work off and carries on, then picks the result up later. Four places are involved:
The consequence is a favourite interview question. Predict the output:
console.log('1: start');
setTimeout(() => console.log('4: timeout'), 0);
Promise.resolve().then(() => console.log('3: promise'));
console.log('2: end');
The order is 1, 2, 3, 4, and the numbers in the strings are there because that is the order they print, not the order they are written.
1: startand2: endare synchronous. They run on the call stack immediately, in source order.3: promiseis a microtask. The moment the stack empties, the entire microtask queue drains.4: timeoutis a macrotask. It waits for the microtask queue to be empty first, andsetTimeout(fn, 0)is not zero milliseconds. Measured on Node, that call actually waited about a millisecond, and under load it is longer.
Microtasks do not just go first, they go completely first. Chain three .then() calls and all three run before a setTimeout(fn, 0) that was scheduled earlier, which was verified before publishing.
This is the mechanism behind a whole class of flaky test. Any test that assumes "the timer fired, so the promise must have settled" has the ordering backwards, and it will pass on a fast machine and fail on a loaded CI runner where the timing shifts. The fix is never a longer timer. It is to wait on the actual event rather than on the clock, which is what Playwright's auto-waiting already does for you.
Three eras, one problem
Callbacks, promises and async/await are three answers to the same question: what do you do while waiting. Recognising all three matters because you will meet them in other people's code.
| Pattern | Looks like | Error handling | Where you meet it |
|---|---|---|---|
| Callback | fn(args, (err, result) => {}) |
manual, check err on every level |
old Node APIs, callback-era Selenium bindings |
| Promise | fn().then(ok).catch(fail) |
one .catch() at the end of the chain |
what every page.* method actually returns |
| async/await | await fn() inside an async function |
ordinary try { } catch { } |
how you write Playwright tests |
The progression is about readability, not capability. Nested callbacks produce the pyramid shape people call callback hell, promises flatten it into a chain, and await flattens the chain into lines you read top to bottom. The promise is still there underneath. await is a way of writing it, not a replacement for it.
Three habits that survive from the callback era
These three are worth checking for in your own code, and all three were verified against Playwright before being written down.
1. An unawaited assertion is not silent any more. The old advice was that a missing await on expect() means the assertion never runs and the test passes regardless. That is no longer true. On Playwright 1.61, a deliberately false unawaited assertion failed the test, reporting expect(locator).toBeVisible() failed and pointing at the exact line. Playwright now tracks the floating promise. Still write the await, but know that the framework has your back on this one.
2. page.waitForTimeout is for debugging only, and Playwright says so itself. Straight from the API documentation: it "should only be used for debugging. Tests using the timer in production are going to be flaky. Use signals such as network events, selectors becoming visible and others instead." A fixed sleep is a callback-era habit wearing async clothing. Web-first assertions already wait.
3. page.waitForNavigation is deprecated. This one matters because the old Promise.all navigation pattern is still copied around widely:
// deprecated: Playwright marks waitForNavigation as "inherently racy"
await Promise.all([page.waitForNavigation(), page.click('#go')]);
// the documented replacement
await page.click('#go');
await page.waitForURL('**/dashboard');
Playwright's own type definitions carry the deprecation and name page.waitForURL as the replacement. The Promise.all shape was invented to avoid a race between starting the wait and triggering the navigation, and waitForURL removes the need for it.
There is a pattern behind all three. Each one was correct advice at some point, and each stopped being correct without anyone announcing it. The check that catches this is the same one from Rule 2: hover the method and read what your editor tells you. A strikethrough on a method name is the fastest code review you will ever get.
try, catch, finally
An awaited promise that rejects throws, and a throw can be caught:
async function riskyTest() {
try {
const result = await Promise.reject(new Error('element not found'));
console.log(result); // never runs
} catch (error) {
console.log(`Test failed with: ${error.message}`);
} finally {
console.log('Test complete');
}
}
riskyTest();
// Test failed with: element not found
// Test complete
Three things fall out of that, all of which came up as questions:
- The line after the failed
awaitnever runs. Control jumps tocatchimmediately, soresultis never printed. catchreceives whatever the promise was rejected with, soerror.messageis the rejection reason.finallyalways runs, on success and on failure alike. It is where cleanup goes.
Swap the rejection for a resolution and the shape is the same:
async function safeTest() {
try {
const status = await Promise.resolve(201);
console.log(`Status: ${status}`);
} catch (error) {
console.log(`Failed: ${error.message}`);
} finally {
console.log('Test complete');
}
}
// Status: 201
// Test complete
Playwright already fails a test when an awaited command rejects, so wrapping every line in try/catch is not the lesson. Reach for it when you intend to handle the failure rather than report it: a retry, a cleanup step, a deliberate negative case. Catching an error and doing nothing with it turns a failing test into a passing one, which is worse than no test.
What a flaky test actually is
The definition given, and it is the one to repeat in an interview:
A flaky test is one whose result is inconsistent across consecutive runs.
Worked through with numbers: 100 tests run, 97 pass, 3 fail. Rerun only those 3, and 2 pass while 1 fails again. Two tests are flaky. Those two changed their answer between runs. The third is not flaky, it is simply failing, and it deserves a bug rather than a retry.
That distinction matters because retries hide flakiness rather than fixing it. A test that needs three attempts is telling you something about the application or the locator.
A retry loop, with the bug left in
The class wrote a retry loop around a simulated API, and deliberately shipped it wrong to see who was awake. Here it is as first written:
let attempts = 0;
function flakyAPI() {
attempts++;
if (attempts <= 3) {
return Promise.reject(new Error(`Attempt ${attempts}: failed`)); // <- the bug
}
return Promise.resolve(`Attempt ${attempts}: success`);
}
Read against the stated intent, that is backwards: it was meant to model a call that is allowed three attempts and fails after them, and instead it fails the first three and then succeeds. The corrected version flips the branches:
let attempts = 0;
function flakyAPI() {
attempts++;
if (attempts <= 3) {
return Promise.resolve(`Attempt ${attempts}: success`);
}
return Promise.reject(new Error(`Attempt ${attempts}: failed`));
}
async function retryTest(maxRetries) {
for (let i = 1; i <= maxRetries; i++) {
try {
const result = await flakyAPI();
console.log(result);
} catch (error) {
console.log(`Fail promise: ${error.message}`);
}
}
}
retryTest(5);
// Attempt 1: success
// Attempt 2: success
// Attempt 3: success
// Fail promise: Attempt 4: failed
// Fail promise: Attempt 5: failed
<= rather than < is what makes the third attempt the last one allowed. With <, only two succeed.
Worth being clear about what this models, because the two readings pull in opposite directions. As written it is an attempt budget: you get three, then it refuses. A real flaky API is the mirror image, failing a few times and then succeeding, which is the case a retry loop exists to survive. If you rewrite this for practice, do both: one function that runs out of allowance, and one that comes good on attempt three. The second is the one that makes a retry loop earn its place, because a loop wrapped around a call that succeeds first time never retries at all.
Sequential or parallel
Sequential is the default and usually correct, because step two normally needs step one's result:
const user = await createUser();
const token = await login(user); // needs user
const order = await placeOrder(token); // needs token
When the steps are genuinely independent, Promise.all starts them together and waits for all of them:
const [a, b, c] = await Promise.all([
apiCall('/dashboard'),
apiCall('/profile'),
apiCall('/settings'),
]);
Three calls taking 2, 3 and 4 seconds finish in about 4 seconds rather than 9.
Promise.all fails fast. One rejection rejects the whole thing immediately, and the other calls are abandoned mid-flight without their results being collected. If you want every outcome regardless, Promise.allSettled is the one that waits for all of them and reports each.
In practice you will rarely reach for it. Playwright already parallelises at the level above, running test files across workers and splitting suites by sharding. Your job inside a single test is the simple one: write the steps in order and await each.
Tasks and announcements
- Practise all 158 exercises. That is the running set, and the number keeps moving because it keeps growing.
- Today's task, and it has a deliverable. Research these keywords properly:
async,await,promise,Promise.all,Promise.race, andcallback. Then publish a tutorial as an HTML page, with screenshots of your own output in it. Using an AI assistant to research is expected; publishing something you cannot explain is not the point. - Optional, and worth doing: put the same tutorial on LinkedIn and Medium. Teaching it is how you find out whether you know it.
- Post at least one doubt in the doubt thread. One per person is the minimum being asked for.
- Next class: the rest of OOP plus TypeScript, and then Playwright proper begins.
- Tuesday: a doubt session.
- Coming later: extra Tuesday and Friday evening sessions covering Playwright CLI, Playwright MCP and AI agents, plus dedicated debugging and regex sessions once Playwright is under way.
Interview forms of today's material: given the A/B/C/D snippet, state the output and explain why D beats C. Why does page.locator() not need await when page.click() does? What runs after an awaited promise rejects inside a try? Define a flaky test without using the word "sometimes". Name the one thing Promise.all does that Promise.allSettled does not.