Skip to content
chatgpt image feb 22, 2026, 07 27 39 pm QATRIBE

QA, Automation & Testing Made Simple

chatgpt image feb 22, 2026, 07 27 39 pm QATRIBE

QA, Automation & Testing Made Simple

  • Home
  • Git
  • Playwright
  • Typescript
  • Selenium
  • API Testing
    • API Authentication
    • REST Assured Interview Questions
    • API Testing Interview Questions
  • C#
  • Java
    • Java Interview Prepartion
    • Java coding
  • Test Lead/Test Manager
  • AI
    • AI Test Automation / MCP Testing
    • AI Prompts for QA
    • AI QA Careers
    • LLM Testing / AI Evaluation
    • AI Code Review & Risk-Based Testing
  • Cucumber
  • TestNG
  • Home
  • Git
  • Playwright
  • Typescript
  • Selenium
  • API Testing
    • API Authentication
    • REST Assured Interview Questions
    • API Testing Interview Questions
  • C#
  • Java
    • Java Interview Prepartion
    • Java coding
  • Test Lead/Test Manager
  • AI
    • AI Test Automation / MCP Testing
    • AI Prompts for QA
    • AI QA Careers
    • LLM Testing / AI Evaluation
    • AI Code Review & Risk-Based Testing
  • Cucumber
  • TestNG
Close

Search

Subscribe
TypeScript async/await
BlogsTypescript

TypeScript Async/Await: Definition, Syntax & Examples for QA Engineers

By Ajit Marathe
95 Min Read
0

Three days before a release, a test suite that had been green for months started failing intermittently. Not every run — maybe one in six. The failure was always the same: an assertion against a value that hadn’t been set yet, even though the code that set it clearly ran before the assertion in the file. Nobody had touched that test in weeks. What had changed was a small refactor to a helper function that fetched a user profile before a login flow. The refactor added an API call inside the helper, marked the function async, and returned a promise — but three call sites that used the helper never got an await added in front of them. The tests weren’t broken. They were racing.

That’s the entire story of async/await in one paragraph: it’s not a feature you learn once and stop thinking about. It’s a contract between the function that produces a value asynchronously and every single place that consumes it, and TypeScript’s type system only enforces half of that contract. The other half is on you, and on your team, and on whoever inherits that helper function eighteen months from now. This article covers TypeScript async/await from the syntax up, but the actual point is the same one that surfaces in almost every production incident, code review comment, and interview question on the topic: async/await removes the syntactic mess of promise chains, but it doesn’t remove the concurrency, and pretending it does is where the bugs come from.

What async/await actually is in TypeScript

async and await are not a new concurrency model. They’re syntax sugar over Promise, which is itself built on top of JavaScript’s single-threaded event loop. TypeScript adds a type layer on top of that JavaScript mechanism — it doesn’t change the runtime behavior at all, it just lets the compiler catch a category of mistakes (wrong return types, unhandled promise types, awaiting something that isn’t awaitable) before your code ever runs. That distinction matters, because a lot of confusion about async/await comes from treating it like threads or parallelism, when it’s neither.

Here’s the minimal example:

async function fetchUserName(userId: string): Promise<string> {
  const response = await fetch(`/api/users/${userId}`);
  const data = await response.json();
  return data.name;
}

Two things happen when you put the async keyword in front of a function declaration. First, the function’s return value is automatically wrapped in a Promise, even if you write a plain return data.name statement inside it — TypeScript infers the return type as Promise<string>, not string, and it will flag you if you annotate it as anything else. Second, it unlocks the use of the await keyword inside that function’s body, which pauses execution of the function (and only that function — not the whole program) until the promise it’s waiting on settles, either resolving with a value or rejecting with an error.

The official TypeScript handbook’s async/await release notes describe this as a way to write asynchronous code that “looks and feels” synchronous, and that phrase is doing a lot of work — it looks synchronous, it does not behave synchronously. The moment you hit an await, control returns to the event loop and other code gets a chance to run. If you’ve spent time debugging QA automation frameworks, this is the exact mechanism behind half the “flaky test” tickets you’ve triaged.

Why this replaced promise chains in test automation code

Before async/await syntax existed in mainstream JavaScript (it landed in ES2017, and TypeScript supported it even earlier by compiling down to generators for older targets), the same fetch-then-parse logic looked like this:

function fetchUserName(userId: string): Promise<string> {
  return fetch(`/api/users/${userId}`)
    .then(response => response.json())
    .then(data => data.name)
    .catch(error => {
      console.error('Failed to fetch user', error);
      throw error;
    });
}

Functionally identical to the async/await version. But nest three or four of these together — which is exactly what a Playwright test setup helper ends up doing, chaining a login call, a navigation, a wait for a selector, and a data seed — and the .then() chains become genuinely hard to read, harder to add conditional logic to, and brutal to debug because stack traces through chained promises used to be nearly useless. Async/await gave you back if statements, for loops, and try/catch blocks that work the way you’d expect from synchronous code, while the underlying mechanism stayed exactly the same. This is why, if you interview for any SDET or automation architect role today, you will be expected to explain both forms and why one replaced the other — not because you’ll write raw .then() chains in new code, but because you’ll definitely read them in a codebase that’s been around for more than two years, and half of test automation work is reading code someone else wrote under deadline pressure.

The syntax, piece by piece

Declaring an async function

There are four syntactic forms you’ll run into, and QA engineers moving into TypeScript from Java or C# tend to get tripped up by the arrow function variants specifically because the async keyword placement looks unfamiliar at first.

// Function declaration
async function loginUser(username: string, password: string): Promise<void> {
  await page.fill('#username', username);
  await page.fill('#password', password);
  await page.click('#submit');
}

// Function expression
const loginUser = async function (username: string, password: string): Promise<void> {
  await page.fill('#username', username);
};

// Arrow function
const loginUser = async (username: string, password: string): Promise<void> => {
  await page.fill('#username', username);
};

// Method on a class or object
class LoginPage {
  async login(username: string, password: string): Promise<void> {
    await this.page.fill('#username', username);
  }
}

Notice the async keyword sits immediately before the parameter list in every form. This is a common source of syntax errors for engineers coming from C#, where async is a modifier that goes before the return type (public async Task LoginAsync(...)) — in TypeScript, it goes before the parentheses, and there is no separate “Task” type; everything async returns a Promise.

The await keyword and where it’s legal

await can only appear inside a function marked async — this used to be a hard rule with exactly one exception, and now it has two. The classic rule: writing await inside a regular, non-async function is a compile error. TypeScript will tell you directly: “await expressions are only allowed within async functions and at the top levels of modules.” That second clause matters — as of TypeScript 3.8 and ES2022 module support, top-level await is legal directly inside a module (not inside a function) when your module target and tsconfig support it, which is genuinely useful in Playwright global setup files and one-off automation scripts where wrapping everything in an IIFE just to use await at the file’s top level always felt like unnecessary ceremony.

// This is a compile error — 'await' outside async function
function badExample() {
  const data = await fetchData(); // Error: await expressions are only allowed within async functions
}

// Top-level await — legal in an ES module with the right tsconfig target
// (playwright.config.ts and global-setup.ts commonly use this)
const browser = await chromium.launch();
const page = await browser.newPage();

Top-level await requires "module": "es2022" or higher (or "nodenext") and "target": "es2022" or higher in your tsconfig.json — if you’ve hit a confusing error message where await at the file scope is rejected in one project but works fine in another, this config difference is almost always the cause, and it’s worth checking before you assume something is wrong with your Node version.

Typing async functions correctly

This is where TypeScript actually earns its keep over plain JavaScript async/await, and it’s also where I see the most avoidable mistakes in code review — mistakes that don’t cause bugs directly but erode the type safety that’s the entire reason you’re using TypeScript instead of JS in your framework.

The Promise wrapper is automatic — don’t fight it

// Correct — TypeScript infers Promise<string> from the return statement
async function getPageTitle(page: Page): Promise<string> {
  return page.title();
}

// Wrong — this is a type error, not just a style issue
async function getPageTitle(page: Page): string {
  return page.title();
  // Error: The return type of an async function must be a Promise
}

New TypeScript users occasionally try to annotate an async function’s return type as the “unwrapped” value type, thinking that’s what the function ultimately produces. It isn’t — the function itself, when called, always returns a Promise, and the value inside that promise is what gets unwrapped by await at the call site. This is one of those things that seems pedantic until you’ve written a utility function whose callers all forget to await it and then try to use the “value” (which is actually a pending Promise object) directly in an assertion — you’ll get baffling failures like an object comparison against [object Promise] instead of the expected string.

Typing what await unwraps

TypeScript has a built-in utility type, Awaited<T>, specifically for describing what type comes out the other side of an await — including nested and chained promises, which is the case that trips up hand-rolled versions of this type.

async function getConfig(): Promise<{ retries: number; timeout: number }> {
  return { retries: 3, timeout: 5000 };
}

// Extract the resolved type without calling the function
type Config = Awaited<ReturnType<typeof getConfig>>;
// Config is { retries: number; timeout: number }

This combination — Awaited<ReturnType<typeof someAsyncFunction>> — comes up constantly in test automation frameworks when you’re building a page object model and want a helper type for “whatever this fixture function resolves to,” without duplicating the interface by hand or importing a type that lives three files away and drifts out of sync.

Async arrow functions and implicit typing pitfalls

When you don’t explicitly annotate a return type, TypeScript infers one from the function body — which is usually fine, but generic helper functions used across a large automation framework benefit from explicit annotations because the inferred type can silently change when someone edits the function body in a way that widens or narrows the return.

// Inferred type: Promise<WebElement | null>
const findElementSafely = async (selector: string) => {
  try {
    return await driver.findElement(By.css(selector));
  } catch {
    return null;
  }
};

// Explicit — safer for a function other engineers will call across the framework
const findElementSafely = async (selector: string): Promise<WebElement | null> => {
  try {
    return await driver.findElement(By.css(selector));
  } catch {
    return null;
  }
};

I push explicit return type annotations on any async helper that’s exported from a shared utilities file — a page object base class, a custom fixture, an API client wrapper. It’s not about distrust of inference; it’s that the function signature becomes the contract, and contracts should be visible at the point where someone is deciding whether to call your function, not something they have to hover in their IDE to discover.

Error handling: try/catch versus .catch()

This is consistently one of the top two or three things that separate engineers who understand async/await from engineers who’ve memorized the syntax, and it’s a favorite interview probe for exactly that reason.

Basic try/catch around await

async function loginAndVerify(page: Page, credentials: Credentials): Promise<boolean> {
  try {
    await page.fill('#username', credentials.username);
    await page.fill('#password', credentials.password);
    await page.click('#submit');
    await page.waitForSelector('.dashboard', { timeout: 5000 });
    return true;
  } catch (error) {
    console.error('Login flow failed:', error);
    return false;
  }
}

A rejected promise inside an await expression behaves exactly like a thrown exception — it unwinds to the nearest enclosing try/catch, or if there isn’t one, it propagates up as an unhandled promise rejection at the caller. This is the single biggest readability win async/await has over chained .then().catch() syntax: you get one try/catch block that covers multiple sequential async operations, instead of a .catch() tacked onto the end of a chain that you have to trace backward to figure out which of the three .then() calls it’s actually catching errors from.

The catch(error) typing problem

TypeScript types the error parameter in a catch block as unknown by default under strict mode (specifically, under the useUnknownInCatchVariables compiler option, which is on by default when strict is enabled from TypeScript 4.4 onward). This is correct behavior — JavaScript lets you throw literally anything, not just Error objects — but it means you can’t directly access error.message without a type guard, and a lot of engineers either fight this with unsafe any casts or don’t understand why code that worked in an older TS version now shows a compile error.

try {
  await apiClient.post('/orders', orderPayload);
} catch (error) {
  // Error: 'error' is of type 'unknown'
  console.log(error.message);
}

// Correct pattern — narrow with a type guard
try {
  await apiClient.post('/orders', orderPayload);
} catch (error) {
  if (error instanceof Error) {
    console.log(error.message);
  } else {
    console.log('Unknown error shape:', error);
  }
}

For API testing frameworks built on Axios or similar HTTP clients, this gets one layer more specific — you often want to distinguish an HTTP error response from a network-level failure, which means narrowing to the client library’s own error type rather than the generic Error.

import axios, { AxiosError } from 'axios';

try {
  await axios.post('/orders', orderPayload);
} catch (error) {
  if (axios.isAxiosError(error)) {
    console.log('Status:', error.response?.status);
    console.log('Body:', error.response?.data);
  } else if (error instanceof Error) {
    console.log('Non-HTTP error:', error.message);
  }
}

This pattern — narrowing with a library-provided type guard before falling back to instanceof Error — is what I’d expect to see in a mature REST API test suite, and its absence (bare catch (error: any) blocks everywhere) is one of the fastest ways to spot a framework that hasn’t been through a serious code review pass.

Why unhandled rejections are worse in test frameworks than in application code

In a running web application, an unhandled promise rejection often degrades gracefully — a component doesn’t render, a spinner spins forever, a user notices and refreshes. In a test framework, an unhandled rejection frequently does something much worse: it either crashes the entire test run with a stack trace that points at the framework internals instead of your test, or — more insidiously — it gets silently swallowed by a runner that doesn’t fail the test at all, and you end up with a green build over code that actually threw an exception. Playwright’s test runner handles this well for operations awaited inside a test body, but the classic failure mode is a “fire and forget” async call inside a fixture or afterEach hook that nobody awaited, which is exactly the bug from the opening of this article.

Common mistakes: where async/await actually breaks test automation

This section is the one worth bookmarking, because every mistake here is one I’ve either made myself, fixed in someone else’s pull request, or explained in a postmortem.

Mistake 1 — forgetting await on a promise-returning call

// Bug: missing await — assertion runs before navigation resolves
test('should navigate to dashboard', async ({ page }) => {
  page.goto('/dashboard'); // missing await
  await expect(page.locator('h1')).toHaveText('Dashboard');
});

TypeScript will not catch this for you by default. page.goto('/dashboard') returns a Promise<Response | null>, and calling it without await is perfectly valid TypeScript — you’re just discarding the returned promise, which is legal (functions are allowed to return values nobody uses). The test above will frequently pass anyway, because Playwright’s own auto-waiting on the locator assertion papers over the missing await in a lot of cases — which is exactly what makes this mistake so dangerous: it works until it doesn’t, usually under load, in CI, or against a slower staging environment, at which point it becomes a flaky test that eats an afternoon of investigation.

The fix at the tooling level is the ESLint rule @typescript-eslint/no-floating-promises, which flags exactly this pattern — a promise-returning expression used as a statement with no await, .then(), .catch(), or explicit void operator to signal it’s intentionally unhandled. If your automation framework’s ESLint config doesn’t have this rule enabled, that’s worth raising in your next framework retro — it is, in my experience, the single highest-value lint rule for any TypeScript test framework, full stop.

Mistake 2 — sequential awaits where parallel would do

// Slow — each await blocks the next, total time = sum of all three
async function seedTestData(): Promise<void> {
  await createUser('alice');
  await createUser('bob');
  await createUser('carol');
}

// Fast — all three run concurrently, total time ≈ the slowest single call
async function seedTestData(): Promise<void> {
  await Promise.all([
    createUser('alice'),
    createUser('bob'),
    createUser('carol'),
  ]);
}

This mistake doesn’t cause incorrect behavior, which is exactly why it survives so long in test suites — it only costs time. But in a CI pipeline where every extra minute of a smoke suite delays a deployment gate, and where test data seeding happens before every single test class, three sequential awaits that could be parallel is a real, measurable cost. I’ve seen suite runtimes drop by 30-40% purely from auditing setup/teardown code for unnecessary sequential awaits and replacing independent operations with Promise.all. The judgment call is knowing which operations are actually independent — if createUser('bob') needs an ID generated by createUser('alice'), they can’t run in parallel, and forcing it will produce a race condition instead of a speed win, which is a worse outcome than the slow version.

Mistake 3 — await inside a loop when the operations don’t depend on each other

// Anti-pattern — 50 sequential round trips
async function verifyAllProducts(productIds: string[]): Promise<void> {
  for (const id of productIds) {
    const product = await apiClient.getProduct(id);
    expect(product.inStock).toBe(true);
  }
}

// Better — fire all requests, then await together
async function verifyAllProducts(productIds: string[]): Promise<void> {
  const products = await Promise.all(
    productIds.map(id => apiClient.getProduct(id))
  );
  products.forEach(product => expect(product.inStock).toBe(true));
}

This is the same principle as Mistake 2, but it’s common enough as its own pattern — a for loop with an await inside it — to call out separately, because engineers coming from synchronous languages default to for loops out of habit even when a .map() plus Promise.all is both faster and, once you’re comfortable with the pattern, more readable. The caveat worth stating clearly in any interview answer on this topic: this only applies when the iterations are independent. If you’re hammering an API that rate-limits you, firing 50 concurrent requests via Promise.all will get you 429 responses instead of a faster test — sometimes the sequential loop is the correct choice specifically because it’s slower and gentler on a shared resource.

Mistake 4 — Promise.all fails fast and can mask which operation actually failed

const results = await Promise.all([
  checkInventoryService(),
  checkPaymentService(),
  checkShippingService(),
]);
// If checkPaymentService() rejects, Promise.all rejects immediately.
// You don't learn the status of inventory or shipping checks at all.

Promise.all rejects as soon as any one of its input promises rejects, and it discards information about the others — they may still be pending, or may have resolved successfully, but you’ll never see those results in the rejected branch. For a health-check style test that wants to know the status of every dependency regardless of individual failures, Promise.allSettled is almost always the correct choice, and confusing the two is a recurring interview question precisely because the difference is subtle and the wrong choice fails silently rather than loudly.

