async/await is the syntax modern test code uses for almost every async operation. Underneath, it's still promises — but the surface looks like ordinary synchronous code. Read top to bottom, no .then chains, errors handled by familiar try/catch. This lesson covers the two keywords, the small list of rules, the parallel-vs-sequential trap, and why Cypress and Playwright handle async so differently from each other.
async and await — two keywords, one model
async marks a function as asynchronous. await pauses execution inside that function until a promise resolves. That's the whole syntax.
async function loadUsers() {
const response = await fetch("/api/users");
const users = await response.json();
console.log(`Loaded ${users.length} users`);
}
loadUsers();Read it as English: "fetch users, await the response, await the JSON, log the count." No .then, no nesting. Each await literally pauses the function until the promise settles, then resumes with the resolved value as if it were a regular variable.
Two important rules to start:
awaitonly works inside anasyncfunction. Writingawaitat the top level of a non-async file is a syntax error in older Node, allowed in newer modules — but inside test code, you'll always be inside anasyncfunction.- An
asyncfunction always returns a promise. Even if the body just returns a number, the return is wrapped in a promise. To use the value, the caller mustawaitit (or chain.then).
The rewrite — promise chain to async/await
The pipeline from the promise lesson, rewritten:
const fs = require("node:fs/promises");
async function buildAdmins() {
const text = await fs.readFile("users.json", "utf-8");
const users = JSON.parse(text);
const admins = users.filter(u => u.role === "admin");
await fs.writeFile("admins.json", JSON.stringify(admins, null, 2));
console.log(`Wrote ${admins.length} admins`);
}
buildAdmins();Same behaviour as the chained version. Half the lines. No .then boilerplate. Each await says "wait for this promise, give me the value, carry on." When the function returns, its returned promise resolves.
This is the form 95% of test code is written in.
Error handling with try/catch
Promise chains use .catch for errors. Async/await uses regular try/catch — the same pattern you'd use for any throwing code:
async function buildAdmins() {
try {
const text = await fs.readFile("users.json", "utf-8");
const users = JSON.parse(text);
const admins = users.filter(u => u.role === "admin");
await fs.writeFile("admins.json", JSON.stringify(admins, null, 2));
console.log(`Wrote ${admins.length} admins`);
} catch (error) {
console.error("Pipeline failed:", error.message);
}
}A rejected promise that's awaited throws — exactly like a synchronous error. Wrap the awaited code in try and the rejection lands in catch. The whole try-block is the equivalent of a long .then chain; the catch is the equivalent of .catch.
This is one of the biggest wins of async/await over chains: you can use the same error handling for sync and async code. No special .catch syntax to remember.
Sequential vs parallel awaits — the speed trap
Multiple awaits in a row run sequentially. Each one waits for the previous one to finish before starting.
async function slow() {
const users = await fetchUsers(); // 200ms
const products = await fetchProducts(); // 200ms
const orders = await fetchOrders(); // 200ms
// total: ~600ms
}If the three calls are independent (none depends on another's result), running them in parallel cuts the wall time to 200ms:
async function fast() {
const [users, products, orders] = await Promise.all([
fetchUsers(),
fetchProducts(),
fetchOrders()
]);
// total: ~200ms — three calls in flight at once
}The trick: kick off all three promises first, then await them together with Promise.all. Beginners almost universally write the slow version because it reads more naturally. Always ask: do these calls actually depend on each other? If not, parallelise.
A real test scenario
A canonical API integration test — log in, fetch profile, assert on the result:
async function testUserProfile() {
const loginRes = await fetch("/api/login", {
method: "POST",
body: JSON.stringify({ user: "alice", password: "Test@1234" })
});
const { token } = await loginRes.json();
const profileRes = await fetch("/api/profile", {
headers: { Authorization: `Bearer ${token}` }
});
const profile = await profileRes.json();
console.log(profile.name === "Alice" ? "✅ PASS" : "❌ FAIL");
}
testUserProfile();The two fetches are sequential — the second one needs the token from the first — so awaiting them one after another is correct. Each step reads as one line. The whole test reads top to bottom like ordinary code.
The forgotten await — the bug that bites everyone
The single most common mistake when starting with async/await: forgetting await.
async function bug() {
const users = fetch("/api/users").then(r => r.json()); // missing await!
console.log(users.length); // TypeError — users is a Promise, not an array
}fetch().then(...) returns a promise. Without await, the variable holds the promise object, not the resolved value. Calling .length on a promise is undefined.
The fix is one keyword:
async function ok() {
const users = await fetch("/api/users").then(r => r.json());
console.log(users.length); // works
}When in doubt, await. Spotting "I got back a Promise object" in a console log is your cue that an await is missing somewhere.
Cypress vs Playwright — two ways to handle async
The two most popular browser automation frameworks make different choices, and it confuses learners.
-
Playwright uses async/await explicitly. Every command returns a promise; you
awaiteach one. Standard JavaScript:await page.goto("/login"); await page.locator("#email").fill("alice@x.com"); await page.locator("#submit").click(); await expect(page).toHaveURL("/dashboard"); -
Cypress chains commands behind the scenes — no
awaitneeded. The framework keeps an internal queue and resolves things in order:cy.visit("/login"); cy.get("#email").type("alice@x.com"); cy.get("#submit").click(); cy.url().should("include", "/dashboard");
Both are async; only one shows it. The Playwright form is closer to vanilla JavaScript and translates to other contexts more easily. The Cypress form is shorter and removes a class of forgot-to-await bugs — but you can't use plain async/await against Cypress commands the way you might expect.
Knowing both lets you read either codebase. The cheat sheet at /cheat-sheets/cypress-commands covers Cypress's chain semantics in more detail.
The same test, three ways
One API test — three async styles
Callbacks
fetch login -> callback -> fetch profile inside it -> callback -> assert
Pyramid of nested callbacks
Repeated error checks at every level
Hard to read past two steps
Promises (.then)
fetch().then(res => res.json()).then(handler)
Flat chain, top to bottom
Single .catch at the end
Readable, but verbose
async / await
await fetch(...); await res.json();
Reads like sync code
try/catch for errors
The modern default
All three columns describe the same test: log in, fetch profile, assert. Modern code is the third column.
⚠️ Common mistakes
- Forgetting
await. The variable is a Promise instead of the resolved value. Calling methods on it throws or returns nonsense. When something behaves "weirdly," check every async call for a missingawait. - Awaiting unrelated calls sequentially.
await fetchUsers(); await fetchProducts();is twice as slow asPromise.all([...])if the calls are independent. Default toPromise.allwhen you have multiple unrelated async calls. - Using async functions where the API doesn't expect them.
array.forEach(async item => await save(item))looks like it awaits each save — butforEachignores the returned promise. The loop finishes before anysaveresolves. Usefor...ofwithawait(sequential) orPromise.all(arr.map(async item => save(item)))(parallel) instead.
🎯 Practice task
Rewrite the promise chain. 20-25 minutes.
-
Open
chain.jsfrom the previous lesson. -
Save a copy as
chain-async.js. -
Wrap the whole pipeline in an
async function run(). Replace each.thenwithconst x = await .... Replace.catchwithtry/catcharound the body. Replace.finallyby moving its message to after thetry/catch. -
Call
run()at the bottom of the file. Run withnode chain-async.js. Confirm the same output as before. -
Add a deliberate bug: change one of the file paths to something that doesn't exist. Rerun and confirm the
catchblock runs. -
Parallel exercise: create two fixture files (
users.jsonandproducts.jsonwith anything in them). Inside anasyncfunction, time both shapes:console.time("sequential"); const u = await fs.readFile("users.json", "utf-8"); const p = await fs.readFile("products.json", "utf-8"); console.timeEnd("sequential"); console.time("parallel"); const [u2, p2] = await Promise.all([ fs.readFile("users.json", "utf-8"), fs.readFile("products.json", "utf-8") ]); console.timeEnd("parallel");The difference is small for local files, but visible enough to feel the win. With network calls (real APIs) the difference is dramatic.
That's the end of chapter 5. You can now read and write any modern async test code — await fetch(...), await page.click(...), the patterns that make every framework tick. The next chapter introduces the DOM and the browser — the things those async commands are talking to.