PRACTICAL GUIDE / Selenium JavaScript async await
Selenium JavaScript Async/Await Patterns That Preserve Command Order
Preserve Selenium JavaScript command order with explicit async/await, sequential iteration, failure-safe helpers, and awaited WebDriver cleanup in Node tests.
In this guide12 sections
- Build One Explicit Dependency Chain
- Write the Happy Path as Serialized Browser Work
- Understand Thenables Without Depending on Cleverness
- Return the Test Promise to the Runner
- Replace async forEach With Sequential Iteration
- Reserve Promise.all for Real Independence
- Preserve the Primary Error When Quit Also Fails
- Keep Page Objects Asynchronous at Their Edges
- Diagnose Ordering Failures From the First Missing Boundary
- Balance Readability and Throughput
- Audit Every Async Boundary
- End With Causal Code
What you will learn
- Build One Explicit Dependency Chain
- Write the Happy Path as Serialized Browser Work
- Understand Thenables Without Depending on Cleverness
- Return the Test Promise to the Runner
Selenium JavaScript code is reliable when command dependencies are visible in the source. Navigation must finish before a page-specific lookup, lookup must produce the intended element before an interaction, and the final assertion must settle before the session is released. Missing one await breaks that chain even if a fast local run happens to pass.
Treat every WebDriver call as asynchronous work with an owner and a result. Await dependent commands in order, return promises from helpers, use sequential loops for one browsing context, and await cleanup in finally. Parallel syntax is not useful when the browser state itself is sequential.
Build One Explicit Dependency Chain
The Selenium JavaScript API uses promises and thenable objects throughout the binding. Builder.build(), navigation, element interactions, waits, script execution, screenshots, and quit() all cross asynchronous boundaries. JavaScript continues after a call until an await, return, or promise chain makes completion part of the control flow.
Command order is therefore a program property, not a comment or naming convention. A line appearing first does not mean its promise settled first. If the next line needs the side effect or value, await the prior line at that boundary.
Animated field map
Awaited WebDriver Command Chain
An async test waits for each remote result before issuing a dependent command and releases the session through an awaited finally path.
01 / async test
Async Test
The runner receives and awaits the promise returned by the test function.
02 / awaited command
Awaited WebDriver Command
Navigation or interaction remains pending until its promise settles.
03 / remote response
Remote Response
The binding resolves a value or throws the protocol error at the await point.
04 / dependent command
Dependent Command
The next lookup, action, or assertion starts only after required state exists.
05 / finally quit
Finally Block Quit
An awaited quit releases the session after success or failure.
Write the Happy Path as Serialized Browser Work
A test should expose the browser's causal order without relying on hidden promise behavior. Await the builder, navigation, each dependent element operation, the condition that marks completion, and values used in assertions.
const assert = require('node:assert/strict');
const { Builder, Browser, By, until } = require('selenium-webdriver');
async function loginAs(user) {
const driver = await new Builder().forBrowser(Browser.CHROME).build();
try {
await driver.get('https://example.test/login');
const email = await driver.findElement(By.name('email'));
await email.sendKeys(user.email);
const password = await driver.findElement(By.name('password'));
await password.sendKeys(user.password);
const submit = await driver.findElement(By.css('button[type="submit"]'));
await submit.click();
await driver.wait(until.urlContains('/dashboard'), 10_000);
const heading = await driver.findElement(By.css('main h1'));
assert.equal(await heading.getText(), 'Dashboard');
} finally {
await driver.quit();
}
}The timeout is a suite policy input in real code, not a universal value. The ordering is the lesson: each command that changes or reads shared browsing-context state settles before the next dependent operation begins. The official driver session documentation also places creation and quitting around the commands that use the session.
Understand Thenables Without Depending on Cleverness
findElement can return a WebElementPromise, which is both a WebElement-facing proxy and a thenable. This permits concise expressions such as await driver.findElement(locator).click(). That can be readable for one operation, but awaiting the element separately gives a clearer failure boundary when several actions use it.
async function readOrderTotal(driver) {
const total = await driver.findElement(By.css('[data-testid="order-total"]'));
const visible = await total.isDisplayed();
assert.equal(visible, true);
return await total.getText();
}Do not store unresolved thenables in page-object fields during construction. Constructors cannot be awaited, and later methods may fail far from the lookup that created the promise. Store locators, then resolve elements inside async methods. This also avoids holding stale element references across page changes.
An async helper should either return a plain value obtained through awaits or return the promise that its caller must await. A helper that starts a command and returns undefined has detached the work from the test lifecycle.
Return the Test Promise to the Runner
Most Node test runners understand an async test function because it returns a promise. The runner can mark the test complete only after that promise settles. If a wrapper calls an async function without returning or awaiting it, the runner may report success while browser work continues in the background.
describe('account access', function () {
it('opens the dashboard', async function () {
await loginAs({
email: process.env.TEST_EMAIL,
password: process.env.TEST_PASSWORD,
});
});
});Avoid mixing callback-style completion such as done with an async test. Two completion channels create ambiguous ownership: the callback can fire before a promise rejects, or the promise can settle while the callback never runs. Pick the runner's promise contract and use it throughout helpers and hooks.
At process level, do not suppress unhandledRejection to keep CI green. An unhandled rejection is evidence that asynchronous work escaped its owner. Fix the missing return or await and let the test fail at the responsible boundary.
Replace async forEach With Sequential Iteration
Array forEach does not await promises returned by its callback. It invokes all callbacks and returns immediately. That is especially dangerous for a single WebDriver because every callback may navigate or mutate the same browsing context.
// Incorrect: the outer function does not await callback promises.
labels.forEach(async (label) => {
await driver.findElement(By.linkText(label)).click();
});
// Correct for dependent operations in one browser.
for (const label of labels) {
const link = await driver.findElement(By.linkText(label));
await link.click();
await driver.navigate().back();
}Other array helpers have similar traps. map returns an array of promises, which is useful only when the caller deliberately awaits them. filter expects a synchronous truth value, so an async predicate does not filter by its eventual boolean. For browser steps, a plain for...of loop is usually the honest representation.
Reserve Promise.all for Real Independence
Promise.all is not a general speed switch. Two commands against the same page can depend on focus, navigation, DOM replacement, window selection, or alert state even when the JavaScript expressions appear unrelated. Starting them together removes the explicit order that makes failures reproducible.
Parallel promise aggregation is appropriate for independent non-browser work, such as reading several fixture files before session creation. It can also coordinate separate WebDriver instances when each task owns its own session and data. The unit of parallelism is the owned session, not arbitrary commands inside one session.
async function runAcrossBrowsers(cases) {
const results = await Promise.all(
cases.map(async ({ browser, url }) => {
const driver = await new Builder().forBrowser(browser).build();
try {
await driver.get(url);
return { browser, title: await driver.getTitle() };
} finally {
await driver.quit();
}
}),
);
return results;
}This pattern creates a complete lifecycle inside each mapped async function. Bound the number of cases to local or Grid capacity; Promise.all otherwise attempts every session creation at once.
Preserve the Primary Error When Quit Also Fails
A simple finally { await driver.quit(); } guarantees an attempt, but a rejected quit can replace an earlier assertion error. Production helpers should retain the primary failure and report cleanup failure as secondary evidence. If the test itself succeeded, a failed quit should still fail the lifecycle because the session may have leaked.
async function withDriver(browser, useDriver) {
let driver;
let primaryError;
try {
driver = await new Builder().forBrowser(browser).build();
return await useDriver(driver);
} catch (error) {
primaryError = error;
throw error;
} finally {
if (driver) {
try {
await driver.quit();
} catch (quitError) {
if (primaryError) {
console.error('WebDriver quit failed after test failure', quitError);
} else {
throw quitError;
}
}
}
}
}The conditional matters because construction may fail before driver is assigned. Each additional resource should have its own guarded release so one cleanup error does not skip the rest.
Keep Page Objects Asynchronous at Their Edges
Page objects should store driver and locators, then expose async actions and observations. Do not hide an unawaited command behind a method with a synchronous-looking name. Callers should see await checkout.submit() and know when the page transition has completed.
class CheckoutPage {
constructor(driver) {
this.driver = driver;
this.placeOrder = By.css('[data-testid="place-order"]');
this.confirmation = By.css('[data-testid="confirmation-number"]');
}
async submit() {
const button = await this.driver.findElement(this.placeOrder);
await button.click();
const result = await this.driver.wait(
until.elementLocated(this.confirmation),
10_000,
);
return await result.getText();
}
}Return a domain value after the page reaches a defined state. That is stronger than returning immediately after click(), because the caller otherwise has to guess which wait proves completion.
Diagnose Ordering Failures From the First Missing Boundary
If a test finishes before the browser action, inspect whether the runner received the async promise. If actions happen in an unexpected sequence, search for missing awaits, async callbacks passed to forEach, detached helper calls, and Promise.all against one driver. If teardown races with a command, a helper likely returned before its WebDriver promise settled.
Errors that appear as unhandled rejections usually identify work that escaped the test promise. Errors at quit() can indicate the session was already closed, the node disappeared, or another code path released shared state. Do not add arbitrary sleeps; they change timing without restoring ownership.
Balance Readability and Throughput
Awaiting dependent commands can look verbose, but it documents protocol boundaries and gives stack traces useful source lines. Dense chains are shorter but can obscure which lookup or action failed. Choose named intermediate values when they carry domain meaning or improve diagnostics.
Parallel browsers can improve throughput at the cost of more Grid slots, accounts, memory, and artifact coordination. Parallel commands within one browser rarely provide the same benefit because they still compete over one stateful browsing context. Scale by complete session lifecycles.
Audit Every Async Boundary
- The test runner receives the promise returned by every async test and hook.
- Every dependent WebDriver command or result is explicitly awaited.
- Page objects store locators rather than unresolved element promises.
- Helpers return awaited domain values or promises their callers await.
- Sequential browser steps use
for...of, not asyncforEach. Promise.allis limited to independent work or separate owned drivers.- Session creation and
quit()both belong to onetry/finallylifecycle. - Quit is awaited, and cleanup failure does not erase a primary test failure.
- Parallel session count is bounded to browser, Grid, and test-data capacity.
End With Causal Code
Reliable Selenium JavaScript reads like the browser's actual dependency graph. Each awaited response authorizes the next dependent command, and one final awaited cleanup closes the session. Make that causality explicit and async failures stop looking random: they appear where ownership or order was genuinely broken.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Selenium documentation
Selenium Project
Canonical WebDriver, Grid, waits, element, and browser automation guidance.
- 02WebDriver standard
W3C
The browser automation protocol specification behind WebDriver implementations.
FAQ / QUICK ANSWERS
Questions testers ask
Do Selenium JavaScript commands need to be awaited individually?
Await every command whose completion or result is required by the next step. Explicit awaits make navigation, element lookup, interaction, assertion, and cleanup order visible.
Why is async forEach unsafe for Selenium command sequences?
forEach ignores the promises returned by its callback. The outer function can continue or finish while WebDriver work is still pending; use for...of for sequential steps.
Can Promise.all run several commands against one WebDriver faster?
It is a poor fit for commands that share one browsing context because their effects and observations are dependent. Use it for truly independent work or separate owned drivers.
Should driver.quit be awaited in a finally block?
Yes. Awaiting quit keeps the test process alive until the remote session is released. Guard quit errors so they do not hide an earlier test failure.
What is a WebElementPromise in the JavaScript binding?
It is a thenable returned by element lookup that can participate in promise chains. Awaiting it gives an ordinary WebElement and makes failures occur at a clear source line.
RELATED GUIDES
Continue the learning route
GUIDE 01
Selenium Wait Commands: Implicit, Explicit, and Fluent Waits
Selenium wait commands explained with implicit, explicit, and fluent waits, practical examples, timing mistakes, flake fixes, and stable patterns.
GUIDE 02
Instrument Selenium Commands with WebDriver Listeners
Instrument Selenium commands with WebDriverListener, structured timing events, failure screenshots, safe redaction, and useful execution timelines.
GUIDE 03
Selenium Grid Tutorial: Run Tests Across Browsers
Selenium Grid tutorial explaining architecture, setup, remote WebDriver, browser capabilities, parallel execution, Docker, CI, and debugging tips.
GUIDE 04
WebElement State Semantics: Displayed, Enabled, Selected, and ARIA
Assert Selenium WebElement state correctly across displayed, enabled, selected, computed ARIA role, accessible name, and product-level readiness.