Async/Await — Writing Clean async Code

8 min read

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:

  • await only works inside an async function. Writing await at 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 an async function.
  • An async function 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 must await it (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 await each 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 await needed. 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 missing await.
  • Awaiting unrelated calls sequentially. await fetchUsers(); await fetchProducts(); is twice as slow as Promise.all([...]) if the calls are independent. Default to Promise.all when 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 — but forEach ignores the returned promise. The loop finishes before any save resolves. Use for...of with await (sequential) or Promise.all(arr.map(async item => save(item))) (parallel) instead.

🎯 Practice task

Rewrite the promise chain. 20-25 minutes.

  1. Open chain.js from the previous lesson.

  2. Save a copy as chain-async.js.

  3. Wrap the whole pipeline in an async function run(). Replace each .then with const x = await .... Replace .catch with try/catch around the body. Replace .finally by moving its message to after the try/catch.

  4. Call run() at the bottom of the file. Run with node chain-async.js. Confirm the same output as before.

  5. Add a deliberate bug: change one of the file paths to something that doesn't exist. Rerun and confirm the catch block runs.

  6. Parallel exercise: create two fixture files (users.json and products.json with anything in them). Inside an async function, 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.

// tip to track lessons you complete and pick up where you left off across devices.