JavaScript for Testers
Async JavaScript for Testers: Promises, await and the Event Loop in Your Tests
The async JavaScript bugs that break test suites: forEach with await, missing awaits on Playwright assertions, Promise.all misuse and how the event loop orders work.
Table of contents
- The mental model in three sentences
- Pitfall 1: await inside forEach
- Pitfall 2: Promise.all versus sequential, and choosing correctly
- Pitfall 3: floating promises in Playwright
- Pitfall 4: unhandled rejections
- The event loop: microtasks versus macrotasks
- await does not make things sequential across tests
- Practise it
- Key takeaways
Almost everything a test does in JavaScript is asynchronous: driving a browser, calling an API, reading a file, querying a database. That makes async behaviour the single biggest source of confusing test failures in JavaScript and TypeScript suites. Tests that pass when they should fail. Assertions that run after the test has finished. Loops that “work” but check nothing.
You do not need to know the ECMAScript specification by heart to avoid these bugs. You need a clear mental model of what a promise is, what await actually does, and how the event loop decides what runs next. This article builds that model with examples from real test code, using Node 22 and Playwright.
The mental model in three sentences
- A promise is an object representing a value that will be available later (or an error that will be thrown later).
awaitpauses the currentasyncfunction until the promise settles, and lets other work run in the meantime. It does not block the thread.- If nothing
awaits a promise, nothing waits for it. The code after it runs immediately, and the result (or error) arrives whenever it arrives.
Point three is behind most of the bugs below.
Pitfall 1: await inside forEach
This looks correct and is one of the most common bugs in test helpers:
// Looks like it deletes every user before continuing. It does not.
async function cleanUp(api: APIRequestContext, ids: number[]) {
ids.forEach(async (id) => {
await api.delete(`/v1/users/${id}`);
});
console.log('cleanup done'); // logs before any delete has finished
}
Array.prototype.forEach calls the callback and ignores its return value. An async callback returns a promise, so forEach throws away every promise it receives. cleanUp returns immediately. If the test then runs, it races against the deletes. If one delete fails, the rejection is not attached to anything your test awaits.
The same applies to map without collecting the result, filter with an async predicate (a promise is always truthy, so nothing is filtered), and some or every with async callbacks.
Fix A: sequential with for…of
async function cleanUp(api: APIRequestContext, ids: number[]) {
for (const id of ids) {
const res = await api.delete(`/v1/users/${id}`);
expect(res.ok()).toBeTruthy();
}
}
Each delete finishes before the next starts. Use this when order matters, when the server dislikes concurrent requests, or when you are acting on a single browser page.
Fix B: concurrent with Promise.all
async function cleanUp(api: APIRequestContext, ids: number[]) {
const responses = await Promise.all(ids.map((id) => api.delete(`/v1/users/${id}`)));
for (const res of responses) expect(res.ok()).toBeTruthy();
}
All deletes start at once and Promise.all waits for every one. The results come back in the same order as the input, regardless of which finished first.
Pitfall 2: Promise.all versus sequential, and choosing correctly
Promise.all is faster for independent I/O, but it has semantics worth knowing:
- It rejects as soon as any promise rejects (“fail fast”), with that first error. The other operations keep running; they are not cancelled, and their results are lost.
- It does not limit concurrency. Mapping a thousand IDs to requests fires a thousand requests at once, which can trip rate limits or overwhelm a test environment.
- It is not useful for actions on the same page. Browser actions on a single page are effectively serial anyway, and running them concurrently creates races in what the user would see.
When you need every result even if some fail, use Promise.allSettled:
const results = await Promise.allSettled(ids.map((id) => api.delete(`/v1/users/${id}`)));
const failed = results.filter((r) => r.status === 'rejected');
expect(failed, `failed deletes: ${failed.length}`).toHaveLength(0);
When you need to cap concurrency, process in fixed-size batches:
async function inBatches<T, R>(items: T[], size: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const results: R[] = [];
for (let i = 0; i < items.length; i += size) {
const batch = items.slice(i, i + size);
results.push(...(await Promise.all(batch.map(fn))));
}
return results;
}
The legitimate Promise.all in Playwright
There is one pattern where starting two things together is exactly right: waiting for an event that your action triggers. You must start listening before the action, or you can miss the event:
// Start waiting first, then act, then await
const popupPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'Open invoice' }).click();
const popup = await popupPromise;
// Equivalent with Promise.all
const [response] = await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/orders') && r.request().method() === 'POST'),
page.getByRole('button', { name: 'Place order' }).click(),
]);
expect(response.status()).toBe(201);
Both forms are correct. The first is usually easier to read.
Pitfall 3: floating promises in Playwright
A floating promise is one that is created and never awaited. In Playwright tests, the most dangerous floating promise is an un-awaited web-first assertion:
test('saves the profile', async ({ page }) => {
await page.goto('/profile');
await page.getByLabel('Display name').fill('Ada');
await page.getByRole('button', { name: 'Save' }).click();
expect(page.getByRole('status')).toHaveText('Saved'); // missing await
});
toHaveText on a locator is asynchronous: it retries until the text matches or the timeout expires, and returns a promise. Without await, the test function returns straight after starting the assertion. Depending on timing, the test may pass without the assertion ever completing, or the failure may surface after the test has ended as a confusing error. Either way the test is not testing what it claims.
The rule: web-first assertions on locators and pages need await. Plain value assertions (expect(2 + 2).toBe(4)) are synchronous and do not.
| Code | Needs await? |
|---|---|
expect(locator).toBeVisible() |
Yes |
expect(page).toHaveURL(/dashboard/) |
Yes |
expect(response).toBeOK() |
Yes |
expect.poll(() => fn()).toBe(3) |
Yes |
expect(await locator.textContent()).toBe('Saved') |
The assertion no; the textContent() call yes |
expect(items).toHaveLength(3) on an array |
No |
Other common floating promises: calling a page object method without await (loginPage.login(user)), a helper that forgets to return its promise, and page.route(...) set up without await before navigation.
Let the tooling catch it
You should not rely on code review for this. Two lint rules catch most cases:
@typescript-eslint/no-floating-promisesfrom typescript-eslint flags any promise-returning call whose result is not handled. It needs type information, so it works in TypeScript projects with typed linting enabled.playwright/missing-playwright-awaitfromeslint-plugin-playwrightspecifically flags un-awaited Playwright assertions and APIs, and works without type information.
Pitfall 4: unhandled rejections
A rejected promise that nobody handles is an unhandled rejection. Since Node 15, the default behaviour is to throw on unhandled rejections, which crashes the process unless something handles the error event. Test runners generally intercept these and report them, but the error often appears attached to a different test from the one that caused it, or as a vague worker failure.
Typical sources in test code:
// 1. Fire-and-forget in a hook
test.afterEach(async ({ request }) => {
request.delete('/v1/test-data'); // not awaited; a failure surfaces later, somewhere else
});
// 2. Creating a promise early and awaiting it only on some paths
const resPromise = request.get('/v1/slow-report');
if (!featureEnabled) return; // resPromise may reject with nothing listening
const res = await resPromise;
The fixes are the same as for floating promises: await everything you start, and if you deliberately start something early, make sure every code path awaits or handles it.
A related trap is try/catch around a promise you return without awaiting:
async function getUser(id: number) {
try {
return request.get(`/v1/users/${id}`); // the catch block never sees a rejection
} catch (e) {
throw new Error(`Failed to fetch user ${id}`, { cause: e });
}
}
return await request.get(...) makes the catch work, because the rejection now happens inside the try block.
The event loop: microtasks versus macrotasks
To reason about ordering, you need a simplified picture of the event loop:
- Run the current synchronous code to completion (the call stack empties).
- Run all queued microtasks: promise reactions (
.then,.catch,.finally, the continuation afterawait) andqueueMicrotaskcallbacks. If a microtask queues another microtask, that runs too, before moving on. - Run one macrotask (also called a task): a timer callback (
setTimeout,setInterval), an I/O callback, a UI event in the browser. - Repeat from step 2.
The practical consequence: promise continuations always run before timers, even a setTimeout with a delay of zero.
Predict the output
Read this carefully and write down the order before checking the answer.
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
queueMicrotask(() => console.log('D'));
(async () => {
console.log('E');
await null;
console.log('F');
})();
console.log('G');
In Node, process.nextTick callbacks have their own queue that is processed ahead of promise microtasks in most situations. Do not write test code that depends on that ordering; if your test needs a specific order, express it with await.
Why this matters in tests
- Fake timers. When a test framework fakes
setTimeout, advancing the clock runs timer callbacks, but any promise continuations they schedule still need the microtask queue to drain. This is why fake-timer tests sometimes need an extraawait(or the framework’s async timer helpers) before assertions see the result. - “It works if I add a sleep.” A sleep yields to the event loop, letting pending work finish. That is a symptom of a missing
awaiton the work itself, not a fix. - Synchronous assertions after async triggers. Code that triggers an update and immediately reads state reads the old state, because the update is queued, not done.
await does not make things sequential across tests
One last misconception. await orders operations within one async function. It says nothing about other tests running in parallel workers, which are separate processes. If two tests share data, await will not save you; isolation will.
Practise it
Async ordering is a staple of SDET interviews, usually as a predict-the-output question or as “find the bug in this helper”. The JavaScript Challenges include executable async exercises, and the JavaScript interview questions cover promises and the event loop at different experience levels.
Key takeaways
awaitonly waits for the promise in front of it. Anything not awaited runs on its own schedule.- Never use
forEachwith async callbacks. Usefor...offor sequential work andPromise.allwithmapfor concurrent work. Promise.allfails fast and does not limit concurrency;Promise.allSettledcollects every outcome.- Always
awaitPlaywright web-first assertions, page object calls and route setup. Enforce it withno-floating-promisesandplaywright/missing-playwright-awaitin CI. - Unhandled rejections surface far from their cause. Await everything you start.
- Microtasks (promise continuations) run before macrotasks (timers). If a sleep fixes a test, look for the missing
await.
#javascript#typescript#async#playwright#node
Related interview questions
- Explain the event loop, and why await inside forEach does not wait.
- Compare Promise.all, Promise.allSettled, Promise.race and Promise.any. When would you use each in test code?
- What is the difference between an interface and a type alias in TypeScript?
- What is the difference between == and ===, and how do you compare objects in tests?
SDETRole Editorial Team
Practising SDETs and QA engineers who write, test and review every SDETRole article and lab. Code samples are run against current tool versions before publishing.