const results = await Promise.allSettled([
  checkInventoryService(),
  checkPaymentService(),
  checkShippingService(),
]);

results.forEach((result, index) => {
  if (result.status === 'rejected') {
    console.error(`Service check ${index} failed:`, result.reason);
  } else {
    console.log(`Service check ${index} succeeded:`, result.value);
  }
});

The MDN reference for Promise.allSettled is worth reading closely if you haven’t used it in anger — the resolved array’s shape (a discriminated union on the status field) is itself a good example of TypeScript narrowing in practice, since accessing result.value versus result.reason requires you to check status first or the compiler will reject the access.

Mistake 5 — treating async functions as if they run immediately and synchronously up to the first await

This one is subtle and it’s a genuinely good interview question because getting it right requires actually understanding the event loop, not just pattern-matching syntax. An async function body runs synchronously — exactly like a normal function — right up until it hits the first await. At that point, and only at that point, control yields back to the caller.

console.log('1');

async function example() {
  console.log('2');
  await Promise.resolve();
  console.log('4');
}

example();
console.log('3');

// Output order: 1, 2, 3, 4

If you expected 1, 2, 4, 3 — reasoning that the async function “runs in the background” the moment it’s called — that’s the exact misconception this example is built to correct. The function starts executing synchronously and immediately, logging 2, and only yields control at the await. Console log 3 then runs because it’s the next line of synchronous code after the (non-awaited) call to example(), and only after the current synchronous execution stack empties does the event loop process the resolved microtask and resume example() to log 4. This exact reasoning is what’s happening under the hood when a “missing await” bug produces assertions that check state before an operation has actually completed — the calling code moves on synchronously to the next line while the async operation is still queued.

Async/await versus raw Promise chains — a direct comparison

AspectPromise chains (.then/.catch)async/await
Readability for sequential logicDegrades quickly past 2-3 stepsReads like synchronous code
Error handling.catch() at end of chain, easy to lose track of scopetry/catch, familiar control flow
Conditional async logicAwkward — often needs nested .then()Plain if/else works naturally
Debugging / stack tracesHistorically poor, improved in modern enginesGenerally clearer, closer to sync call stacks
Parallel executionPromise.all() natively, same either wayStill needs Promise.all() — async/await doesn’t parallelize automatically
Underlying mechanismPromisePromise (async/await is sugar over it)

That last row is the one people forget in interviews and in practice: async/await does not replace Promise, it sits on top of it. Every async function returns a Promise. Every await expression is unwrapping one. You still need to reach for Promise.all, Promise.race, Promise.allSettled, and Promise.any directly, because await only ever handles one promise value at a time in a sequential, blocking-within-the-function way. Engineers who’ve internalized async/await syntax but never learned the Promise API underneath it consistently write code that’s correct but needlessly sequential, because they don’t reach for the combinator functions that exist specifically to handle multiple concurrent operations.

Edge cases worth knowing

Async functions inside constructors — not allowed

class ApiClient {
  // Compile error: constructors cannot be async
  async constructor(baseUrl: string) {
    await this.authenticate();
  }
}

Constructors return the instance itself, synchronously, by JavaScript’s own object model — there’s no way to make that async without breaking the fundamental contract of new. The standard workaround is a static async factory method:

class ApiClient {
  private constructor(private token: string) {}

  static async create(baseUrl: string): Promise<ApiClient> {
    const token = await authenticate(baseUrl);
    return new ApiClient(token);
  }
}

const client = await ApiClient.create('https://api.example.com');

This pattern shows up in well-designed API test client wrappers precisely because authentication is inherently asynchronous, and a private constructor plus static factory is the idiomatic way to guarantee that nobody can construct a client instance that hasn’t finished authenticating.

Async generators and for-await-of

Less common in day-to-day test automation, but it does appear in scenarios like paginated API result processing, where each page fetch is itself async:

async function* fetchAllPages(baseUrl: string): AsyncGenerator<Product[]> {
  let page = 1;
  let hasMore = true;
  while (hasMore) {
    const response = await fetch(`${baseUrl}?page=${page}`);
    const data = await response.json();
    yield data.items;
    hasMore = data.hasNextPage;
    page++;
  }
}

for await (const pageOfProducts of fetchAllPages('https://api.example.com/products')) {
  pageOfProducts.forEach(p => expect(p.price).toBeGreaterThan(0));
}

The for await...of loop is the async counterpart to a regular for...of loop, and it correctly awaits each yielded value before proceeding to the next iteration — useful when you want to validate paginated data without loading every page into memory at once, which matters for API test suites hitting endpoints with genuinely large result sets.

Async/await with setTimeout — the promisify pattern

setTimeout is callback-based, not promise-based, so it can’t be awaited directly. Wrapping it is a common small utility, and it’s also a decent litmus test in interviews for whether someone actually understands how to bridge a callback API into promise-based code:

function wait(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function retryWithBackoff(fn: () => Promise<void>, attempts: number): Promise<void> {
  for (let i = 0; i < attempts; i++) {
    try {
      await fn();
      return;
    } catch (error) {
      if (i === attempts - 1) throw error;
      await wait(2 ** i * 100);
    }
  }
}

That retryWithBackoff helper is close to production code you’d actually want in a flaky-endpoint retry layer for an API test suite, and it’s a good example of combining several of the ideas above — a try/catch inside a loop, a manually constructed promise wrapping a callback API, and exponential backoff logic that only makes sense once you understand that each await wait(...) genuinely pauses that function’s execution without blocking the rest of the process.

Connecting this to real test automation code

Playwright: async/await is the default posture of the entire API

Every meaningful Playwright API call — navigation, locating elements, interacting with them, waiting for state — returns a Promise, and Playwright’s own Page API documentation reflects this throughout. This is a deliberate design choice: because Playwright auto-waits on most actions, forgetting an await is one of the most common sources of intermittent Playwright test failures in the wild, precisely because auto-waiting sometimes covers for the mistake and sometimes doesn’t, depending on network timing.

test('checkout flow completes successfully', async ({ page }) => {
  await page.goto('/cart');
  await page.click('[data-testid="checkout-button"]');
  await page.fill('#card-number', '4242424242424242');
  await page.fill('#expiry', '12/28');
  await page.click('[data-testid="place-order"]');
  await expect(page.locator('.order-confirmation')).toBeVisible();
});

Notice every single line has an await. That’s not stylistic thoroughness — in Playwright, omitting await on page.click() means the test moves on to page.fill('#card-number', ...) before the click has necessarily registered, which is exactly the class of race condition that produces “works on my machine, fails in CI” behavior, because CI environments are frequently slower or under more contention than a local dev machine, which changes the timing just enough to expose the race.

Selenium with C#: async/await maps directly, with different keywords

Since your primary stack pairs Selenium with C#, it’s worth being explicit about the mapping, because the concepts transfer completely even though the syntax differs. C#’s async/await and Task/Task<T> are functionally the direct analog of TypeScript’s async/await and Promise/Promise<T>:

// C# / Selenium
public async Task<bool> LoginAndVerifyAsync(string username, string password)
{
    try
    {
        await Task.Run(() => _driver.FindElement(By.Id("username")).SendKeys(username));
        await Task.Run(() => _driver.FindElement(By.Id("password")).SendKeys(password));
        _driver.FindElement(By.Id("submit")).Click();
        return _wait.Until(d => d.FindElement(By.ClassName("dashboard")).Displayed);
    }
    catch (WebDriverTimeoutException ex)
    {
        Console.WriteLine($"Login failed: {ex.Message}");
        return false;
    }
}

The important caveat if you’re bridging between the two stacks in your head: classic Selenium WebDriver calls (FindElement, Click, SendKeys) are synchronous and blocking by default in both the C# and Java bindings — WebDriver doesn’t have the same async-first API surface Playwright does. You’ll see Task.Run wrapping used to push blocking WebDriver calls onto a background thread in some C# frameworks, which is solving a different problem (keeping a UI or test runner responsive) than what await solves for genuinely async operations like an HTTP call. Conflating “wrapped in Task.Run” with “actually asynchronous under the hood” is a real conceptual gap that shows up in interviews — a sharp interviewer will ask you to explain the difference, and “it’s non-blocking because it’s wrapped in a Task” is the wrong answer if the underlying WebDriver call is still synchronous I/O happening on a thread pool thread.

API test clients: the layer where async/await typing discipline pays off most

interface ApiTestClient {
  get<T>(path: string): Promise<T>;
  post<T>(path: string, body: unknown): Promise<T>;
}

class OrdersApiClient implements ApiTestClient {
  constructor(private baseUrl: string, private token: string) {}

  async get<T>(path: string): Promise<T> {
    const response = await fetch(`${this.baseUrl}${path}`, {
      headers: { Authorization: `Bearer ${this.token}` },
    });
    if (!response.ok) {
      throw new Error(`GET ${path} failed with status ${response.status}`);
    }
    return response.json() as Promise<T>;
  }

  async post<T>(path: string, body: unknown): Promise<T> {
    const response = await fetch(`${this.baseUrl}${path}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.token}` },
      body: JSON.stringify(body),
    });
    if (!response.ok) {
      throw new Error(`POST ${path} failed with status ${response.status}`);
    }
    return response.json() as Promise<T>;
  }
}

// Usage in a test
test('creating an order returns a valid order ID', async () => {
  const client = new OrdersApiClient(baseUrl, token);
  const order = await client.post<Order>('/orders', { productId: 'p-123', quantity: 2 });
  expect(order.id).toBeDefined();
});

This is roughly the shape of an API client I’d expect in a mature TypeScript automation framework — generic async methods that let each call site specify its own expected response type, explicit status checking before assuming the response body is valid JSON, and consistent error throwing so a single try/catch at the test level covers every possible failure mode from that client.

Interview questions on async/await you should be ready for

These come up across QA Lead, SDET, and Automation Architect interviews with enough regularity that I’d consider all of them fair game for a mid-to-senior level technical screen.

“What’s the difference between async/await and Promises?”

The honest answer, not the memorized one: there isn’t a difference in capability — async/await is syntax built on top of Promises, not a replacement for them. The difference is readability and control flow ergonomics. A strong answer mentions that async functions always return promises, that await unwraps a promise’s resolved value or throws its rejection, and that you still need the Promise combinator methods (all, allSettled, race, any) for concurrent operations because await only handles one promise at a time.

“What happens if you forget to await an async function call?”

The call still executes — the function body starts running synchronously up to its first internal await — but the caller doesn’t wait for it to finish, and any value it eventually resolves to is discarded unless something else references the returned promise. In practice, this means whatever depended on that operation completing (a DOM update, a database write, a file being saved) may not have happened yet when the next line of code runs. This is the single most common root cause of flaky async tests.

“Explain Promise.all vs Promise.allSettled vs Promise.race vs Promise.any.”

Promise.all resolves when every input promise resolves, and rejects immediately if any one rejects. Promise.allSettled always resolves once every input promise has settled (whether fulfilled or rejected), giving you a status for each. Promise.race settles as soon as the first input promise settles, in whichever state (resolved or rejected) that first one lands in. Promise.any resolves as soon as the first input promise fulfills, and only rejects if every single input promise rejects, bundling the individual errors into an AggregateError. A genuinely strong answer gives a concrete use case for each: all for parallel independent setup steps, allSettled for health checks or batch validation where you want every result, race for timeout patterns, any for hitting multiple redundant endpoints and taking whichever responds first.

“How do you implement a timeout for an async operation in TypeScript?”

function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
  const timeout = new Promise<never>((_, reject) =>
    setTimeout(() => reject(new Error(`Operation timed out after ${ms}ms`)), ms)
  );
  return Promise.race([promise, timeout]);
}

// Usage
const result = await withTimeout(apiClient.get('/slow-endpoint'), 3000);

This is a genuinely good one to have ready verbatim, because it combines Promise.race, generics, and the never return type (for a promise that only ever rejects and never resolves) in a way that demonstrates real fluency rather than surface familiarity with the keywords.

“Is async/await slower than raw Promises?”

No, not meaningfully — under the hood, async/await compiles down to the same promise machinery (historically via generator functions when targeting older JS versions, and natively in modern engines). Any performance difference is negligible in real-world test automation code, and the question is more about whether the candidate understands that async/await is sugar rather than a distinct runtime mechanism.

“Can you use async/await with array methods like forEach?”

// Broken — forEach does not await the callback, all iterations fire "simultaneously"
// and the outer function has no way to know when they've all finished
async function seedUsers(names: string[]): Promise<void> {
  names.forEach(async (name) => {
    await createUser(name);
  });
  console.log('All users created'); // This logs before any createUser() call resolves
}

// Correct
async function seedUsers(names: string[]): Promise<void> {
  await Promise.all(names.map(name => createUser(name)));
  console.log('All users created');
}

This is arguably the single most common async/await interview trap, and it’s worth memorizing why it fails: Array.prototype.forEach doesn’t await the promises returned by its callback — it doesn’t even look at the return value at all — so marking the callback async doesn’t make forEach itself asynchronous. It just means each individual callback invocation returns a promise that nobody is tracking. map plus Promise.all is the correct replacement precisely because map returns an array of the promises, which you can then actually await together.

A professional’s take

Async/await is one of those topics where the syntax is genuinely easy — most engineers can write a correct-looking async function within an hour of first seeing the keywords — and the actual skill is entirely in the judgment calls around it: when to parallelize versus when to stay sequential, how to type errors honestly instead of reaching for any, and building the instinct to ask “did I actually await that?” every time you see a function call that touches the network, the filesystem, or a browser action. In a QA automation context specifically, that instinct is worth more than knowing the formal semantics of the microtask queue, because the bugs that async/await mistakes cause in test frameworks don’t look like bugs — they look like flakiness, and flakiness is the single most expensive, most trust-eroding failure mode a test suite can have. Teams stop believing their own CI pipeline long before they figure out it’s a missing await three files deep in a shared fixture. If there’s one habit worth building from everything in this article, it’s treating every promise-returning call as something that demands an explicit decision — await it, combine it with others via Promise.all, or deliberately mark it as fire-and-forget with a void operator — rather than letting the compiler’s silence on a missing await convince you that silence means correctness.

What’s actually happening under the hood: the event loop and the microtask queue

You can write correct async/await code for years without ever needing to explain the microtask queue, right up until you hit a bug where the ordering of operations genuinely matters and doesn’t match your mental model. It usually happens around a mix of setTimeout, promise resolution, and synchronous code in the same function, and it’s worth working through once properly rather than memorizing “promises are faster than setTimeout” as a rule of thumb without understanding why.

JavaScript’s runtime has one call stack, and two separate queues that feed it once the stack is empty: the microtask queue (promise callbacks, including everything after an await, plus queueMicrotask) and the macrotask queue (also called the task queue — setTimeout, setInterval, I/O callbacks, UI rendering in a browser). The event loop’s rule is simple but has a sharp edge: after every single macrotask, the engine drains the entire microtask queue before it’s allowed to pick up the next macrotask. Not one microtask — all of them, including any new ones added while draining.

console.log('start');

setTimeout(() => console.log('timeout'), 0);

Promise.resolve()
  .then(() => console.log('promise 1'))
  .then(() => console.log('promise 2'));

console.log('end');

// Output: start, end, promise 1, promise 2, timeout

Even with a 0ms delay, setTimeout‘s callback runs after both chained .then() callbacks, because the promise callbacks are microtasks and the timeout callback is a macrotask, and the microtask queue is always fully drained first. This is precisely why code that mixes await with setTimeout-based waits can produce ordering that looks wrong until you know this rule — and it’s precisely why the wait(ms) helper shown earlier, which wraps setTimeout in a promise, still ultimately resolves via the macrotask queue underneath, even though you’re consuming it with await.

For test automation specifically, this matters most when you’re debugging a test that interacts with application code doing its own internal scheduling — a debounced search input, a component that batches state updates via setTimeout, a polling mechanism. If your test’s await resolves before the application’s internal macrotask has fired, you’ll assert against stale state, and no amount of retrying the assertion syntax will fix it — you need an explicit wait for the condition the macrotask produces, not just an await on your own async call.

await doesn’t block the thread — it blocks the function

This distinction gets stated so often it’s become a cliché, but it’s worth being precise about what it actually means in practice, because “non-blocking” is thrown around loosely. When function A hits an await, function A’s execution pauses at exactly that line. Nothing else about function A runs until the awaited promise settles. But the JavaScript engine itself is completely free to run other code in the meantime — other event handlers, other async functions that are also mid-flight, timers that fire, and so on. In a Playwright test runner executing tests in parallel across multiple workers, this is what allows a single worker process to interleave execution across concurrent fixture setup, network requests, and assertions without needing separate OS threads for each test — it’s all cooperative multitasking on one thread per worker, coordinated entirely through this await/microtask mechanism.

Async/await inside classes, inheritance, and page object models

Page Object Model frameworks are one of the most common places async/await interacts with TypeScript’s class and inheritance features, and there are a few sharp edges worth knowing before you build a framework around them.

Async methods and method overriding

abstract class BasePage {
  constructor(protected page: Page) {}

  abstract async waitForLoad(): Promise<void>;

  async navigate(path: string): Promise<void> {
    await this.page.goto(path);
    await this.waitForLoad();
  }
}

class DashboardPage extends BasePage {
  async waitForLoad(): Promise<void> {
    await this.page.waitForSelector('[data-testid="dashboard-widgets"]');
  }
}

class SettingsPage extends BasePage {
  async waitForLoad(): Promise<void> {
    await this.page.waitForSelector('[data-testid="settings-form"]');
  }
}

