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
- Synchronous code runs to completion on the call stack.
- When the stack is empty, the runtime runs all queued microtasks (promise reactions, code after an
await). - 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
awaitruns later, as a microtask. Variables may have changed in the meantime if other code shares them. - An
asyncfunction always returns a promise. Callers mustawaitit too, all the way up. - A rejection that is awaited inside
tryis caught bycatch; 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 usesCompletableFuture, for exampleCompletableFuture.supplyAsync(() -> api.get("/users"))combined withallOf(...).join(). Java 21 also adds virtual threads, which make large numbers of blocking tasks cheap. - Python has
async/awaitthroughasyncio, withasyncio.gatherplaying the role ofPromise.all. Playwright for Python offers both a sync API and an async API; most pytest suites use the sync API through thepytest-playwrightplugin, 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:
forEachwith anasynccallback:cleanupreturns before any deletion finishes.deleteUser(id)is not awaited, so its rejection is never caught by thetryblock and becomes an unhandled rejection.- As a result
failuresis always empty when checked, so the function never reports anything. - 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).
awaitpauses only the current async function and turns rejections into thrown errors. Await every promise.- Avoid
asynccallbacks inforEach; usefor...oforPromise.allwithmap. - Pick the right combinator:
allto fail fast,allSettledto see everything,racefor timeouts,anyfor first success. - Replace sleeps with condition-based polling: web-first assertions,
expect.poll,toPass, or a labelledwaitForhelper.
Key terms
Finished this lesson?
Progress is saved in this browser. No account needed.
Practise it
Related interview questions
- Compare Promise.all, Promise.allSettled, Promise.race and Promise.any. When would you use each in test code?
- Explain the event loop, and why await inside forEach does not wait.
- How do you make a Selenium framework thread-safe for parallel execution? Explain ThreadLocal.
- Explain any, unknown and never. When should you use each?