Skip to content
SDETRole
SDETRole

Lesson 7 of 9 · Programming for Testers

Async Programming: Promises and async/await

How the event loop, promises and async/await really work, the async bugs that make automation flaky, and patterns for parallel work, timeouts and polling in Playwright and Node.

intermediate 8 min read Updated

You will learn to

  • Explain the event loop, promise states and what await actually does
  • Find and fix floating promises, async forEach and accidental sequential code
  • Use Promise.all, allSettled, race and any appropriately
  • Implement timeouts and condition-based polling instead of fixed sleeps

Prerequisites

  • JavaScript Essentials
  • Exception Handling

JavaScript Essentials introduced promises and the missing-await bug. This lesson goes deeper, because asynchronous behaviour is behind a large share of flaky JavaScript tests and is a favourite topic in SDET interviews. Once you understand what actually happens when you write await, many confusing failures become obvious.

All examples run in Node 22 as ES modules (.mjs files or "type": "module"), or in the JavaScript Lab.

Why JavaScript is asynchronous

JavaScript runs your code on a single thread. If it blocked while waiting for a browser, a network call or a file, nothing else could happen. Instead, slow operations are handed to the runtime (Node or the browser), and JavaScript carries on. When the operation finishes, a callback is queued and run later.

The event loop in brief

  1. Synchronous code runs to completion on the call stack.
  2. When the stack is empty, the runtime runs all queued microtasks (promise reactions, code after an await).
  3. Then it takes the next macrotask (a timer callback, an I/O completion), runs it, and drains microtasks again.
console.log('1 sync');
setTimeout(() => console.log('4 timer (macrotask)'), 0);
Promise.resolve().then(() => console.log('3 promise (microtask)'));
console.log('2 sync');

Output order: 1, 2, 3, 4. Even a zero-millisecond timer waits until the current code and all pending microtasks have finished. This ordering question appears often in interviews.

Promises, precisely

A promise represents the eventual result of an asynchronous operation. It is in one of three states:

State Meaning Settled?
Pending Not finished yet No
Fulfilled Finished with a value Yes
Rejected Failed with a reason (usually an Error) Yes

Once settled, a promise never changes state. You react to it with .then, .catch and .finally, or with await.

function delay(ms, value) {
  return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}

delay(100, 'done').then((v) => console.log(v)); // 'done' after ~100 ms

You rarely construct promises yourself in test code; Playwright, fetch and file APIs return them. Constructing one is mainly useful for wrapping callback-based APIs.

What await really does

await expr pauses the current async function until the promise settles, then resumes it with the value, or throws the rejection reason at that line. Other code, including other tests’ timers and the browser’s events, keeps running while it waits.

Three consequences:

  • Code after await runs later, as a microtask. Variables may have changed in the meantime if other code shares them.
  • An async function always returns a promise. Callers must await it too, all the way up.
  • A rejection that is awaited inside try is caught by catch; one that is not awaited is not.

The async bugs that cause flaky tests

1. Floating promises (missing await)

// BROKEN
test('saves profile', async ({ page }) => {
  await page.getByLabel('Name').fill('Ana');
  page.getByRole('button', { name: 'Save' }).click(); // not awaited
  await expect(page.getByRole('status')).toHaveText('Saved');
});

The click starts but the test moves on. The web-first assertion’s retries may hide the problem most of the time, and when the test or page closes first you get errors such as “Target page, context or browser has been closed”. Await every Playwright action and every helper that returns a promise. Enable @typescript-eslint/no-floating-promises to catch these automatically.

Playwright assertions on locators are async too: await expect(locator).toBeVisible() without await never actually checks anything before the test ends.

2. async callbacks in forEach

// BROKEN: forEach ignores the promises returned by the async callback
const ids = ['u1', 'u2', 'u3'];
ids.forEach(async (id) => {
  await deleteUser(id);
});
console.log('cleanup finished'); // printed before any deletion completes

forEach does not wait for anything. Use for...of for sequential work or Promise.all with map for concurrent work:

for (const id of ids) {
  await deleteUser(id); // one at a time
}

await Promise.all(ids.map((id) => deleteUser(id))); // all at once

3. Accidentally sequential code

// Slow: each request waits for the previous one
const user = await getUser(42);
const orders = await getOrders(42);
const prefs = await getPreferences(42);

// Fast: independent requests run concurrently
const [user2, orders2, prefs2] = await Promise.all([getUser(42), getOrders(42), getPreferences(42)]);

Sequential is correct when one step needs the previous result (create a user, then log in as that user). Otherwise, run independent setup concurrently.

4. Unhandled rejections

If a promise rejects and nothing ever handles it, Node 15+ treats it as an uncaught error and, by default, terminates the process. Test runners usually report it, but often against the wrong test. Floating promises are the usual source.

Combinators: all, allSettled, race, any

Method Settles when Result Use in tests
Promise.all All fulfil, or the first rejects Array of values, or the first error Parallel setup where any failure should fail the test
Promise.allSettled All settle Array of { status, value } or { status, reason } Health checks, cleanup where you want every result
Promise.race The first settles (fulfil or reject) That value or error Timeouts
Promise.any The first fulfils, or all reject First value, or an AggregateError Try mirrors or fallbacks
const services = ['users', 'orders', 'payments'];
const results = await Promise.allSettled(
  services.map((s) => fetch(`${base}/${s}/health`).then((r) => {
    if (!r.ok) throw new Error(`${s} returned ${r.status}`);
    return s;
  })),
);