Note: TypeScript actually disallows the abstract async combination as written above in some strict configurations — abstract members have no body, so there’s nothing for async to modify; the cleaner pattern is to declare the abstract method’s return type as Promise<void> without the async keyword on the abstract signature itself, since async only matters where there’s an actual function body to wrap:

abstract class BasePage {
  constructor(protected page: Page) {}

  abstract waitForLoad(): Promise<void>;

  async navigate(path: string): Promise<void> {
    await this.page.goto(path);
    await this.waitForLoad();
  }
}

This is a small but genuinely common mistake in early drafts of a Page Object base class — engineers copy the async keyword onto every method by reflex, including abstract declarations where it doesn’t belong and where some TypeScript configurations will flag it as an error while others silently accept it and just ignore the keyword. Consistency here matters more than most style debates in a framework, because a base class is the template every other page object copies from.

The this-binding trap in async callbacks

class TestReporter {
  private results: string[] = [];

  async attachToPage(page: Page): Promise<void> {
    page.on('console', async function (msg) {
      // 'this' here is NOT the TestReporter instance — it's undefined or the global object
      this.results.push(msg.text()); // Runtime error or silent failure depending on strict mode
    });
  }
}

// Fixed with an arrow function, which lexically captures 'this'
class TestReporter {
  private results: string[] = [];

  async attachToPage(page: Page): Promise<void> {
    page.on('console', async (msg) => {
      this.results.push(msg.text());
    });
  }
}

This isn’t unique to async functions — it’s the classic JavaScript this-binding issue that predates promises entirely — but it shows up constantly in async event handler callbacks specifically, because engineers reach for the traditional function keyword out of habit in event listener registration, and the bug only becomes visible once the callback actually fires and tries to touch instance state. Arrow functions solve it by capturing this lexically from the enclosing scope at definition time rather than rebinding it based on how the function is called.

Testing async code directly — not just writing async tests

Everything so far has been about writing async code inside tests. There’s a related but distinct skill: writing tests for async functions themselves, which comes up constantly when you’re testing utility functions, API client wrappers, or retry logic in isolation rather than through a full browser automation flow.

Testing a resolved promise

import { describe, it, expect } from 'vitest';

describe('fetchUserName', () => {
  it('resolves with the user name for a valid ID', async () => {
    const name = await fetchUserName('user-123');
    expect(name).toBe('Alice');
  });
});

Testing a rejected promise

describe('fetchUserName', () => {
  it('throws for an unknown user ID', async () => {
    await expect(fetchUserName('nonexistent')).rejects.toThrow('User not found');
  });
});

The rejects matcher (available in Jest, Vitest, and most modern TS test runners) exists specifically because expect(fetchUserName('nonexistent')).toThrow(...) without await and without rejects would not work correctly — toThrow expects a synchronous function that throws when invoked, not a promise that eventually rejects, and this mismatch is a common source of tests that pass even though the underlying function is broken, because the test framework never actually waited for the rejection to happen.

Mocking timers when testing retry/backoff logic

import { describe, it, expect, vi } from 'vitest';

describe('retryWithBackoff', () => {
  it('retries three times before giving up', async () => {
    vi.useFakeTimers();
    const flaky = vi.fn().mockRejectedValue(new Error('fail'));

    const resultPromise = retryWithBackoff(flaky, 3).catch(e => e);
    await vi.runAllTimersAsync();
    const result = await resultPromise;

    expect(flaky).toHaveBeenCalledTimes(3);
    expect(result).toBeInstanceOf(Error);
    vi.useRealTimers();
  });
});

Testing exponential backoff logic without mocking timers means your test suite actually waits out the real delays — for a backoff sequence of 100ms, 200ms, 400ms, that’s not catastrophic, but for anything with longer delays or more retries, real-timer tests turn into slow, flaky additions to your suite. Fake timers combined with await vi.runAllTimersAsync() (or the equivalent in Jest, jest.runAllTimersAsync()) let you advance virtual time instantly while still correctly flushing the microtask queue between each timer tick, which matters because naive timer mocking that doesn’t also handle the microtask queue will produce assertions that fire before your retried promises have actually resolved.

More mistakes worth knowing about

Mistake 6 — swallowing errors with an empty catch block

async function cleanupTestData(): Promise<void> {
  try {
    await deleteTestUser('temp-user');
  } catch {
    // silently ignored
  }
}

Sometimes this is genuinely intentional — cleanup code that shouldn’t fail a test just because a resource was already gone — but it’s frequently copy-pasted into places where it hides a real bug. At minimum, log what was swallowed, even in cleanup code, so a pattern of repeated failures shows up somewhere instead of vanishing entirely:

async function cleanupTestData(): Promise<void> {
  try {
    await deleteTestUser('temp-user');
  } catch (error) {
    console.warn('Cleanup failed (non-fatal):', error instanceof Error ? error.message : error);
  }
}

Mistake 7 — returning a promise from inside a try block and expecting the catch to still work

// Subtle bug: returning the promise directly means the function returns
// BEFORE the promise settles, and if it rejects, the catch here never runs —
// the rejection propagates to the caller of getConfig, not this catch
async function getConfig(): Promise<Config> {
  try {
    return fetchConfigFromServer(); // no 'await' — just returning the promise
  } catch (error) {
    console.error('Config fetch failed, using default');
    return defaultConfig;
  }
}

This is a genuinely subtle one because the code looks reasonable at a glance, and it “mostly works” in the sense that a successful config fetch behaves fine. The bug only appears on failure: because fetchConfigFromServer() is returned directly without an await, the try block’s synchronous execution completes successfully (returning a pending promise is not a synchronous error), so the catch block never triggers, and the rejection surfaces later at whatever code called getConfig() and awaited it — code that may have no idea a default config was supposed to be the fallback.

// Fixed — await inside try so a rejection is actually caught here
async function getConfig(): Promise<Config> {
  try {
    return await fetchConfigFromServer();
  } catch (error) {
    console.error('Config fetch failed, using default');
    return defaultConfig;
  }
}

The rule of thumb worth internalizing: if you want a try/catch around a promise-returning call to actually catch rejections from it, you need await in front of that call inside the try block. Returning a promise directly — even from inside a try — hands the rejection off to whoever’s holding the promise you returned, not to your local catch.

Mistake 8 — assuming async functions run in a separate thread

Covered conceptually above, but worth stating as its own mistake because of how it manifests: engineers sometimes reach for multiple concurrent async operations expecting genuine parallel CPU execution, and then get confused when a CPU-heavy synchronous block inside one async function freezes everything else — including unrelated async operations that have nothing to do with the heavy computation. JavaScript’s concurrency model interleaves I/O-bound waiting, but it does not parallelize CPU-bound work across cores unless you explicitly reach for Worker threads. A test framework generating a large synthetic dataset synchronously inside an otherwise async setup function will block every other concurrent test in that same worker process for the duration of that computation.

Mistake 9 — not handling the process-level unhandledRejection event in Node-based frameworks

// In a global setup or framework bootstrap file
process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled promise rejection detected:', reason);
  // Depending on your framework's philosophy, you may want to fail the run here
  process.exitCode = 1;
});

Most modern test runners (Playwright’s runner, Jest, Vitest) already surface unhandled rejections as failures by default, but if you’re building custom tooling around a test framework — a custom global setup, a reporting plugin, a data seeding script that runs outside the test runner’s own process — it’s worth explicitly listening for this event rather than assuming Node’s default behavior (which, since Node 15, is to crash the process) is what you want. In CI, an uncaught rejection that crashes the whole process before test results get written out anywhere is a debugging nightmare compared to one that’s caught, logged clearly, and attributed to a specific test or setup step.

Mistake 10 — using async/await inside Array.prototype.filter and expecting it to filter correctly

// Broken — filter's callback returning a Promise is always truthy,
// so this "filters" nothing at all; every item passes
async function getActiveUsers(userIds: string[]): Promise<string[]> {
  return userIds.filter(async (id) => {
    const user = await fetchUser(id);
    return user.isActive;
  });
}

// Correct — resolve all the async checks first, then filter synchronously
async function getActiveUsers(userIds: string[]): Promise<string[]> {
  const users = await Promise.all(userIds.map(id => fetchUser(id)));
  return userIds.filter((_, index) => users[index].isActive);
}

Same root cause as the broken forEach example earlier — filter‘s callback return value is evaluated for truthiness synchronously, and a Promise object (whatever it eventually resolves to) is always truthy, regardless of whether it resolves to true or false. This one is particularly nasty because it doesn’t throw, doesn’t produce an obviously wrong result count in every case, and can pass code review because the code visually looks correct — it takes actually tracing through what filter does with a callback’s return value to spot it.

Generics and async functions together

Combining generics with async functions is where TypeScript’s type system genuinely pays for itself in a large framework, because it lets you write one reusable async utility instead of duplicating near-identical functions for every data type your framework touches.

async function pollUntil<T>(
  fn: () => Promise<T>,
  predicate: (result: T) => boolean,
  options: { intervalMs: number; timeoutMs: number }
): Promise<T> {
  const start = Date.now();
  while (Date.now() - start < options.timeoutMs) {
    const result = await fn();
    if (predicate(result)) {
      return result;
    }
    await wait(options.intervalMs);
  }
  throw new Error(`pollUntil timed out after ${options.timeoutMs}ms`);
}

// Usage — T is inferred as OrderStatus from the fn's return type
const finalStatus = await pollUntil(
  () => apiClient.get<OrderStatus>(`/orders/${orderId}/status`),
  (status) => status.state === 'SHIPPED',
  { intervalMs: 500, timeoutMs: 10000 }
);

This pollUntil pattern is genuinely one of the most useful generic async utilities you can add to a test framework — it replaces a dozen bespoke polling loops scattered across a codebase (waiting for an order to ship, a background job to complete, an async index to update) with one typed, reusable, well-tested function, and it’s a strong thing to bring up in a system-design-style interview question about building test infrastructure from scratch.

Async/await and dependency injection in test frameworks

Larger TypeScript automation frameworks — the kind built for a multi-team organization rather than a single project — often introduce a lightweight dependency injection or fixture composition layer, and async initialization interacts with it in ways worth understanding.

interface TestFixtures {
  apiClient: ApiTestClient;
  authenticatedPage: Page;
}

// Playwright's fixture model handles async setup natively
const test = base.extend<TestFixtures>({
  apiClient: async ({}, use) => {
    const client = await ApiTestClient.create(process.env.BASE_URL!);
    await use(client);
    await client.dispose();
  },
  authenticatedPage: async ({ page, apiClient }, use) => {
    const token = await apiClient.login('test-user', 'test-pass');
    await page.addInitScript((t) => {
      window.localStorage.setItem('authToken', t);
    }, token);
    await use(page);
  },
});

Playwright’s fixture system is a good illustration of async/await composing cleanly through a dependency graph — authenticatedPage depends on apiClient, and because both fixture functions are async, Playwright awaits apiClient‘s setup fully before authenticatedPage‘s setup function even starts, without you writing any explicit ordering logic. That correctness guarantee — that dependent fixtures are fully resolved before dependents run — is exactly what the underlying await mechanism gives you for free, and it’s worth pointing out explicitly in an interview if asked to justify a fixture-based architecture over manual setup/teardown functions.

A note on Cypress, since it’s a common comparison point

If your background includes Cypress alongside Playwright and Selenium, it’s worth flagging one genuine divergence: Cypress commands are not standard promises, and Cypress explicitly discourages using async/await with its command chain, because its internal queuing and retry mechanism doesn’t interoperate cleanly with native promise resolution timing. Cypress’s own documentation is explicit about this — commands are enqueued and run later, not executed and awaited in the way a real Promise is. This trips up engineers moving from Playwright (where async/await is the entire API surface) to Cypress (where .then() chaining is the idiomatic pattern, and top-level await on a Cypress command is actively discouraged), and it’s a good example of why understanding what async/await is actually sugar over — a genuine Promise — matters more than memorizing the keywords, because not every “looks async” API is built on real promises underneath.

Compiler target and lib considerations that affect async/await behavior

A handful of tsconfig.json settings change how async/await actually compiles and what runtime features are assumed available, and getting these wrong produces confusing runtime errors that have nothing to do with your test logic.

  • target below ES2017 — TypeScript compiles async/await down to a state-machine implementation using generators rather than relying on native engine support. This mostly works transparently, but stack traces through the compiled output can be noticeably harder to read when debugging a failure, since you’re looking at generated helper functions rather than your original async function.
  • lib not including a recent enough es2018 or later — utility types and methods like Promise.allSettled (ES2020) or Promise.any (ES2021) won’t be recognized by the compiler even if the runtime actually supports them, producing a type error on code that would otherwise execute fine.
  • downlevelIteration — relevant if you’re using for await...of against async iterables while targeting an older JS version; without it, the compiled iteration logic can silently behave incorrectly for certain iterable shapes.

For a Playwright/TypeScript framework specifically, targeting ES2022 or later with "lib": ["ES2022", "DOM"] is a safe modern default that avoids nearly all of these compatibility questions, and it’s worth checking your framework’s tsconfig.json against this rather than inheriting whatever default a scaffolding tool generated two years ago.

Debugging async/await code effectively

Reading async stack traces

Modern V8 (and therefore modern Node and Chromium-based Playwright runs) preserves reasonably useful async stack traces across await boundaries, which was a genuine pain point in older JavaScript engines. When a test fails inside a deeply nested chain of awaited async function calls, the stack trace will generally show you the chain of async calls that led to the failure, not just the innermost frame — but it’s worth knowing that this “async stack trace stitching” has a small performance cost and is sometimes disabled or reduced in production builds, so don’t be surprised if a CI environment’s stack traces look thinner than what you see running the same test locally in watch mode.

Using debugger statements with async code

async function debugThisFlow(page: Page): Promise<void> {
  await page.goto('/checkout');
  debugger; // pauses here when running with an attached debugger (e.g. Playwright's --debug flag)
  await page.click('#place-order');
}

A debugger statement placed after an await pauses execution exactly where you’d expect — the async nature of the surrounding function doesn’t complicate this the way it sometimes does with breakpoints inside .then() callbacks in older debugging tooling, where breakpoint behavior across chained callbacks used to be inconsistent between browsers.

Logging with timestamps to diagnose ordering bugs

function logWithTime(label: string): void {
  console.log(`[${new Date().toISOString()}] ${label}`);
}

async function suspiciousFlow(): Promise<void> {
  logWithTime('before first await');
  await stepOne();
  logWithTime('after first await, before second');
  await stepTwo();
  logWithTime('after second await');
}

Unglamorous, but genuinely effective for the class of bug where you suspect operations are resolving out of order or a race condition is at play — timestamped logs across await boundaries, correlated against logs from whatever your code is actually racing against, will usually surface the actual ordering faster than staring at the code trying to reason about it purely from reading, especially once Promise.all and concurrent fixture setup are involved and the actual execution order is genuinely non-obvious just from source order.

Migrating a legacy callback-based or chained-promise test helper to async/await

Since a meaningful amount of test automation work is maintaining and gradually modernizing existing frameworks rather than greenfield builds, it’s worth walking through an actual migration, because the “before” code below is a realistic shape for something that’s survived several years of incremental changes.

// Before — callback-based, using an old-style Selenium wrapper
function loginLegacy(driver, username, password, callback) {
  driver.findElement(By.id('username'), function (err, el) {
    if (err) return callback(err);
    el.sendKeys(username, function (err) {
      if (err) return callback(err);
      driver.findElement(By.id('password'), function (err, el2) {
        if (err) return callback(err);
        el2.sendKeys(password, function (err) {
          if (err) return callback(err);
          driver.findElement(By.id('submit'), function (err, btn) {
            if (err) return callback(err);
            btn.click(callback);
          });
        });
      });
    });
  });
}
// After — async/await, same behavior, dramatically flatter and more maintainable
async function login(driver: WebDriver, username: string, password: string): Promise<void> {
  const usernameField = await driver.findElement(By.id('username'));
  await usernameField.sendKeys(username);

  const passwordField = await driver.findElement(By.id('password'));
  await passwordField.sendKeys(password);

  const submitButton = await driver.findElement(By.id('submit'));
  await submitButton.click();
}

The “before” version is what’s often called callback hell or the pyramid of doom, and it’s a genuinely fair thing to show in an interview if asked why async/await mattered enough to become the dominant pattern — the after version isn’t just shorter, it’s linearly readable top to bottom, each error path is handled by one enclosing try/catch instead of manual error-first checking at every single step, and adding a new step in the middle of the flow means inserting one line instead of restructuring a nested nightmare of nested callbacks.

Performance considerations specific to test suites

Awaiting too eagerly inside beforeEach hooks

// Runs full setup before every single test, even tests that don't need all of it
beforeEach(async ({ page }) => {
  await seedDatabase();
  await warmCache();
  await createTestUser();
  await page.goto('/');
});

A beforeEach hook runs before every test in its scope, so anything awaited inside it that isn’t actually needed by every test is a tax paid on every single test run. This isn’t strictly an async/await mistake — it’s a test architecture mistake — but async/await’s ease of use is part of why it happens: because sequential awaits read so cleanly, it’s tempting to just keep adding setup steps to a shared hook rather than pushing test-specific setup into the individual tests that actually need it, or into more granular fixtures that only run when referenced.

Global setup versus per-test setup for expensive async operations

Operations that are genuinely global and don’t need to be repeated per test — starting a test database container, running migrations, authenticating a service account used across the whole suite — belong in a globalSetup function (Playwright, Jest, and most modern runners support this concept explicitly), not in a beforeEach that re-runs the same expensive async work hundreds of times across a large suite. I’ve seen suite runtimes drop from twelve minutes to under three purely from moving a genuinely one-time async database seed operation out of a per-test hook and into global setup, with zero change to the actual test logic or coverage.

