Every unreliable Selenium suite I have seen had the same root cause, and it was never a Selenium bug. It was waiting for the wrong thing, or reading a value before waiting for it.
The intermediate track covered explicit waits. This lecture is about what to do when the built-in conditions are not enough, which at senior level is often.
Fluent waits: control over how you wait, not just how long.
const el = await driver.wait(
until.elementLocated(By.css('[data-testid="toast"]')),
10000,
'The save confirmation never appeared',
500, // poll every 500ms instead of the default
);
Two parameters people skip, and both matter at scale.
The message. Without it, a timeout says a condition was not met. With it, the failure says what the test expected in business terms. Across a large suite this single habit saves more diagnostic time than most refactors.
The polling interval. Faster polling detects the condition sooner and costs more round trips. On a remote Grid, polling every 100ms across a whole suite is real network load. Slower polling is cheaper and adds latency to every wait. 250 to 500ms is usually right, and it is a decision worth making rather than inheriting.
In Java the equivalent adds ignoring, which lets you tolerate specific exceptions while polling. Ignoring StaleElementReferenceException during a wait is the standard way to survive a re-rendering page.
Custom conditions: the real senior skill.
The built-in conditions cover existence, visibility and text. Real applications need conditions that are about your application:
const untilRowCountStable = (locator, timeout = 10000) => async (driver) => {
const first = (await driver.findElements(locator)).length;
await driver.sleep(200);
const second = (await driver.findElements(locator)).length;
return first === second && first > 0 ? first : null;
};
await driver.wait(untilRowCountStable(By.css('[data-testid="row"]')), 10000,
'The contact list never settled');
A condition function returns something truthy when satisfied and null or false otherwise. Selenium calls it repeatedly until it succeeds or times out.
That particular condition solves a common problem: a list that renders in stages, where waiting for "at least one row" catches it half-populated. Waiting for the count to stop changing is what you actually mean.
Other conditions worth building for a real application: waiting for all spinners to disappear, for a specific network-driven value to appear, for an animation to finish by checking a computed style, or for an element to be both visible and enabled and not covered.
The application-idle condition. Teams testing single-page apps often build a condition that asks the page whether it is busy:
const untilAppIdle = async (driver) =>
driver.executeScript('return window.__pendingRequests === 0');
This requires cooperation from developers to expose that flag, which is exactly the kind of testability negotiation senior engineers have. It is far more reliable than guessing, and it is worth asking for.
⚠️ Waiting for the network is not waiting for the screen.
A request completing does not mean the app has rendered the result. A condition that waits only for network idle will still hand you a page mid-render.
Where you can, wait for the visible outcome. Use network or idle conditions to supplement that, not to replace it.
The order that prevents most flakiness. Selenium's assertions never retry, so:
wait for the condition → read the value → assert.
Reading before waiting is the single most common cause of a flaky Selenium test, and it is invisible in code review unless you are looking for it. Make it a review checklist item.
Remember this: name your waits, tune the polling deliberately, build conditions about your application, and always wait before you read.
Reference: Waiting strategies.