const down = results.filter((r) => r.status === 'rejected').map((r) => r.reason.message);
if (down.length) throw new Error(`Unhealthy services: ${down.join(', ')}`);

allSettled lets you report every unhealthy service at once instead of only the first.

Note that Promise.all does not cancel the other operations when one fails; they keep running in the background. For requests, use an AbortController if cancellation matters.

Timeouts

Never let a helper wait forever. For fetch, the cleanest option is AbortSignal.timeout (available in Node 18+):

const res = await fetch(`${base}/reports/export`, { signal: AbortSignal.timeout(5_000) });

If the timeout expires, the promise rejects with a TimeoutError DOMException. For other promises, a race against a timer works:

function withTimeout(promise, ms, label) {
  let timer;
  const timeout = new Promise((_, reject) => {
    timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms} ms`)), ms);
  });
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}

Clearing the timer in finally prevents a stray timer from keeping the process alive.

Polling instead of sleeping

Fixed sleeps are slow when the system is fast and flaky when it is slow. Wait for a condition instead.

In Playwright, locator assertions already retry. For other conditions, such as an API reaching a state, use expect.poll or toPass:

import { test, expect } from '@playwright/test';

test('export job completes', async ({ request }) => {
  const start = await request.post('/api/exports', { data: { type: 'orders' } });
  const { id } = await start.json();

  await expect
    .poll(async () => {
      const res = await request.get(`/api/exports/${id}`);
      return (await res.json()).status;
    }, { timeout: 30_000, message: `export ${id} did not complete` })
    .toBe('complete');
});
await expect(async () => {
  const res = await request.get('/api/orders/latest');
  expect(res.status()).toBe(200);
  expect((await res.json()).state).toBe('shipped');
}).toPass({ timeout: 20_000 });

expect.poll retries a function until its return value matches; toPass retries a whole block until all its assertions pass. Outside Playwright, a small loop does the same job:

async function waitFor(condition, { timeout = 10_000, interval = 250, label = 'condition' } = {}) {
  const deadline = Date.now() + timeout;
  while (Date.now() < deadline) {
    if (await condition()) return;
    await new Promise((r) => setTimeout(r, interval));
  }
  throw new Error(`Timed out after ${timeout} ms waiting for ${label}`);
}

Async in Java and Python, briefly

The same concepts exist elsewhere, with different defaults:

  • Java is multi-threaded and most APIs (including Selenium’s) are blocking: each call waits before returning, so there is no await. Asynchronous work uses CompletableFuture, for example CompletableFuture.supplyAsync(() -> api.get("/users")) combined with allOf(...).join(). Java 21 also adds virtual threads, which make large numbers of blocking tasks cheap.
  • Python has async/await through asyncio, with asyncio.gather playing the role of Promise.all. Playwright for Python offers both a sync API and an async API; most pytest suites use the sync API through the pytest-playwright plugin, which avoids async code entirely.

Practice exercise

This cleanup helper is meant to delete test users concurrently, report all failures together, and give up after 10 seconds. It has several bugs. Find them and rewrite it.

async function cleanup(ids) {
  const failures = [];
  ids.forEach(async (id) => {
    try {
      deleteUser(id);
    } catch (e) {
      failures.push(id);
    }
  });
  if (failures.length) throw new Error('cleanup failed for ' + failures);
}
Show a model answer

Bugs:

  1. forEach with an async callback: cleanup returns before any deletion finishes.
  2. deleteUser(id) is not awaited, so its rejection is never caught by the try block and becomes an unhandled rejection.
  3. As a result failures is always empty when checked, so the function never reports anything.
  4. There is no timeout.
async function cleanup(ids, timeoutMs = 10_000) {
  const work = Promise.allSettled(ids.map((id) => deleteUser(id)));

  let timer;
  const timeout = new Promise((_, reject) => {
    timer = setTimeout(() => reject(new Error(`cleanup timed out after ${timeoutMs} ms`)), timeoutMs);
  });

  const results = await Promise.race([work, timeout]).finally(() => clearTimeout(timer));

  const failures = results
    .map((r, i) => (r.status === 'rejected' ? `${ids[i]} (${r.reason?.message ?? r.reason})` : null))
    .filter(Boolean);

  if (failures.length) {
    throw new Error(`cleanup failed for: ${failures.join(', ')}`);
  }
}

allSettled waits for every deletion and keeps each result in the same order as ids, so failures can be reported with their IDs and reasons. The timeout races against the whole batch. Note that deletions still in flight when the timeout fires keep running; if that matters, pass an AbortSignal into deleteUser.

Key takeaways

  • JavaScript is single-threaded; the event loop runs synchronous code, then microtasks (promise reactions), then macrotasks (timers, I/O).
  • await pauses only the current async function and turns rejections into thrown errors. Await every promise.
  • Avoid async callbacks in forEach; use for...of or Promise.all with map.
  • Pick the right combinator: all to fail fast, allSettled to see everything, race for timeouts, any for first success.
  • Replace sleeps with condition-based polling: web-first assertions, expect.poll, toPass, or a labelled waitFor helper.

Key terms

Finished this lesson?

Progress is saved in this browser. No account needed.

Type to search across SDETRole.

Full-text search by Pagefind · runs in your browser Open search page →