More interview questions worth preparing

“What does Promise.resolve(x) do if x is already a Promise?”

It returns the same promise, not a new wrapped one — Promise.resolve is idempotent with respect to already-thenable values. This matters when writing generic utility functions that accept either a plain value or a promise and want to normalize both into a promise without accidentally double-wrapping.

“Can an async function return a non-Promise value directly, and what happens?”

Yes — you write return 'done' inside an async function, and TypeScript/JavaScript automatically wraps that plain value in a resolved promise. The caller still needs to await it or otherwise unwrap it; the automatic wrapping happens on the way out of the function, not as an exemption from needing await at the call site.

“What’s the difference between throwing inside an async function and calling Promise.reject?”

async function a(): Promise<never> {
  throw new Error('boom'); // becomes a rejected promise automatically
}

async function b(): Promise<never> {
  return Promise.reject(new Error('boom')); // explicit, functionally equivalent here
}

Inside an async function, both are equivalent — a thrown error is automatically converted into a rejected promise, which is one of the genuine ergonomic wins async/await has over manual promise construction, where forgetting to call reject explicitly in a new Promise((resolve, reject) => ...) executor is a classic bug. It’s worth noting the two are only equivalent inside an async function specifically — a synchronous function that throws does not automatically become a rejected promise; it throws a regular synchronous exception, which is a different failure mode entirely for whatever’s calling it.

“How would you implement a simple concurrency limiter for a batch of async operations?”

async function runWithConcurrencyLimit<T>(
  tasks: (() => Promise<T>)[],
  limit: number
): Promise<T[]> {
  const results: T[] = new Array(tasks.length);
  let index = 0;

  async function worker(): Promise<void> {
    while (index < tasks.length) {
      const current = index++;
      results[current] = await tasks[current]();
    }
  }

  const workers = Array.from({ length: limit }, () => worker());
  await Promise.all(workers);
  return results;
}

This one separates candidates who understand async/await mechanically from candidates who understand it well enough to build infrastructure with it — a naive Promise.all(tasks.map(fn)) fires everything concurrently with no limit, which is exactly what causes rate-limit errors against a real API; this worker-pool pattern caps true concurrency to limit while still using plain async/await and array indexing rather than a third-party queue library, which is exactly the kind of question that shows up when interviewing for an SDET Lead or Automation Architect role where you’re expected to build framework infrastructure, not just write individual tests.

Closing thoughts, revisited

Everything in this article reduces to a small number of habits that separate reliable async TypeScript code from code that merely looks correct: await every promise-returning call unless you have a deliberate reason not to, choose the right Promise combinator for whether you need all results, the first result, or every settled outcome regardless of failure, keep your catch blocks honest about the unknown-typed error rather than casting to any, and remember that async/await is sugar over Promise, not a replacement for understanding what a Promise actually does. None of that is exotic knowledge. It’s the difference between a test suite that’s trusted and one that gets a Slack message that says “just rerun it, it’s probably flaky” — which, if you’ve spent any real time in QA and test automation, you already know is one of the most quietly expensive sentences a team can normalize.

AbortController and cancelling in-flight async operations

A gap in early promise-based JavaScript that took years to get a proper native solution: how do you cancel an async operation that’s already in flight? Promise itself has no cancellation mechanism — once you’ve created one, it will eventually settle, and there’s no built-in way to tell it to stop early. AbortController is the standard answer, and it comes up constantly in test automation for exactly the scenario you’d expect: a test times out or is torn down early, and any in-flight network requests it kicked off need to actually stop, not keep running in the background consuming resources and potentially polluting the next test’s environment.

async function fetchWithTimeout<T>(url: string, timeoutMs: number): Promise<T> {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, { signal: controller.signal });
    clearTimeout(timeoutId);
    if (!response.ok) {
      throw new Error(`Request failed with status ${response.status}`);
    }
    return response.json() as Promise<T>;
  } catch (error) {
    if (error instanceof Error && error.name === 'AbortError') {
      throw new Error(`Request to ${url} timed out after ${timeoutMs}ms`);
    }
    throw error;
  }
}

Note the difference from the Promise.race-based timeout pattern shown earlier in the interview questions section: Promise.race stops your code from waiting any longer, but it does not actually cancel the underlying fetch call — that request keeps running on the network and the server keeps processing it, you’ve just stopped listening for the result. AbortController genuinely cancels the underlying operation (assuming the API you’re calling supports the abort signal, which fetch does natively). This distinction is worth being precise about in an interview — “how do you implement a timeout” and “how do you implement cancellation” sound similar but are different problems, and conflating them is a common gap.

Passing AbortSignal through Playwright and custom API clients

class ApiTestClient {
  async get<T>(path: string, signal?: AbortSignal): Promise<T> {
    const response = await fetch(`${this.baseUrl}${path}`, { signal });
    return response.json() as Promise<T>;
  }
}

test('cancels a slow request cleanly on teardown', async () => {
  const controller = new AbortController();
  const client = new ApiTestClient(baseUrl);

  const requestPromise = client.get('/slow-report', controller.signal);
  controller.abort();

  await expect(requestPromise).rejects.toThrow();
});

Building request cancellation into a custom API test client from the start — accepting an optional AbortSignal parameter on every method — is a small design decision that pays off disproportionately once your suite is large enough that hung requests from crashed or timed-out tests become a real source of resource leaks in CI, particularly in long-running nightly regression suites where dozens of tests execute over hours and a handful of genuinely stuck requests can quietly accumulate open connections.

Async/await compared with Java’s CompletableFuture and C#’s Task in more depth

Given how much of the QA and SDET world moves between TypeScript, Java, and C# depending on the project, it’s worth a closer side-by-side than the earlier brief comparison, because the conceptual mapping is close but not perfect, and interviewers who know you work across stacks will sometimes probe exactly where the mapping breaks down.

ConceptTypeScriptJavaC#
Async return wrapperPromise<T>CompletableFuture<T>Task<T>
Await keywordawait.get() / .join() (blocking) or thenApply chains (non-blocking)await
Run multiple concurrentlyPromise.all()CompletableFuture.allOf()Task.WhenAll()
First to completePromise.race()CompletableFuture.anyOf()Task.WhenAny()
Underlying concurrency modelSingle-threaded event loopThread pool (ForkJoinPool by default)Thread pool via SynchronizationContext / ThreadPool
True parallelism for CPU workNo (single thread; needs Worker threads)Yes (genuine OS threads)Yes (genuine OS threads)

The row that trips people up most in interviews is the underlying concurrency model. Java’s CompletableFuture and C#’s Task are backed by real OS-level thread pools — when you run several of them concurrently, you can get genuine parallel execution across CPU cores, not just interleaved I/O waiting. TypeScript’s Promise, running in Node.js or a browser, is fundamentally single-threaded — Promise.all gives you concurrent I/O waiting (multiple network requests genuinely in flight at once, because the waiting happens outside the JS thread, in the runtime’s I/O layer), but it does not give you parallel CPU execution. If you say “Promise.all runs things in parallel” in an interview without qualifying that as I/O concurrency rather than CPU parallelism, a sharp interviewer familiar with both ecosystems will follow up specifically on that distinction, because it’s the kind of detail that separates someone who’s used the syntax from someone who understands what’s actually happening underneath it.

Blocking vs non-blocking await equivalents in Java

// Java — CompletableFuture, non-blocking chain (closer to TS async/await spirit)
CompletableFuture<String> future = fetchUserNameAsync(userId)
    .thenApply(name -> name.toUpperCase());

// Java — blocking .get(), defeats the purpose of async but common in test code
String name = fetchUserNameAsync(userId).get(); // blocks the calling thread

A detail worth knowing if you’re working across a Java-based REST Assured suite and a TypeScript Playwright suite in the same organization: Java test code that calls .get() on a CompletableFuture is blocking that thread until the future completes — there’s no TypeScript equivalent of “accidentally blocking” in this way, because await never blocks the underlying thread, it only pauses the specific async function. This is actually a point in TypeScript/JavaScript’s favor for test framework design: it’s structurally harder to accidentally write blocking code in an async context, whereas Java test code that mixes blocking .get() calls into what’s meant to be a concurrent test suite is a genuinely common performance bug.

Async/await interacting with ORMs and database layers in API tests

API test suites that seed or verify data directly against a database (bypassing the application’s own API for setup speed) commonly use an async ORM like Prisma or TypeORM, and the same principles from earlier apply directly, with a few database-specific wrinkles.

import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

async function seedOrder(userId: string, productId: string): Promise<Order> {
  return prisma.order.create({
    data: { userId, productId, status: 'PENDING' },
  });
}

async function verifyOrderStatus(orderId: string, expectedStatus: string): Promise<void> {
  const order = await prisma.order.findUniqueOrThrow({ where: { id: orderId } });
  expect(order.status).toBe(expectedStatus);
}

Transactions and async/await

async function seedOrderWithLineItems(order: OrderInput, items: LineItemInput[]): Promise<Order> {
  return prisma.$transaction(async (tx) => {
    const createdOrder = await tx.order.create({ data: order });
    await tx.lineItem.createMany({
      data: items.map(item => ({ ...item, orderId: createdOrder.id })),
    });
    return createdOrder;
  });
}

Prisma’s $transaction callback pattern is a good real-world example of a higher-order function accepting an async callback — the transaction wrapper itself manages committing or rolling back based on whether the async function you pass in resolves or rejects, which means throwing inside that callback (or letting an awaited call inside it reject) automatically rolls back everything the transaction touched, without you writing explicit rollback logic. Not understanding this — writing separate, un-transacted await calls for what should be one atomic seed operation — is a common cause of half-seeded test data when one insert in a multi-step setup fails partway through, leaving your database in an inconsistent state that then causes confusing failures in unrelated tests that happen to run afterward against the same test database.

Closing database connections — a common async cleanup gap

afterAll(async () => {
  await prisma.$disconnect();
});

Forgetting to await (or even call) $disconnect() in a global teardown is a classic cause of a test process that finishes all its assertions successfully but then hangs indefinitely instead of exiting, because Node won’t exit while there are open handles — including open database connections — keeping the event loop alive. If you’ve seen a CI job where the tests all report passing in the log output but the job itself times out anyway, an un-awaited or missing disconnect call in teardown is one of the first things worth checking.

Async/await in visual regression and screenshot-based testing

test('dashboard renders correctly', async ({ page }) => {
  await page.goto('/dashboard');
  await page.waitForLoadState('networkidle');
  await expect(page).toHaveScreenshot('dashboard.png', {
    maxDiffPixelRatio: 0.01,
  });
});

Screenshot comparison introduces its own timing sensitivity on top of everything already covered: a screenshot taken before an animation finishes, before a font finishes loading, or before a lazy-loaded image resolves will produce a false-positive visual diff that has nothing to do with an actual regression and everything to do with an async operation the test didn’t wait for. waitForLoadState('networkidle') helps but isn’t a complete solution — CSS transitions and JS-driven animations don’t necessarily correlate with network activity at all, which is why serious visual regression setups often disable animations globally via CSS injection rather than relying purely on async waits to outlast them:

test.beforeEach(async ({ page }) => {
  await page.addStyleTag({
    content: `*, *::before, *::after {
      animation-duration: 0s !important;
      transition-duration: 0s !important;
    }`,
  });
});

This is a good example of a case where the correct fix for an async-timing-flavored flakiness problem isn’t “await something longer” — it’s removing the source of async variability entirely, which is often the more durable solution than chasing ever-longer or more elaborate waits.

Async/await with WebSockets and real-time features

WebSocket-based features — live notifications, real-time dashboards, chat — don’t fit the request/response promise model as cleanly, because a WebSocket connection is long-lived and can emit multiple messages, not resolve once with a single value. Testing them typically means wrapping the event-based API in a promise that resolves on a specific expected message:

function waitForMessage(ws: WebSocket, predicate: (data: unknown) => boolean, timeoutMs = 5000): Promise<unknown> {
  return new Promise((resolve, reject) => {
    const timeout = setTimeout(() => {
      ws.removeEventListener('message', handler);
      reject(new Error('Timed out waiting for expected WebSocket message'));
    }, timeoutMs);

    function handler(event: MessageEvent) {
      const data = JSON.parse(event.data);
      if (predicate(data)) {
        clearTimeout(timeout);
        ws.removeEventListener('message', handler);
        resolve(data);
      }
    }

    ws.addEventListener('message', handler);
  });
}

test('receives an order-confirmed notification', async () => {
  const ws = new WebSocket('wss://api.example.com/notifications');
  await new Promise(resolve => ws.addEventListener('open', resolve));

  await placeOrder(orderId);

  const message = await waitForMessage(ws, (data: any) => data.type === 'ORDER_CONFIRMED');
  expect(message).toMatchObject({ orderId });

  ws.close();
});

This is a genuinely useful pattern to have ready for interviews that probe beyond basic HTTP testing — it demonstrates manually constructing a Promise from a callback-based, event-emitting API (the WebSocket message event), which is the same underlying skill as the earlier setTimeout-wrapping example, applied to a case where you also need cleanup logic (removing the event listener) regardless of whether the promise resolves or times out, to avoid leaking listeners across tests.

Debouncing and throttling considerations in async test assertions

Applications that debounce user input — a search box that waits 300ms after the last keystroke before firing a request — introduce a specific async testing challenge: typing text and immediately asserting on search results will reliably fail, not because of a real bug, but because the debounce delay hasn’t elapsed yet.

test('search returns filtered results', async ({ page }) => {
  await page.fill('#search-input', 'laptop');
  // Wrong: asserting immediately races the debounce timer
  // await expect(page.locator('.result-item')).toHaveCount(4);

  // Correct: wait for the debounce window, or better, wait for a specific
  // signal that the search actually completed (a loading spinner disappearing,
  // a network response, a data-testid flag the app sets when done)
  await page.waitForResponse(resp => resp.url().includes('/api/search'));
  await expect(page.locator('.result-item')).toHaveCount(4);
});

The instinct to reach for a flat await page.waitForTimeout(500) here is understandable but it’s the weakest possible fix — it either doesn’t wait long enough under load (bringing back the flakiness) or wastes time waiting longer than necessary on a fast run, and it hardcodes a debounce value that’s likely to drift out of sync with whatever the application code actually uses. Waiting for the actual network response, or a UI state change the application deliberately exposes, is more robust precisely because it’s coupled to what genuinely determines readiness rather than to a guessed duration — this is the same underlying principle as the animation-disabling fix in the visual regression section: address the actual source of async uncertainty rather than papering over it with a longer wait.

Contract testing and async schema validation

import { z } from 'zod';

const OrderResponseSchema = z.object({
  id: z.string().uuid(),
  status: z.enum(['PENDING', 'SHIPPED', 'DELIVERED']),
  total: z.number().positive(),
});

async function fetchAndValidateOrder(orderId: string): Promise<z.infer<typeof OrderResponseSchema>> {
  const response = await apiClient.get(`/orders/${orderId}`);
  return OrderResponseSchema.parse(response);
}

test('order response matches the expected contract', async () => {
  await expect(fetchAndValidateOrder(existingOrderId)).resolves.toMatchObject({
    status: 'PENDING',
  });
});

Combining a runtime schema validator like Zod with async API calls gives you both a type (via z.infer, so TypeScript knows the shape at compile time) and a runtime check (so a genuinely malformed response fails loudly with a specific validation error instead of causing a confusing downstream assertion failure three lines later). This pattern shows up increasingly in contract testing setups where the goal is catching a backend team’s breaking API change before it reaches a consumer, and it’s worth knowing as an example of async/await composing with a validation library rather than just with HTTP clients directly.

Async/await in accessibility testing

import AxeBuilder from '@axe-core/playwright';

test('dashboard has no critical accessibility violations', async ({ page }) => {
  await page.goto('/dashboard');
  const results = await new AxeBuilder({ page }).analyze();
  const critical = results.violations.filter(v => v.impact === 'critical');
  expect(critical).toHaveLength(0);
});

Accessibility scanning tools like axe-core run asynchronously against the current DOM state, which means the same page-readiness concerns from earlier apply directly — scanning before the page has finished rendering dynamic content produces an incomplete or misleading scan, not an error, which makes it a particularly easy category of test to get subtly wrong without noticing, since a scan that runs too early will often report fewer violations than actually exist rather than failing outright.

Mobile testing with Appium and async/await in TypeScript

import { remote } from 'webdriverio';

async function launchApp(): Promise<WebdriverIO.Browser> {
  return remote({
    capabilities: {
      platformName: 'Android',
      'appium:automationName': 'UiAutomator2',
      'appium:app': '/path/to/app.apk',
    },
  });
}

test('login succeeds on Android', async () => {
  const driver = await launchApp();
  const usernameField = await driver.$('~username-input');
  await usernameField.setValue('testuser');
  const loginButton = await driver.$('~login-button');
  await loginButton.click();
  const dashboard = await driver.$('~dashboard-title');
  await expect(dashboard).toBeDisplayed();
  await driver.deleteSession();
});

WebdriverIO’s async API for Appium-based mobile testing follows the same promise-based conventions as Playwright, and everything covered above — missing awaits causing races, the value of Promise.all for independent setup steps, proper error typing in catch blocks — transfers directly. The one meaningful difference worth knowing: mobile automation frequently has higher and more variable latency than web automation (app launch time, device or emulator performance variance, network conditions on real device farms), which means timeout tuning and retry logic built around the async patterns discussed here matter proportionally more in mobile suites than in web suites running against a fast local or CI browser instance.

A troubleshooting reference: symptom to likely async cause

SymptomLikely async/await cause
Test passes locally, fails intermittently in CIMissing await on an action, or a fixed-duration wait that’s too short under CI load
Assertion checks stale/previous stateMissing await before the assertion, or asserting before a debounced/throttled operation completes
Test suite hangs and never exitsUnclosed async resource in teardown — database connection, browser context, open WebSocket, unresolved timer
Promise.all rejects but you can’t tell which call failedShould have used Promise.allSettled, or wrap each promise with its own error context before combining
filter()/forEach() with async callback “doesn’t work”These methods don’t await callback promises — use map() + Promise.all() instead
catch block errors on error.message with a type errorerror is typed unknown under strict mode — needs an instanceof or type guard
Retry logic doesn’t actually wait between attemptsMissing await on the backoff delay promise inside the retry loop
try/catch around an async call doesn’t catch anythingMissing await on the call inside the try block — the promise’s rejection propagates past the local catch

Glossary of terms used throughout this article

  • Promise — an object representing the eventual result (or failure) of an asynchronous operation, with three states: pending, fulfilled, rejected.
  • Microtask queue — the queue that holds promise callbacks (including code after await) and is fully drained after every macrotask, before the next macrotask runs.
  • Macrotask (task) queue — the queue holding setTimeout, setInterval, and I/O callbacks; only one macrotask runs per event loop iteration.
  • Thenable — any object with a .then() method, which Promise combinators and await will treat as a promise even if it isn’t literally a native Promise instance.
  • Unhandled rejection — a rejected promise with no .catch() or enclosing try/catch anywhere in its chain, which Node.js treats as a fatal error by default since Node 15.
  • Awaited<T> — a TypeScript utility type that extracts the resolved value type from a Promise type, correctly unwrapping nested promises.
  • Race condition — a bug where correctness depends on the relative timing of two or more asynchronous operations, and that timing isn’t guaranteed.

Final professional’s take, extended

If you take one thing from this entire piece into your next code review or your next interview, make it this: async/await is not the hard part of asynchronous TypeScript. The hard part — the part that actually separates a senior automation engineer from someone who’s memorized the syntax — is the discipline of treating every single promise-returning expression as a decision point. Await it. Combine it deliberately with others. Mark it explicitly as intentionally unhandled. Never let it pass silently, because silent is exactly what a missing await looks like right up until the one time in six it doesn’t work, in a CI run at 2 AM, on the one build that happened to be gating a release.

Advanced typing patterns for async functions

Function overloads on async functions

async function getUser(id: string): Promise<User>;
async function getUser(id: string, includeOrders: true): Promise<UserWithOrders>;
async function getUser(id: string, includeOrders = false): Promise<User | UserWithOrders> {
  const user = await apiClient.get<User>(`/users/${id}`);
  if (!includeOrders) {
    return user;
  }
  const orders = await apiClient.get<Order[]>(`/users/${id}/orders`);
  return { ...user, orders };
}

// Call sites get the correctly narrowed return type based on the second argument
const basicUser = await getUser('u-1'); // User
const fullUser = await getUser('u-1', true); // UserWithOrders

Overloads on async functions work exactly the way they do on synchronous ones — you declare the possible signatures first, then a single implementation signature that’s broad enough to cover all of them, and TypeScript picks the right overload based on the call site’s arguments. This is a genuinely useful pattern for a shared API test client’s helper methods, where a single underlying function serves multiple call shapes and you want callers to get precise typing rather than a broad union type that forces them to narrow it manually every time they call it.

Conditional types combined with Awaited

type UnwrapApiResponse<T> = T extends Promise<{ data: infer D }> ? D : never;

async function fetchOrders(): Promise<{ data: Order[]; meta: { total: number } }> {
  const response = await apiClient.get('/orders');
  return response;
}

type OrdersOnly = UnwrapApiResponse<ReturnType<typeof fetchOrders>>;
// OrdersOnly is Order[]

Conditional types combined with infer inside a promise-shaped generic constraint let you build framework-level type utilities that extract exactly the piece of a wrapped API response you care about — useful when your backend consistently wraps every response in an envelope ({ data, meta }) and you want a reusable type helper to unwrap just the data portion for typing test assertions, without writing a bespoke type for every single endpoint’s response shape.

Decorators and async methods

function logExecutionTime(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const originalMethod = descriptor.value;

  descriptor.value = async function (...args: unknown[]) {
    const start = performance.now();
    const result = await originalMethod.apply(this, args);
    const duration = performance.now() - start;
    console.log(`${propertyKey} took ${duration.toFixed(1)}ms`);
    return result;
  };

  return descriptor;
}

class OrdersApiClient {
  @logExecutionTime
  async fetchOrder(orderId: string): Promise<Order> {
    return this.get(`/orders/${orderId}`);
  }
}

A decorator wrapping an async method needs its replacement function to also be async (or otherwise return a promise) if it wants to preserve the original method’s async contract — a decorator that forgets this and returns a plain synchronous value silently breaks every caller that awaits the decorated method, because now they’re awaiting a plain value wrapped in an auto-resolved promise instead of getting the timing behavior they expect. This pattern (execution-time logging via decorator) is genuinely useful for identifying which API calls are the slow ones in a large test suite without manually adding timing code to every single client method.

Streaming and large payload handling with async iterators

Beyond the paginated API example covered earlier, async iterators show up in a second common test automation context: processing large file downloads or large response bodies without loading the entire payload into memory at once, which matters for tests validating export functionality (CSV exports, report generation, bulk data downloads) where the response can be tens or hundreds of megabytes.

async function countCsvRows(response: Response): Promise<number> {
  const reader = response.body!.getReader();
  const decoder = new TextDecoder();
  let rowCount = 0;
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('\n');
    buffer = lines.pop() ?? '';
    rowCount += lines.length;
  }

  return rowCount;
}

test('exported CSV contains the expected number of rows', async () => {
  const response = await fetch('/api/export/orders.csv');
  const rowCount = await countCsvRows(response);
  expect(rowCount).toBe(expectedOrderCount + 1); // +1 for the header row
});

This pattern — reading a ReadableStream chunk by chunk via reader.read(), each call of which returns a promise — is the streaming equivalent of the paginated async generator shown earlier, and it’s worth knowing specifically because loading a large export response fully into memory via a plain response.text() call before processing it, while functionally simpler, can genuinely cause out-of-memory failures in CI runners with constrained memory limits when the payload is large enough, which is exactly the kind of test infrastructure bug that’s invisible until your data volume grows past whatever it was when the test was first written.

Memory considerations with long-running async operations

Test suites that run for hours (nightly regression, load-adjacent smoke tests looping continuously) can leak memory through async code in ways that are easy to miss in a short-lived test run. The most common pattern: event listeners registered inside an async setup function that are never removed, each one holding a closure over test-specific data that should have been garbage collected once that test finished.

// Leaky — listener added per test, never removed
test('processes a notification', async ({ page }) => {
  page.on('response', (response) => {
    if (response.url().includes('/notifications')) {
      console.log('Notification response:', response.status());
    }
  });
  await triggerNotification();
});

// Fixed — remove the listener explicitly, or scope it so it's naturally cleaned up
test('processes a notification', async ({ page }) => {
  const handler = (response: Response) => {
    if (response.url().includes('/notifications')) {
      console.log('Notification response:', response.status());
    }
  };
  page.on('response', handler);
  try {
    await triggerNotification();
  } finally {
    page.off('response', handler);
  }
});

The finally block here is doing important work independent of whether triggerNotification() succeeds or throws — the listener gets removed either way, which matters because a test that fails partway through shouldn’t leave cleanup undone, and finally runs regardless of whether the try block completed normally or via a thrown/rejected error. This is a good moment to note that try/catch/finally works with async/await exactly the way it works with synchronous code — the finally block runs after the awaited operations in try settle, whether they resolved or rejected, which makes it the natural place for cleanup logic that must run unconditionally.

Parallelizing test suites in CI and how it interacts with async code within a single test

It’s worth being precise about a distinction that gets blurred in casual conversation: “parallel tests” in a CI pipeline (multiple test files or workers running simultaneously, usually across multiple processes or containers) is a completely different kind of parallelism from the concurrent await Promise.all(...) pattern happening inside a single test. Confusing the two leads to bad assumptions about where your suite’s actual bottleneck is.

// playwright.config.ts
export default defineConfig({
  workers: process.env.CI ? 4 : undefined, // undefined = auto-detect based on CPU cores
  fullyParallel: true,
});

Playwright’s workers setting controls genuine OS-level process parallelism — each worker is a separate process, capable of true concurrent execution across CPU cores, unlike the single-threaded concurrency of Promise.all within one test. The two compose: increasing worker count reduces total suite wall-clock time by running more test files simultaneously across processes, while auditing individual tests for unnecessary sequential awaits (as covered in the mistakes section) reduces the wall-clock time of each individual test. Teams that only tune one of these two levers usually leave a meaningful amount of speed on the table — I’ve seen suites where increasing worker count from 2 to 8 in CI cut runtime by 60%, and a subsequent audit of sequential-await patterns inside the slowest individual tests cut the remaining runtime by another 25%, and neither optimization would have surfaced the other’s opportunity.

Security testing scenarios involving async/await

Testing rate limiting

test('enforces rate limit after 100 requests per minute', async () => {
  const requests = Array.from({ length: 105 }, () => apiClient.get('/api/status'));
  const results = await Promise.allSettled(requests);

  const rejected = results.filter(r => r.status === 'rejected');
  const rateLimited = results.filter(
    r => r.status === 'fulfilled' && (r.value as ApiResponse).statusCode === 429
  );

  expect(rateLimited.length).toBeGreaterThan(0);
});

Rate limit testing is a natural fit for Promise.allSettled rather than Promise.all, precisely because you expect — and want — some of these requests to fail. Using Promise.all here would be a genuine mistake, since the entire test would abort on the first 429 response instead of letting you inspect the full pattern of successes and failures across all 105 concurrent requests, which is the actual thing under test.

Testing token expiry and refresh flows

async function withAutoRefresh<T>(fn: () => Promise<T>): Promise<T> {
  try {
    return await fn();
  } catch (error) {
    if (error instanceof ApiError && error.statusCode === 401) {
      await refreshAuthToken();
      return await fn();
    }
    throw error;
  }
}

test('automatically refreshes an expired token and retries the request', async () => {
  await expireCurrentToken();
  const order = await withAutoRefresh(() => apiClient.get<Order>(`/orders/${orderId}`));
  expect(order.id).toBe(orderId);
});

This retry-on-401 pattern is close to production authentication-refresh logic, and it’s a good illustration of catching a specific, narrowly typed error condition (a 401 from a custom ApiError type, checked via instanceof) and handling only that case with a retry, while re-throwing anything else — the alternative, a blanket retry on any caught error, would mask genuine failures like a 500 server error as if they were expected token-refresh scenarios, which is a subtle but real correctness bug in retry logic that isn’t specific enough about which failures actually warrant a retry.

An extended interview question bank

The questions below round out the earlier set — treat this as a broader pool to draw from when prepping for a Lead SDET, QA Architect, or Automation Manager interview where async/await questions are likely to go beyond entry-level syntax checks.

“Why does TypeScript infer unknown instead of any for catch block errors, and why does that matter?”

Because JavaScript allows throwing any value — not just Error instances — the compiler genuinely cannot assume anything about the shape of a caught value without narrowing it first. Typing it as any would silently permit unsafe property access (error.message on a caught string, for instance) that could fail at runtime; unknown forces an explicit check, which is exactly the kind of “make the unsafe thing require deliberate effort” design choice TypeScript’s strict mode is built around.

“What’s a race condition, concretely, in the context of async/await?”

A bug where the correctness of your code depends on the relative order that two or more independently-scheduled async operations complete, and that order isn’t actually guaranteed. A strong answer gives a concrete test automation example — two concurrent API calls that both write to the same record, where whichever one’s response arrives last silently overwrites the other’s changes, with no error thrown at all, which is what makes race conditions specifically dangerous: they often fail silently rather than loudly.

“How would you explain ‘callback hell’ to someone who’s only ever used async/await?”

Show the nested pyramid example from the migration section above — deeply nested callbacks where each async step’s continuation lives inside the previous step’s callback, making error handling, code review, and adding new steps all disproportionately hard as nesting depth grows, and contrast it directly against the flattened async/await version to make the improvement concrete rather than abstract.

“What happens if you await a value that isn’t a Promise?”

async function example() {
  const value = await 42; // perfectly legal
  console.log(value); // 42
}

await works on any value, not just genuine promises — non-promise values are treated as already-resolved, effectively wrapped in Promise.resolve() automatically. This is occasionally useful for writing code that works uniformly whether a helper function is synchronous or asynchronous, without needing to branch on which case you’re in.

“Explain the difference between concurrent and parallel in the context of JavaScript’s async model.”

Concurrent means multiple operations are in progress and making forward progress during overlapping time windows — which is exactly what Promise.all gives you for I/O-bound work, since the actual waiting happens off the JS thread. Parallel specifically means simultaneous execution on separate CPU cores at literally the same instant, which JavaScript’s single-threaded model does not provide without explicitly reaching for Worker threads (or, in Node, worker_threads or child processes). Every Promise.all in a TypeScript test suite is concurrent I/O; none of it is parallel CPU execution unless you’ve deliberately introduced actual threads.

“How do you handle a situation where one async operation in a Promise.all needs a fallback value instead of failing the whole batch?”

const results = await Promise.all([
  fetchInventory().catch(() => ({ items: [], degraded: true })),
  fetchPricing(),
  fetchShipping(),
]);

Attaching a local .catch() to an individual promise before it goes into Promise.all lets that specific operation supply a fallback value on failure without dragging the entire batch down — this is a genuinely elegant middle ground between Promise.all‘s fail-fast behavior and Promise.allSettled‘s need to manually check every result’s status afterward, and it’s worth having ready as an answer because it shows you know .catch() and async/await compose together rather than being mutually exclusive styles.

Frequently asked questions

Does async/await make my code run faster?

No — it doesn’t change the underlying performance characteristics of the operations you’re calling. What it changes is how easy the code is to write correctly, which indirectly affects performance because correct concurrency (using Promise.all where appropriate, avoiding unnecessary sequential awaits) is easier to write and easier to spot in code review when the code is readable async/await rather than nested callbacks or long promise chains.

Can I use async/await in a synchronous forEach loop safely if I don’t care about the order or waiting for completion?

Technically yes, but this is almost never actually what you want in test automation — “I don’t care about waiting for completion” is rarely true when the operations are things like database writes or API calls that subsequent test steps depend on. If you genuinely don’t need to wait, it’s clearer to say so explicitly with a void operator on a map call rather than relying on forEach‘s quiet promise-ignoring behavior, since the latter reads as though it should wait and doesn’t, which is exactly the confusing-mistake shape covered earlier.

Is it bad practice to mix async/await with .then() in the same codebase?

Not inherently — but consistency within a single function or file matters more than a blanket rule against ever using .then(). A common, reasonable pattern is async/await for the primary control flow and an occasional .then() for a short, single-step transformation that doesn’t need its own await. What genuinely hurts readability is mixing the two styles within the same logical block of sequential steps, forcing a reader to track two different mental models simultaneously for what’s conceptually one flow.

Why does my IDE show a “Promise returned is ignored” warning even though my code seems to work?

This is almost certainly the ESLint no-floating-promises rule (or your IDE’s built-in equivalent) correctly flagging a missing await — “seems to work” in this context usually means the race condition hasn’t manifested yet in your local testing, not that there isn’t one. Treat this warning as something to actually fix, not suppress, unless you’ve deliberately decided the promise really is fire-and-forget, in which case an explicit void operator communicates that intent to both the linter and the next engineer reading the code.

Should I always add explicit return type annotations on async functions?

For anything exported and used across multiple files — page objects, API clients, shared test utilities — yes, for the reasons covered earlier: the signature becomes a contract, and inferred types can silently drift as implementations change. For small, local, single-use async functions inside one test file, inference is usually fine and the extra annotation is more ceremony than value.

A closing checklist for reviewing async TypeScript test code

  • Every promise-returning call either has an await, a .then()/.catch(), is deliberately marked with void, or is intentionally collected into a Promise.all/allSettled array.
  • Independent async setup steps use Promise.all instead of sequential awaits.
  • Catch blocks narrow the unknown-typed error before accessing properties on it.
  • try/catch blocks that are meant to catch a rejection actually await the call inside the try, not just return the bare promise.
  • forEach, filter, and map aren’t used with an async callback where the code actually depends on the callback’s returned promise being awaited.
  • Teardown/cleanup code (database disconnects, event listener removal, browser context closing) runs inside a finally block so it executes regardless of test outcome.
  • Fixed-duration waits (waitForTimeout) are used only as a last resort, with a comment explaining why a condition-based wait wasn’t possible.
  • Any exported async utility used across the framework has an explicit return type annotation.

Async/await versus RxJS observables — when reactive streams are the better fit

Some TypeScript test frameworks, particularly ones built on top of Angular applications or using RxJS-based utilities for event stream handling, introduce Observable as a third concurrency abstraction alongside Promise-based async/await. It’s worth understanding the boundary between them, because reaching for the wrong one produces code that technically works but fights the abstraction the whole way.

// Promise / async-await — models a single eventual value
async function fetchLatestPrice(symbol: string): Promise<number> {
  const response = await apiClient.get<{ price: number }>(`/prices/${symbol}`);
  return response.price;
}

// Observable — models a stream of values over time
function priceUpdates(symbol: string): Observable<number> {
  return webSocketSubject.pipe(
    filter(msg => msg.symbol === symbol),
    map(msg => msg.price)
  );
}

A Promise — and by extension, everything async/await does — models exactly one eventual value or one eventual failure. It settles once and is done. An Observable models a stream of values over time, which can emit any number of times, and it’s cancellable natively via unsubscription in a way a bare Promise isn’t. Testing a single API response is squarely async/await territory. Testing a live-updating price feed, a stream of WebSocket messages, or debounced user input over time is squarely observable territory — and trying to force the latter into an await-based shape (for example, converting an observable to a promise via firstValueFrom and only ever looking at the first emitted value) throws away the exact thing that made an observable the right tool in the first place. If your test framework already has RxJS in its dependency tree because the application under test uses Angular, it’s worth knowing when to use its observable-testing utilities (TestScheduler, marble testing) rather than forcing every async test into a Promise-shaped box just because async/await syntax is more familiar.

Building a complete, realistic API test client with retries, timeouts, and typed errors

Most of the individual pieces above have appeared in isolation — a retry helper here, a timeout wrapper there, a typed error narrowing example somewhere else. It’s worth seeing them composed into something closer to what an actual production-grade API test client looks like, because the interview question “walk me through how you’d design an API client for a test framework” is common enough at the Lead/Architect level that having a coherent answer ready is genuinely valuable.

class ApiClientError extends Error {
  constructor(
    message: string,
    public readonly statusCode: number,
    public readonly responseBody: unknown
  ) {
    super(message);
    this.name = 'ApiClientError';
  }
}

class ApiTestClient {
  constructor(
    private readonly baseUrl: string,
    private readonly defaultTimeoutMs = 5000,
    private readonly maxRetries = 2
  ) {}

  private async requestWithRetry<T>(
    path: string,
    init: RequestInit,
    attempt = 0
  ): Promise<T> {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), this.defaultTimeoutMs);

    try {
      const response = await fetch(`${this.baseUrl}${path}`, {
        ...init,
        signal: controller.signal,
      });
      clearTimeout(timeoutId);

      if (response.status >= 500 && attempt < this.maxRetries) {
        await this.backoff(attempt);
        return this.requestWithRetry<T>(path, init, attempt + 1);
      }

      const body = await response.json().catch(() => null);

      if (!response.ok) {
        throw new ApiClientError(
          `Request to ${path} failed with status ${response.status}`,
          response.status,
          body
        );
      }

      return body as T;
    } catch (error) {
      clearTimeout(timeoutId);
      if (error instanceof ApiClientError) {
        throw error;
      }
      if (error instanceof Error && error.name === 'AbortError' && attempt < this.maxRetries) {
        await this.backoff(attempt);
        return this.requestWithRetry<T>(path, init, attempt + 1);
      }
      throw error;
    }
  }

  private backoff(attempt: number): Promise<void> {
    const delayMs = 200 * 2 ** attempt;
    return new Promise(resolve => setTimeout(resolve, delayMs));
  }

  async get<T>(path: string): Promise<T> {
    return this.requestWithRetry<T>(path, { method: 'GET' });
  }

  async post<T>(path: string, body: unknown): Promise<T> {
    return this.requestWithRetry<T>(path, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });
  }
}

Walking through the design decisions in this client is a genuinely good way to demonstrate depth in an interview: a custom typed error class (ApiClientError) that carries the status code and response body rather than a generic Error, so test assertions can check error.statusCode directly with full type safety after an instanceof narrow; retries scoped specifically to 5xx server errors and abort-timeouts, not to 4xx client errors, because retrying a 404 or a 400 won’t ever succeed and just wastes time; exponential backoff between retries so a struggling server isn’t hammered harder by the retry logic itself; and a private recursive requestWithRetry method so the public get/post methods stay simple while the retry complexity lives in exactly one place instead of being duplicated across every HTTP verb.

Testing the client itself

describe('ApiTestClient', () => {
  it('retries once on a 500 and succeeds on the second attempt', async () => {
    const mockFetch = vi
      .fn()
      .mockResolvedValueOnce(new Response(null, { status: 500 }))
      .mockResolvedValueOnce(new Response(JSON.stringify({ id: '1' }), { status: 200 }));
    vi.stubGlobal('fetch', mockFetch);

    const client = new ApiTestClient('https://api.test');
    const result = await client.get<{ id: string }>('/orders/1');

    expect(result).toEqual({ id: '1' });
    expect(mockFetch).toHaveBeenCalledTimes(2);
  });

  it('throws a typed ApiClientError on a 404 without retrying', async () => {
    vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 404 })));

    const client = new ApiTestClient('https://api.test');
    await expect(client.get('/orders/missing')).rejects.toThrow(ApiClientError);
  });
});

Testing the retry logic itself — not just using the client to test other things — is worth doing for any piece of shared test infrastructure this central, because a bug in the client’s retry or timeout logic doesn’t just fail one test, it silently changes the timing and failure characteristics of every test in the suite that uses it, which makes bugs here disproportionately expensive compared to bugs in an individual test.

Test data factories and async/await

Larger frameworks often introduce a “test data factory” layer — functions that produce realistic entities (users, orders, products) for seeding, frequently involving async database or API calls to actually persist the generated data before a test runs against it.

interface UserFactoryOptions {
  role?: 'admin' | 'customer';
  verified?: boolean;
}

async function createTestUser(options: UserFactoryOptions = {}): Promise<User> {
  const payload = {
    email: `test-${crypto.randomUUID()}@example.com`,
    role: options.role ?? 'customer',
    verified: options.verified ?? true,
  };
  return apiClient.post<User>('/test-utils/users', payload);
}

async function createTestOrder(userId: string, options: { itemCount?: number } = {}): Promise<Order> {
  const items = Array.from({ length: options.itemCount ?? 1 }, () => ({
    productId: `prod-${crypto.randomUUID()}`,
    quantity: 1,
  }));
  return apiClient.post<Order>('/test-utils/orders', { userId, items });
}

test('admin can view any customer order', async () => {
  const [admin, customer] = await Promise.all([
    createTestUser({ role: 'admin' }),
    createTestUser({ role: 'customer' }),
  ]);
  const order = await createTestOrder(customer.id, { itemCount: 3 });

  const viewedOrder = await apiClient.get<Order>(`/orders/${order.id}`, { asUser: admin.id });
  expect(viewedOrder.id).toBe(order.id);
});

Notice the Promise.all for the two independent user creations, followed by a sequential await for the order creation, which genuinely depends on the customer’s ID and can’t run concurrently with it. This is a good compact example of the “parallelize independent steps, keep dependent steps sequential” principle from earlier applied to a realistic factory pattern, and it’s exactly the kind of test setup code where the difference between correct and naive concurrency use adds up across hundreds of tests each doing similar setup.

Randomized data generation and async factory composition

async function createTestOrderWithHistory(userId: string, statusHistory: OrderStatus[]): Promise<Order> {
  let order = await createTestOrder(userId);
  for (const status of statusHistory) {
    // Sequential and deliberate — each status transition depends on the previous one,
    // this is NOT a candidate for Promise.all
    order = await apiClient.post<Order>(`/test-utils/orders/${order.id}/transition`, { status });
  }
  return order;
}

const shippedOrder = await createTestOrderWithHistory(customer.id, ['PENDING', 'PAID', 'SHIPPED']);

Worth calling out explicitly in the code comment, as shown above, precisely because a reviewer skimming a for loop with an await inside it — the exact shape flagged as an anti-pattern in Mistake 3 earlier — needs to be able to tell at a glance whether this is a mistake or a deliberate sequential dependency. Leaving that judgment implicit is how a well-intentioned “optimize this for speed” pull request accidentally introduces a genuine bug by parallelizing operations that were sequential for a reason.

Chaos and network-condition testing with async/await

test('checkout gracefully handles a slow payment gateway', async ({ page, context }) => {
  await context.route('**/api/payment/**', async (route) => {
    await new Promise(resolve => setTimeout(resolve, 8000)); // simulate a slow gateway
    await route.continue();
  });

  await page.goto('/checkout');
  await page.click('#place-order');
  await expect(page.locator('.loading-indicator')).toBeVisible();
  await expect(page.locator('.order-confirmation')).toBeVisible({ timeout: 15000 });
});

Playwright’s request interception (context.route) is itself async — the route handler is an async callback, and awaiting an artificial delay inside it before calling route.continue() is a clean way to simulate network latency or a slow dependency without needing a separate test environment or a real degraded service. This is a good example of async/await being used not just to consume asynchronous behavior but to deliberately construct it, for the purpose of testing how the application under test handles exactly that kind of delay — a distinction worth drawing out in an interview if asked to demonstrate chaos-testing or resilience-testing thinking, since it shows async/await used as a testing tool, not just a mechanism you’re working around.

Simulating and testing network failures, not just delays

test('checkout shows a retry option when the payment API is unreachable', async ({ page, context }) => {
  let requestCount = 0;
  await context.route('**/api/payment/**', async (route) => {
    requestCount++;
    if (requestCount === 1) {
      await route.abort('failed');
    } else {
      await route.continue();
    }
  });

  await page.goto('/checkout');
  await page.click('#place-order');
  await expect(page.locator('.payment-error')).toBeVisible();
  await page.click('#retry-payment');
  await expect(page.locator('.order-confirmation')).toBeVisible();
});

Combining a mutable counter with an async route handler lets you script a specific failure-then-success sequence deterministically — the first request genuinely fails at the network level via route.abort('failed'), and the second one is allowed through, which tests both the application’s error-state UI and its retry mechanism in a single, repeatable test rather than relying on a real backend service to actually be flaky in a controllable way (which it, definitionally, isn’t controllable).

GraphQL testing and async/await

interface GraphQLResponse<T> {
  data?: T;
  errors?: { message: string; path?: string[] }[];
}

async function graphqlRequest<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
  const response = await fetch('/graphql', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, variables }),
  });
  const result: GraphQLResponse<T> = await response.json();

  if (result.errors && result.errors.length > 0) {
    throw new Error(`GraphQL errors: ${result.errors.map(e => e.message).join(', ')}`);
  }
  return result.data as T;
}

test('fetches an order with nested line items', async () => {
  const query = `
    query GetOrder($id: ID!) {
      order(id: $id) {
        id
        status
        lineItems { productName quantity }
      }
    }
  `;
  const data = await graphqlRequest<{ order: Order }>(query, { id: orderId });
  expect(data.order.lineItems.length).toBeGreaterThan(0);
});

A GraphQL-specific wrinkle worth knowing: a GraphQL response can be a 200 OK at the HTTP layer while still containing an errors array in the response body — GraphQL’s error handling doesn’t map cleanly onto HTTP status codes the way REST typically does, so a test client’s error-checking logic needs to inspect the response body’s errors field explicitly rather than relying on response.ok the way the REST client examples earlier could. Missing this is a common gap in GraphQL test clients ported over from REST-oriented client code, where the response.ok check silently passes on a request that actually failed at the resolver level.

Where teams get async/await conventions wrong at the framework level

Beyond individual code mistakes, there are framework-wide conventions worth getting right early, because retrofitting them across an established codebase is expensive.

Inconsistent error types across the framework

If some parts of your framework throw plain Error objects, others throw strings (technically legal in JavaScript, though TypeScript’s strict mode discourages it), and others throw custom error classes inconsistently, every catch block across the codebase ends up needing defensive, inconsistent narrowing logic. Standardizing on a small set of custom error classes (an ApiClientError, a TimeoutError, an AssertionError if you’re not already using your test runner’s own) that all extend the native Error class, early in a framework’s life, pays for itself many times over once the framework has grown past a handful of contributors.

No shared convention for what “await everything” means at hook boundaries

Some frameworks are strict that every hook (beforeEach, afterEach, beforeAll, afterAll) must return a promise that’s properly awaited by the test runner internally, and lint accordingly. Others are looser, and it’s genuinely easy for a hook function to accidentally not be marked async at all — silently turning what looks like setup logic into fire-and-forget code that races against the actual test body. A hook function that isn’t declared async but calls an async operation without awaiting it inside is one of the most dangerous silent failures in a framework, because it doesn’t fail loudly — it just occasionally lets a test start before setup genuinely finished, and the resulting flakiness gets attributed to “the test” rather than correctly traced back to the hook.

No documented pattern for concurrency limits against shared test environments

A team that adopts Promise.all everywhere for speed, without a documented convention for how much concurrency a shared staging environment or shared test database can actually absorb, tends to discover the limit the hard way — a nightly suite that was reliable at 4 workers starts throwing intermittent 503s and connection pool exhaustion errors once someone bumps it to 16 workers to “make CI faster,” and the root cause investigation takes longer than it should because the failure looks like flakiness in individual tests rather than a systemic concurrency ceiling being exceeded across the whole suite. Documenting an explicit concurrency budget — worker count times typical in-test Promise.all fan-out, checked against what the shared environment can actually sustain — is boring, unglamorous framework governance, but it’s exactly the kind of thing a QA Architect or Automation Lead is expected to own that an individual contributor writing tests day to day usually isn’t thinking about.

Wrapping up: what this all adds up to in practice

Everything covered in this piece — the syntax, the typing rules, the mistakes, the framework-level patterns — points at the same underlying skill from different angles: knowing exactly what state your program is in at every point where an await appears, and knowing exactly what happens if that awaited operation is slow, fails, or never resolves at all. TypeScript’s type system helps enforce a meaningful slice of this discipline at compile time — catching missing Promise wrappers, forcing honest handling of unknown-typed errors, letting you build precise generic utilities around async operations — but the rest is judgment that comes from having actually been paged, or actually spent an afternoon bisecting a flaky test, because of exactly the mistakes catalogued above. If you’re prepping for a Lead SDET or Automation Architect interview, that’s ultimately what the async/await questions are probing for: not whether you know the keywords, but whether you’ve internalized what can go wrong when a team of engineers writes concurrent code together, at scale, under deadline pressure, and whether you’d catch it in review before it becomes someone else’s 2 AM page.

TypeScript async/await and BDD frameworks (Cucumber, SpecFlow-style step definitions)

A meaningful share of QA automation frameworks are built around Gherkin-style BDD, and TypeScript async/await interacts with Cucumber step definitions in a way that’s worth covering separately, since the wiring between step definitions and the underlying async operations they perform is a common source of confusion for engineers moving from a synchronous or callback-based Cucumber setup to a modern TypeScript one.

import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from '@playwright/test';

Given('I am logged in as a {string}', async function (this: CustomWorld, role: string) {
  const user = await createTestUser({ role: role as 'admin' | 'customer' });
  await this.page.goto('/login');
  await this.page.fill('#username', user.email);
  await this.page.fill('#password', 'test-password');
  await this.page.click('#submit');
  this.currentUser = user;
});

When('I place an order for {int} items', async function (this: CustomWorld, itemCount: number) {
  this.currentOrder = await createTestOrder(this.currentUser.id, { itemCount });
  await this.page.goto(`/orders/${this.currentOrder.id}`);
});

Then('the order status should be {string}', async function (this: CustomWorld, expectedStatus: string) {
  await expect(this.page.locator('.order-status')).toHaveText(expectedStatus);
});

Cucumber’s Given/When/Then step definitions accept regular async functions, and the same rules from everywhere else in this article apply without modification — every step definition here is async, and every promise-returning call inside it is awaited. The one Cucumber-specific detail worth knowing: step definitions that forget to mark themselves async but still call an async helper function without awaiting it fail in a particularly unhelpful way, because Cucumber’s runner considers the step “complete” the instant the (non-async) function returns, moving on to the next step in the scenario while your unawaited async call is still resolving in the background — which produces exactly the kind of race condition covered throughout this piece, except now scattered across natural-language Gherkin scenarios where the underlying async bug is one layer further removed from the code a reviewer is reading, making it correspondingly harder to spot.

Shared World state and async initialization

import { setWorldConstructor, World } from '@cucumber/cucumber';
import { Page, Browser, chromium } from '@playwright/test';

class CustomWorld extends World {
  browser!: Browser;
  page!: Page;
  currentUser!: User;
  currentOrder!: Order;

  async init(): Promise<void> {
    this.browser = await chromium.launch();
    this.page = await this.browser.newPage();
  }

  async cleanup(): Promise<void> {
    await this.page?.close();
    await this.browser?.close();
  }
}

setWorldConstructor(CustomWorld);

Because Cucumber’s World constructor itself can’t be async (same constraint as the earlier constructor example — constructors can’t return promises), the idiomatic pattern is a separate init() method called explicitly from a Before hook, and a matching cleanup() called from an After hook — structurally identical to the static-factory workaround shown earlier for classes that need async construction, just applied to Cucumber’s specific lifecycle hooks.

CI/CD pipeline YAML and async test execution — timeouts that matter beyond the test code itself

A category of async-adjacent failure that has nothing to do with your TypeScript code at all: the CI platform’s own job-level and step-level timeout settings interacting badly with legitimately slow async operations inside your suite. This is worth knowing because it’s a common source of “the test passed locally but the CI job failed with no useful error” reports.

# GitHub Actions example
jobs:
  e2e-tests:
    runs-on: ubuntu-latest
    timeout-minutes: 30  # job-level ceiling
    steps:
      - name: Run Playwright tests
        run: npx playwright test
        timeout-minutes: 25  # step-level ceiling, should be less than the job-level one

If your test suite’s own internal timeouts (Playwright’s test.setTimeout(), individual await operations with generous per-call timeouts) are set higher than the CI platform’s job or step timeout, the CI platform will kill the job abruptly mid-run rather than letting your test framework’s own timeout logic produce a clean, attributable failure — you’ll see a generic “job cancelled” or “step timed out” message instead of a specific test failure pointing at exactly which await never resolved. Keeping your framework’s own timeout ceilings comfortably below the CI platform’s ceilings, with some margin for cleanup/teardown time to still run after a test-level timeout fires, is a small piece of configuration hygiene that saves real debugging time when something does eventually hang.

A complete, annotated walk-through: from a flaky test ticket to a root-caused fix

To bring everything together, it’s worth walking through a realistic debugging session end to end, the way you’d actually encounter one of these issues in production, rather than as an isolated code snippet.

The ticket

“Checkout smoke test fails intermittently in CI, roughly 1 in 8 runs. Passes reliably when run locally. Failure is always an assertion timeout on the order confirmation element.”

Step 1 — read the failing test

test('completes checkout successfully', async ({ page }) => {
  await page.goto('/cart');
  await addItemToCart(page, 'sku-123');
  await page.click('#checkout-button');
  fillPaymentDetails(page); // <-- no await here
  await page.click('#place-order');
  await expect(page.locator('.order-confirmation')).toBeVisible({ timeout: 5000 });
});

The missing await on fillPaymentDetails(page) jumps out immediately once you’re specifically looking for it, but note the ticket description: it passes reliably locally. That’s consistent with a race condition — locally, the machine and network are usually fast and consistent enough that fillPaymentDetails‘s internal operations happen to complete before #place-order is clicked, purely by timing coincidence, not because the code is actually correct. In CI, under more variable load and often slower infrastructure, that coincidence doesn’t hold reliably, which produces exactly the roughly-1-in-8 intermittent failure rate described.

Step 2 — confirm the hypothesis before fixing it

async function fillPaymentDetails(page: Page): Promise<void> {
  console.log(`[${Date.now()}] starting fillPaymentDetails`);
  await page.fill('#card-number', '4242424242424242');
  await page.fill('#expiry', '12/28');
  await page.fill('#cvc', '123');
  console.log(`[${Date.now()}] finished fillPaymentDetails`);
}

test('completes checkout successfully', async ({ page }) => {
  // ...
  fillPaymentDetails(page);
  console.log(`[${Date.now()}] clicking place order`);
  await page.click('#place-order');
  // ...
});

Adding timestamped logging around the suspected race — the exact technique from the debugging section earlier — and running the test repeatedly in CI (or with artificial network throttling locally to simulate CI-like variability) surfaces the actual ordering: on failing runs, “clicking place order” logs before “finished fillPaymentDetails,” confirming the race directly rather than just inferring it from reading the code.

Step 3 — fix and verify

test('completes checkout successfully', async ({ page }) => {
  await page.goto('/cart');
  await addItemToCart(page, 'sku-123');
  await page.click('#checkout-button');
  await fillPaymentDetails(page); // fixed
  await page.click('#place-order');
  await expect(page.locator('.order-confirmation')).toBeVisible({ timeout: 5000 });
});

Step 4 — the part most teams skip: preventing the next one

Fixing this single instance is necessary but not sufficient — the actual root cause is that nothing caught this at the code review stage, and nothing in the framework’s tooling would have caught the next one either. This is exactly the point at which enabling @typescript-eslint/no-floating-promises across the framework (if it wasn’t already enabled) turns a one-off fix into a systemic prevention — running that lint rule against the existing codebase after this incident is a reasonable next step, and it will very likely surface several more instances of the same underlying mistake sitting quietly in other tests that haven’t happened to fail yet.

Summary table: async/await syntax reference

What you want to doSyntax
Declare an async functionasync function name() {}
Declare an async arrow functionconst name = async () => {}
Declare an async class methodasync methodName() {}
Wait for one promiseconst result = await somePromise;
Wait for several independent promises togetherconst results = await Promise.all([p1, p2, p3]);
Wait for all, regardless of individual failuresconst results = await Promise.allSettled([p1, p2, p3]);
Wait for the first to settleconst result = await Promise.race([p1, p2]);
Wait for the first to succeedconst result = await Promise.any([p1, p2]);
Catch a rejectiontry { await p; } catch (error) { … }
Type the resolved value of a Promise typeAwaited<ReturnType<typeof fn>>
Explicitly ignore a promise on purposevoid somePromise;
Iterate an async generatorfor await (const item of asyncIterable) { … }
Use await outside a function, at module scopeLegal at the top level of an ES module with a modern tsconfig target

Keep this table nearby the next time you’re reviewing a pull request touching async TypeScript test code — a surprising share of the mistakes catalogued throughout this article boil down to reaching for the wrong row in this table, or forgetting one of these rows exists at all and reimplementing it manually and imperfectly.

A short history: how TypeScript async/await got here

It’s worth knowing the lineage, not as trivia but because it explains a few design decisions that otherwise seem arbitrary. Before native async/await, TypeScript and JavaScript developers wrote asynchronous code with raw callbacks, then with libraries like Bluebird and Q that added promise-like abstractions on top of callback APIs, then with native ES6 Promises once they landed in the language itself. Generator functions (function*, yield) briefly became a popular way to write promise-chain code that looked synchronous, using libraries like co to drive a generator forward each time a yielded promise resolved. Async/await, standardized in ES2017, is essentially that generator-driven pattern built directly into the language, with the engine handling the “drive the generator forward on each promise resolution” machinery natively instead of relying on a userland library to do it.

TypeScript actually supported the async/await keywords before they were finalized in the ECMAScript spec, by compiling them down to that same generator-based machinery for targets that didn’t have native support — which is part of why, even today, checking your compile target matters for how the emitted JavaScript actually looks, and why debugging compiled output on an older target can mean staring at unfamiliar generator-driven state-machine code instead of something resembling your original source. Knowing this lineage is genuinely useful context for the “why does TypeScript support async/await if it’s just Promise sugar” style of interview question — the honest answer is that language-level syntax for a widely-used pattern reduces the amount of boilerplate and third-party tooling every project needs to reimplement, and standardizing it let engines optimize the common case natively instead of every project depending on a slightly different userland implementation.

Strict null checks and async/await — a combination worth understanding together

TypeScript’s strictNullChecks (bundled into strict mode) interacts with async code in a way that catches a specific, common category of bug: assuming a value exists after an awaited call when the function’s actual type signature says otherwise.

async function findUserByEmail(email: string): Promise<User | null> {
  const users = await apiClient.get<User[]>(`/users?email=${email}`);
  return users[0] ?? null;
}

async function sendWelcomeEmail(email: string): Promise<void> {
  const user = await findUserByEmail(email);
  await emailService.send(user.id, 'welcome'); // Error under strict mode: user could be null
}

Under strictNullChecks, TypeScript correctly refuses to let you access user.id without first narrowing away the null possibility, and this catches exactly the class of bug where a test helper assumes a lookup always succeeds and doesn’t handle the “not found” case — a bug that, without strict mode, would compile fine and then throw a genuinely confusing runtime error (“Cannot read property ‘id’ of null”) deep inside whatever came after the lookup, far from the actual root cause.

async function sendWelcomeEmail(email: string): Promise<void> {
  const user = await findUserByEmail(email);
  if (!user) {
    throw new Error(`No user found for email: ${email}`);
  }
  await emailService.send(user.id, 'welcome'); // safe — user is narrowed to User here
}

This is one of the clearer examples of why running a test framework under strict: true (rather than a looser tsconfig inherited from a quick scaffold) pays for itself specifically in async-heavy code — a huge share of “await a lookup that might not find anything” patterns exist throughout any realistic API test client, and strict null checking forces exactly the kind of defensive handling that keeps those lookups from becoming silent runtime crashes several layers removed from where the actual gap in reasoning happened.

Onboarding new team members to a framework’s async/await conventions

If you’re the QA Lead or Architect responsible for a shared TypeScript framework, the material in this article is also, in practice, onboarding material — the mistakes catalogued above are exactly the ones a new hire, regardless of how senior they are in a different stack, tends to make in their first few pull requests against an unfamiliar async TypeScript codebase. A few practical things that make this transfer smoothly rather than through a string of individually corrected PR comments:

  • Enable @typescript-eslint/no-floating-promises and @typescript-eslint/require-await from day one, so the tooling catches the two most common mistakes automatically rather than relying on a reviewer’s attention every single time.
  • Keep one canonical, well-commented example of the framework’s retry/timeout/error-handling conventions (something close to the ApiTestClient example earlier) somewhere genuinely discoverable — a README, a pinned example file — rather than expecting new contributors to reverse-engineer the pattern from scattered usage across the codebase.
  • Explicitly document which parts of setup are safe to parallelize with Promise.all and which aren’t, per test suite or per shared environment, rather than leaving that judgment call to be rediscovered independently by every engineer who touches setup code.
  • Walk new hires through at least one real historical flaky-test postmortem from the codebase (redacted as needed) rather than only a hypothetical example — a “this actually happened, here’s the ticket, here’s the root cause” story lands with far more weight than an abstract warning about missing awaits.

A cheat sheet: the async/await gotchas ranked by how often they actually bite

If you only remember five things from this entire article, make it these five, ranked roughly by how often each one shows up in a real flaky-test investigation based on the pattern across the mistakes covered:

  1. Missing await on a promise-returning call. By far the most common root cause of intermittent async test failures. Enable the lint rule; don’t rely on manual review alone.
  2. forEach/filter with an async callback, expecting it to wait. It won’t. Reach for map() combined with Promise.all() whenever the intent is “run these concurrently and wait for all of them.”
  3. Returning a promise from inside a try block instead of awaiting it. The enclosing catch won’t fire on rejection unless you await inside the try.
  4. Using Promise.all where Promise.allSettled was actually needed. Fail-fast behavior silently discards information about the other operations when you actually wanted a full picture of every result.
  5. Sequential awaits for genuinely independent operations. Not a correctness bug, but a real, compounding cost to suite runtime that’s easy to overlook because each individual instance looks harmless.

Every one of these is checkable, either by a linter, by a code review habit, or by a five-minute audit pass over a slow test suite’s setup code — none of them require exotic tooling or deep runtime internals knowledge to catch, which is exactly why they’re worth memorizing as a checklist rather than something you reason through fresh every time you open a pull request.

One more worked example: converting a flaky polling loop into a robust one

// Before — polls too aggressively, no timeout, no backoff, swallows errors silently
async function waitForOrderShipped(orderId: string): Promise<void> {
  while (true) {
    try {
      const order = await apiClient.get<Order>(`/orders/${orderId}`);
      if (order.status === 'SHIPPED') return;
    } catch {}
    await new Promise(resolve => setTimeout(resolve, 100));
  }
}

This version has three separate problems worth naming individually: it polls every 100ms indefinitely with no upper bound, meaning a genuinely stuck order (a real bug in the system under test) hangs the test forever instead of failing with a clear timeout message; it swallows every error silently, including genuinely unexpected ones like a 500 or a malformed response, which means a real backend outage looks identical to “still pending” from this function’s perspective; and it doesn’t back off, hammering the API at a fixed aggressive interval regardless of how long it’s already been waiting.

// After — bounded, backing off, and honest about failures
async function waitForOrderShipped(orderId: string, timeoutMs = 10000): Promise<void> {
  const start = Date.now();
  let intervalMs = 200;

  while (Date.now() - start < timeoutMs) {
    const order = await apiClient.get<Order>(`/orders/${orderId}`);
    if (order.status === 'SHIPPED') return;
    await new Promise(resolve => setTimeout(resolve, intervalMs));
    intervalMs = Math.min(intervalMs * 1.5, 1000);
  }

  throw new Error(`Order ${orderId} did not reach SHIPPED status within ${timeoutMs}ms`);
}

This is essentially a hand-rolled, narrower version of the generic pollUntil utility shown earlier in the generics section, and comparing the two is a useful exercise — once you’ve written this pattern two or three times for different specific conditions (order shipped, job completed, index updated), it’s the clearest possible signal that it’s time to extract the generic version and delete the bespoke duplicates, which is exactly the kind of refactoring judgment that turns a collection of individually-fine test helpers into an actually maintainable framework.

The bottom line for anyone learning this for an interview versus anyone maintaining a framework in production

If you’re preparing for an interview, the material most worth over-rehearsing is the crisp explanations — Promise.all vs allSettled vs race vs any, why forEach doesn’t await, why the catch parameter is unknown, how to implement a timeout, how to implement a concurrency-limited batch runner. Those are the questions with clean, correct, complete answers you can deliver in under two minutes, and they’re exactly what a technical screener is listening for.

If you’re maintaining a framework in production, the material most worth internalizing is different: it’s the instinct to treat every new async helper function as something that needs a return type annotation, a decision about retry/timeout behavior, and a decision about how its errors should be typed and surfaced — before it gets copied fifteen times across a codebase and those decisions become expensive to change. The syntax of async/await is the easy five percent. Everything else in this article is the other ninety-five percent, and it’s the part that actually determines whether your test suite is something your team trusts or something your team routinely reruns and hopes turns green.

Async/await considerations in load and performance-adjacent testing

Pure load testing tools like k6 or Artillery typically use their own execution models rather than raw TypeScript async/await (k6 scripts, for instance, run in a Go-based runtime with a JavaScript-like scripting layer that has its own constraints around async code), but TypeScript-based performance smoke checks — verifying that a critical endpoint responds within an acceptable latency budget as part of a regular test run rather than a dedicated load test — do use the same async/await patterns covered throughout this article, just with timing assertions layered on top.

async function measureLatency(fn: () => Promise<unknown>): Promise<number> {
  const start = performance.now();
  await fn();
  return performance.now() - start;
}

test('order lookup responds within acceptable latency', async () => {
  const latencies = await Promise.all(
    Array.from({ length: 10 }, () => measureLatency(() => apiClient.get(`/orders/${orderId}`)))
  );
  const p95 = latencies.sort((a, b) => a - b)[Math.floor(latencies.length * 0.95)];
  expect(p95).toBeLessThan(500);
});

Worth being precise about what this measures: firing ten requests concurrently via Promise.all and timing each one individually measures latency under a small amount of concurrent load, not sequential single-request latency — if the intent was to measure how fast a single, uncontended request responds, the requests need to run sequentially instead, each timed independently. Conflating “average latency under concurrent load” with “typical single-request latency” is an easy mistake to make when reusing the concurrency patterns from earlier in this article for a genuinely different measurement purpose, and it’s worth double-checking which one your specific performance assertion actually needs before wiring up the async structure around it.

Test isolation and async state leakage between tests

A subtler failure mode than most of what’s covered above: async operations that outlive the test that started them, bleeding state or side effects into a subsequent, unrelated test. This is distinct from the “hanging process” teardown issue covered earlier — it’s specifically about a fire-and-forget async operation from Test A completing partway through Test B’s execution and mutating shared state that Test B also touches.

// Dangerous pattern — a background async operation started in one test,
// not awaited, and not cleaned up, can still be running when the next test starts
test('triggers an async cache warm-up on page load', async ({ page }) => {
  await page.goto('/dashboard');
  triggerCacheWarmup(); // fire-and-forget, no await, no cleanup
  await expect(page.locator('.widget')).toBeVisible();
});

test('dashboard shows cold-cache fallback state', async ({ page }) => {
  // If the previous test's cache warm-up is still resolving, this test's
  // assumption of a genuinely cold cache may silently be false
  await page.goto('/dashboard');
  await expect(page.locator('.fallback-message')).toBeVisible();
});

This is a real, if relatively rare, category of flakiness that’s specifically hard to diagnose because the two tests involved don’t appear related to each other at all when read individually — the connection only becomes visible once you’re specifically looking for lingering async operations crossing test boundaries, which is exactly why any intentionally fire-and-forget async call (marked explicitly with void, per the conventions covered earlier) deserves a second look for whether it could plausibly still be running when the next test starts, and whether that’s actually acceptable for your test isolation guarantees.

Further reading and reference material

For anyone wanting to go beyond what’s covered here, a small set of primary sources are worth bookmarking rather than relying on secondhand summaries, since async/await behavior details do shift slightly across spec versions and engine implementations:

  • The official TypeScript Handbook’s section on functions, which covers async function typing rules directly from the source.
  • MDN’s async function reference, consistently the most precise and up-to-date documentation of the underlying JavaScript semantics that TypeScript’s async/await compiles down to.
  • The Playwright documentation on timeouts, directly relevant to several of the CI-flakiness scenarios covered above.
  • Node.js’s documentation on the unhandledRejection process event, useful background for anyone building custom framework tooling around a test runner rather than relying entirely on the runner’s own defaults.

None of these replace the judgment built from actually debugging a flaky async test suite under deadline pressure, but they’re the right places to double-check a specific detail when something in this article doesn’t quite match what you’re seeing in your own codebase — spec details and engine behavior do shift over time, and primary sources age better than any single article, including this one.

Putting numbers on it: a before-and-after case study

Abstract principles are easier to apply once you’ve seen them attached to real numbers, so it’s worth walking through a composite case study built from the kind of audit described piecemeal throughout this article — the sort of exercise a QA Architect might run against an existing TypeScript automation framework that’s grown organically over a couple of years without much deliberate attention to async/await conventions.

Starting point: a Playwright-based end-to-end suite with 340 tests, averaging 22 minutes of total wall-clock runtime across 4 CI workers, with a flaky-test rate hovering around 6% per run (roughly 20 tests failing and passing again on rerun, on a typical day, with no code changes in between).

Audit pass one — floating promises

Enabling @typescript-eslint/no-floating-promises against the existing codebase surfaced 47 violations — promise-returning calls with no await, no .then(), and no explicit void marker. Not every one of these was actually causing observed flakiness; several were harmless because of how Playwright’s auto-waiting happened to paper over the gap in practice. But cross-referencing the 47 flagged locations against the suite’s own historical flaky-test data showed that 14 of the 20 typically-flaky tests had at least one floating promise somewhere in their call chain — their own test body, a shared fixture, or a page object method they depended on. Fixing all 47 (adding the missing await, or an explicit void where the fire-and-forget behavior was genuinely intentional) brought the flaky-test rate down from roughly 6% to roughly 2% per run, without touching test logic, assertions, or coverage in any way — purely a correctness fix to existing async wiring.

Audit pass two — sequential awaits in setup

A second pass specifically targeting beforeEach hooks and shared fixture setup functions found 18 instances of genuinely independent async operations being awaited sequentially rather than combined via Promise.all — test user creation, cache warm-up, and feature-flag configuration, each awaited on its own line despite having no dependency on one another. Converting these to Promise.all where the operations were confirmed independent (a few were not, and were correctly left sequential, following the “parallelize only what’s genuinely independent” principle from earlier) reduced average per-test setup time by roughly 400ms. Multiplied across 340 tests and accounting for the 4-worker parallelism in CI, that alone accounted for roughly 3 minutes of the suite’s total wall-clock runtime.

Audit pass three — global setup migration

A third pass identified that database seeding — genuinely global, one-time setup — was being re-run inside a shared fixture that executed once per test file rather than once per suite run, because of how the fixture had originally been scoped when the framework was small enough that this didn’t matter. Moving it into Playwright’s globalSetup cut a further 6 minutes off total runtime.

Net result

Combined, these three passes — none of which involved rewriting test logic, adding new tooling, or changing test coverage — took the suite from 22 minutes with a 6% flaky rate to roughly 13 minutes with a flaky rate under 1%. None of the individual fixes were exotic; every one of them is a direct application of a principle covered earlier in this article. What made the difference wasn’t discovering some clever new technique — it was systematically auditing an existing codebase against a known checklist of async/await mistakes, the same checklist reproduced in condensed form earlier in this piece, rather than only fixing individual flaky tests reactively, one ticket at a time, as they were reported.

This is, in miniature, the argument for why understanding async/await deeply is disproportionately valuable for anyone in a QA Lead, SDET Lead, or Automation Architect role specifically — it’s not really about writing new async code correctly from scratch, which most engineers eventually manage to do reasonably well through trial and error. It’s about being able to run exactly this kind of systematic audit against someone else’s two-year-old codebase, find the handful of specific, fixable patterns responsible for a disproportionate share of the team’s pain, and fix them with confidence rather than guesswork — which is a meaningfully different and rarer skill than simply being able to write a correct async function when starting from a blank file.

A note on reading vendor and framework documentation critically

One last practical habit worth mentioning: framework documentation for tools like Playwright, WebdriverIO, and various API client libraries doesn’t always model async/await usage perfectly in every code example, particularly in older documentation pages or community-contributed examples that predate a library’s more recent best-practice guidance. It’s worth reading example code from any library’s documentation with the same scrutiny you’d apply to a colleague’s pull request — checking specifically for missing awaits, for Promise.all usage where sequential awaits would have been safer or vice versa, and for error handling that’s been simplified for brevity in a way that wouldn’t actually be acceptable in production test code. Copying a documentation example verbatim into a shared framework utility without this scrutiny is a realistic way for exactly the mistakes catalogued in this article to enter a codebase through what looks like an authoritative source, and “the documentation did it this way” is a weaker defense in code review than it might feel like in the moment, since documentation examples are frequently optimized for brevity and illustrating one specific feature, not for the full correctness bar a shared framework utility actually needs to meet.

Recap

TypeScript async/await gives you synchronous-looking syntax over an asynchronous, single-threaded, event-loop-driven runtime. The syntax is straightforward to learn. What takes real, accumulated experience — the kind this article has tried to compress into something closer to a reference than a first read — is everything that syntax doesn’t automatically protect you from: races between awaited and un-awaited calls, the difference between concurrent I/O and true parallelism, honest typing of errors that could genuinely be anything, and the discipline to treat every single promise-returning expression as a decision rather than something you can write and immediately stop thinking about. Get that discipline right, in your own code and in what you approve in code review, and async/await stops being a source of mysterious flaky tests and starts being exactly what it was designed to be: a clean, readable way to write correct asynchronous test automation code.

Appendix: a fuller worked example combining most of the patterns in this article

To close out, here’s a single, more complete example that deliberately threads together several of the individual patterns covered above — typed async API client usage, generic polling, Promise.all for independent setup, proper error narrowing, and cleanup in a finally block — the way they’d actually coexist in one realistic test rather than as isolated snippets.

interface OrderTestContext {
  admin: User;
  customer: User;
  order: Order;
}

async function setupOrderTestContext(itemCount: number): Promise<OrderTestContext> {
  const [admin, customer] = await Promise.all([
    createTestUser({ role: 'admin' }),
    createTestUser({ role: 'customer' }),
  ]);
  const order = await createTestOrder(customer.id, { itemCount });
  return { admin, customer, order };
}

async function teardownOrderTestContext(context: OrderTestContext): Promise<void> {
  await Promise.allSettled([
    deleteTestUser(context.admin.id),
    deleteTestUser(context.customer.id),
    deleteTestOrder(context.order.id),
  ]);
}

test('admin can mark a customer order as shipped and customer sees updated status', async ({ page }) => {
  const context = await setupOrderTestContext(2);

  try {
    await page.goto(`/admin/orders/${context.order.id}`, {
      waitUntil: 'networkidle',
    });

    await page.click('[data-testid="mark-shipped"]');

    await pollUntil(
      () => apiClient.get<Order>(`/orders/${context.order.id}`),
      (order) => order.status === 'SHIPPED',
      { intervalMs: 300, timeoutMs: 8000 }
    );

    await page.goto(`/orders/${context.order.id}`, {
      waitUntil: 'networkidle',
    });
    await expect(page.locator('.order-status')).toHaveText('Shipped');
  } catch (error) {
    if (error instanceof Error) {
      console.error(`Test failed during order status verification: ${error.message}`);
    }
    throw error;
  } finally {
    await teardownOrderTestContext(context);
  }
});

Walking through the choices here one more time, because each one maps directly back to a section above: the two independent user creations in setupOrderTestContext use Promise.all because they don’t depend on each other, while the order creation that follows is a separate, sequential await because it genuinely depends on the customer’s ID. The teardown function uses Promise.allSettled rather than Promise.all deliberately — cleanup should attempt every deletion regardless of whether an earlier one fails, since a failed admin-user deletion shouldn’t prevent the customer-user and order deletions from still being attempted. The pollUntil call reaches for the generic utility built earlier rather than a bespoke inline polling loop, keeping the test body focused on what it’s actually verifying rather than reimplementing wait logic. The catch block narrows the caught value with instanceof Error before touching .message, consistent with the unknown-typed catch parameter discussed at length above. And the finally block guarantees teardown runs whether the test passes or fails, which matters specifically because a failed assertion partway through this test would otherwise leave orphaned test users and an orphaned order sitting in whatever environment this ran against, quietly accumulating test data debt across every future run until someone notices and cleans it up manually.

If you can read this example and immediately explain why each async/await decision was made the way it was, rather than just recognizing that the syntax is valid, that’s a genuinely good gut check for whether the material in this article has actually landed — and it’s close to the bar a strong technical interviewer is checking for when they ask you to walk through a piece of async test code and explain the reasoning behind it, rather than just asking you to define what async and await mean in isolation.

A last practical note on code style consistency for teams

One area this article hasn’t touched on directly yet: how a team agrees on a consistent async/await style once the individual correctness rules are understood, since correctness and consistency are related but separate concerns. Two engineers can both write fully correct async TypeScript and still produce code that looks meaningfully different — one preferring explicit return type annotations everywhere, another relying on inference for anything local; one preferring small single-purpose async helper functions, another preferring longer inline async blocks; one always destructuring awaited results immediately, another storing the awaited value in an intermediate variable first. None of these are correctness issues, but a framework where every file makes different style choices around async code is measurably harder to review quickly, because reviewers spend part of their attention re-establishing “what’s the pattern here” per file instead of focusing entirely on whether the logic is right.

A short, concrete style guide — even three or four bullet points, agreed on once and referenced in a CONTRIBUTING file or framework README — removes this friction almost entirely. Something as simple as: explicit return types on any exported async function; Promise.all preferred over sequential awaits for anything confirmed independent, with a one-line comment when sequential awaits are deliberate rather than accidental; a single shared custom error class hierarchy rather than ad hoc throw new Error(...) scattered without a consistent shape; and cleanup logic always in a finally block rather than duplicated across both the success and failure paths of a test. None of these four rules require debate every time they come up in a pull request once they’re written down once, and that’s really the entire point of a style guide for async code specifically — it converts the same judgment calls this article has walked through into decisions a team makes collectively and rarely, rather than decisions each individual engineer relitigates privately in every single pull request they open, which is both slower for the team and a source of exactly the kind of subtle inconsistency that makes a codebase feel unfamiliar even to engineers who’ve been contributing to it for months.

Quick reference: common async/await questions mapped to the section that answers them

For anyone using this piece as a reference rather than reading it start to finish, here’s a fast lookup by the kind of problem you’re actually facing right now.

  • “My test fails intermittently, only in CI” — start with the missing-await mistakes near the top, then the full debugging walk-through later in the article.
  • “I don’t know whether to use Promise.all or Promise.allSettled” — see the direct comparison in the mistakes section and the interview question bank that expands on it.
  • “TypeScript won’t let me access error.message in my catch block” — the error handling section, and the strict null checks section for the related null-narrowing pattern.
  • “My retry/backoff logic doesn’t seem to actually wait between attempts” — the setTimeout-promisify pattern and the full ApiTestClient build-out.
  • “I need to test a WebSocket or streaming response, not a simple request/response” — the WebSocket section and the streaming/async iterator section.
  • “My suite is too slow and I want to speed it up without rewriting everything” — the sequential-await mistakes, the CI parallelization section, and the case study near the end, which walks through exactly this scenario with real before/after numbers.
  • “I’m prepping for an interview and want the highest-yield material to review” — the two interview question banks, the cheat sheet ranking the most common gotchas, and the syntax reference table.

Bookmark this section specifically if you’re coming back to this piece under time pressure rather than reading it end to end — it’s designed to route you to the relevant depth quickly rather than making you re-read everything to find the one paragraph that actually answers your current problem.

One final scenario: async/await mistakes that only appear under real concurrency, not in a single local run

Everything catalogued in this article assumes a fairly direct cause-and-effect relationship between a specific async mistake and a specific observable failure. There’s a smaller, nastier category worth flagging on the way out: bugs that only manifest when multiple tests genuinely run concurrently against a shared, stateful backend — not because any single test’s async code is wrong in isolation, but because two tests’ correctly-written async operations interleave in a way neither test author anticipated.

// Test A
test('increments the global counter', async () => {
  const before = await apiClient.get<{ count: number }>('/counter');
  await apiClient.post('/counter/increment');
  const after = await apiClient.get<{ count: number }>('/counter');
  expect(after.count).toBe(before.count + 1); // fails if Test B increments in between
});

// Test B, running concurrently in a different worker against the same shared counter
test('resets the global counter', async () => {
  await apiClient.post('/counter/reset');
});

Neither test, read individually, has a missing await, an unhandled rejection, or any of the mistakes catalogued throughout this piece. Both are internally correct. The bug is architectural: they share mutable state across a genuinely concurrent execution environment, and nothing about async/await syntax protects against that, because it’s not an async/await problem at all — it’s a test isolation and test data design problem that happens to surface through async timing, which makes it easy to misdiagnose as one of the syntax-level issues covered above when it’s actually something a step earlier in the design. The fix is almost never in the async code itself; it’s in giving each test (or each worker) its own isolated slice of state — a unique counter ID, a unique tenant, a fresh database schema per worker — so that concurrent correctness at the infrastructure level, not just at the individual test’s async code level, is actually guaranteed. Worth keeping in your back pocket as the answer to “I’ve checked every await and everything looks correct and it’s still flaky” — sometimes the async code genuinely is fine, and the real problem is one level up, in what that async code is concurrently operating on.

Closing summary

Async/await in TypeScript is a small piece of syntax carrying an outsized amount of responsibility in any serious test automation framework — it’s the mechanism through which nearly every meaningful interaction with a browser, an API, or a database gets expressed. Everything covered in this piece, from the basic keyword placement rules through the deepest concurrency-under-load edge cases, ultimately comes back to the same handful of habits: await what needs awaiting, choose the right combinator for concurrent operations, type your errors honestly instead of reaching for shortcuts, and treat every promise-returning expression as a decision rather than an afterthought. Master that, and the rest — the interview questions, the code reviews, the flaky-test investigations — gets considerably easier, because you’re no longer debugging the syntax. You’re debugging the actual system, which is the job you signed up for in the first place.

One more FAQ worth adding: “Do I need to know all of this to pass a mid-level interview, or is this senior-level depth?”

Fair question, and worth answering honestly rather than implying everything above is table stakes for every level. The core syntax, the missing-await mistake, basic try/catch error handling, and the difference between Promise.all and sequential awaits are reasonable expectations at any level, including junior QA automation roles. The Promise.allSettled versus Promise.all distinction, generic async utilities like pollUntil, AbortController-based cancellation, and the deeper event-loop mechanics start to separate mid-level from senior candidates. The framework-governance material — onboarding conventions, style guide decisions, the case study walking through a systematic audit of an existing codebase — is squarely what a Lead SDET, QA Architect, or Automation Manager interview is probing for, and it’s reasonable, not a red flag, if you’re stronger on the syntax-and-mistakes half of this article than the framework-governance half when you’re earlier in that career progression. What matters more than having every section memorized is being able to speak concretely and specifically about whichever of these areas you do have real experience in, rather than giving vague, general answers across all of them — a specific, detailed answer about one real flaky-test investigation you’ve actually run is worth more in an interview than a surface-level summary of every topic covered in this piece.

And if you’re on the other side of that conversation — screening candidates for a QA Lead or SDET Lead opening — the questions in this article that tend to differentiate best aren’t the definitional ones (“what is async/await”) but the ones that require walking through consequences: what happens if you forget an await, what breaks if you pick Promise.all when you meant allSettled, how you’d design a retry layer, how you’d track down a flaky test that’s resisted three previous debugging attempts. Candidates who’ve genuinely lived through these scenarios answer with specifics, timing details, and war stories; candidates who’ve only studied the syntax tend to answer in generalities that sound correct but don’t hold up under one or two natural follow-up questions.

That’s a wrap on TypeScript async/await from syntax to production-grade framework practice — treat the sections above as a working reference to revisit whenever a specific mistake, interview question, or flaky test brings you back to it, rather than something to absorb in one sitting.

Save it, share it with whoever else on your team is auditing async TypeScript code this quarter, and revisit the cheat sheet and troubleshooting table specifically the next time a flaky test lands on your desk — most of the time, the root cause is already sitting somewhere in the list above, waiting to be recognized rather than rediscovered from scratch.

If nothing else sticks, let this be the one habit that does: every time you type await, or deliberately choose not to, ask yourself whether that choice is something you could defend in a code review — out loud, specifically, to a colleague who’s going to ask “why.” If the answer comes easily, you’ve internalized the material in this article. If it doesn’t yet, that’s fine too — that’s exactly what this piece is here for you to come back to.

Good luck with the interview prep, and with the next flaky test — you now have a real checklist to run it against instead of just a hunch.

🔥 Continue Your Learning Journey

Want to go beyond Playwright with Typescript setup and crack interviews faster? Check these hand-picked guides:

👉 🚀 Master TestNG Framework (Enterprise Level)
Build scalable automation frameworks with CI/CD, parallel execution, and real-world architecture
➡️ Read: TestNG Automation Framework – Complete Architect Guide

👉 🧠 Learn Cucumber (BDD from Scratch to Advanced)
Understand Gherkin, step definitions, and real-world BDD framework design
➡️ Read: Cucumber Automation Framework – Beginner to Advanced Guide

👉 🔐 API Authentication Made Simple
Master JWT, OAuth, Bearer Tokens with real API testing examples
➡️ Read: Ultimate API Authentication Guide

👉 ⚡ Crack Playwright Interviews (2026 Ready)
Top real interview questions with answers and scenarios
➡️ Read: Playwright Interview Questions Guide

Tags:

Async AwaitPlaywrightPromisesSDET Interview QuestionsSeleniumTest AutomationTypeScriptTypeScript for QA
Author

Ajit Marathe

Follow Me
Other Articles
TypeScript Record
Previous

TypeScript Record<K, V>: Typed Key-Value Collections Explained

TypeScript Optional Parameters
Next

TypeScript Optional & Default Parameters Explained (With Examples)

No Comment! Be the first one.

    Leave a Reply Cancel reply

    Your email address will not be published. Required fields are marked *

    Recent Posts

    • TypeScript Optional & Default Parameters Explained (With Examples)
    • TypeScript Async/Await: Definition, Syntax & Examples for QA Engineers
    • TypeScript Record: Typed Key-Value Collections Explained
    • TypeScript Generics: Definition, Syntax & Examples (Beginner-Friendly Guide)
    • TypeScript Functions: Typing Parameters, Return Types & Examples

    Categories

    • AI
    • AI Code Review & Risk-Based Testing
    • AI Prompts for QA
    • AI QA Careers
    • AI Test Automation / MCP Testing
    • AI Test Case Generation
    • AI-Powered Test Maintenance
    • API Authentication
    • API Testing
    • API Testing Interview Questions
    • Blogs
    • C#
    • Cucumber
    • Git
    • Java
    • Java coding
    • Java Interview Prepartion
    • LLM Testing / AI Evaluation
    • Playwright
    • REST Assured Interview Questions
    • Selenium
    • Test Lead/Test Manager
    • TestNG
    • Typescript
    • About
    • Privacy Policy
    • Contact
    • Disclaimer
    Copyright © 2026 — QATRIBE. All rights reserved. Learn • Practice • Crack Interviews