The fact that findElement doesn't retry is the single biggest reason Selenium suites turn flaky. Pages load asynchronously. Buttons enable a quarter-second after they appear. AJAX calls finish two seconds after a click. If your test races the page, it loses. The fix is waits — and Selenium gives you three of them, only one of which you should actually use. This lesson is the case for explicit waits over everything else, and the rule that prevents the most-painful debugging session of your career: never mix implicit and explicit.
The timing problem
Run this and watch it fail intermittently:
driver.get("https://www.saucedemo.com");driver.findElement(By.id("user-name")).sendKeys("standard_user");driver.findElement(By.id("password")).sendKeys("secret_sauce");driver.findElement(By.id("login-button")).click();// IMMEDIATELY after click — without waiting:WebElement firstProduct = driver.findElement(By.cssSelector("[data-test='inventory-item']"));
On a fast laptop, the inventory page loads in 200ms and the test passes. On a slow CI runner, it takes 1.5 seconds and the test fails with NoSuchElementException. The bug isn't your code — it's that your code finished before the browser did.
Three approaches solve this. They are not equally good.
Approach 1: Thread.sleep() — never use this
The reflex of every beginner is to add a fixed sleep:
driver.findElement(By.id("login-button")).click();Thread.sleep(5000); // wait for the dashboard to loaddriver.findElement(By.cssSelector("[data-test='inventory-item']"));
Two problems, both terminal:
You waste time. The dashboard usually loads in 800ms. You wait 5 seconds anyway. Across 200 tests, that's 14 unnecessary minutes per run.
It still fails. When the dashboard is genuinely slow (CI under load, slow network, the API spikes), 5 seconds isn't enough. The test fails for the same reason it would have failed without the sleep — except now you've also burned 5 seconds before failing.
The right amount of time to wait is "until the thing is ready" — which Thread.sleep cannot express. It's the wrong tool. Banish it.
Approach 2: Implicit wait — convenient, but problematic
Implicit wait tells WebDriver to retry every findElement call for up to N seconds before throwing NoSuchElementException:
import java.time.Duration;driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10));// Every findElement now retries up to 10s before throwingdriver.findElement(By.id("login-button")).click();driver.findElement(By.cssSelector("[data-test='inventory-item']"));
It looks great — one line, applies globally, no boilerplate per element. So why is it almost universally discouraged in 2026?
It applies to everyfindElement, including ones that should fail fast. A negative test asserting "no error message exists" now waits 10 seconds for the absence — multiplied across many tests, your suite slows to a crawl.
It can't wait for conditions. "Element is visible," "element is clickable," "spinner has disappeared" — implicit wait can't express any of these. It only retries on findElement. The element can be in the DOM but invisible, and implicit wait happily returns it.
It interacts unpredictably with explicit waits. This is the killer. Selenium's docs warn against mixing the two; the resulting timing is implementation-defined and varies between drivers.
Implicit wait is a footgun dressed up as a convenience. Most experienced Selenium teams set it to 0 (the default) and never touch it again.
Approach 3: Explicit wait — the right answer
Explicit wait targets a specific element with a specific condition and a specific timeout:
The wait.until(...) call polls every 500ms (configurable). Each poll evaluates the condition. As soon as it's true, the wait returns the element. If 10 seconds pass without the condition being met, it throws TimeoutException with a descriptive message.
Three things make this strictly better than the alternatives:
Targeted. Only waits on the elements you actually care about. Negative-path tests that genuinely expect missing elements don't pay the timeout cost.
Condition-aware. Wait for visible, clickable, invisible, URL contains, text appears — not just "exists."
Per-call timeout. A login that's known to be slow can wait 30 seconds; a search that should be instant can wait 2. Different timeouts for different elements.
The three approaches, side by side
Three ways to wait — only one is right
🚫
Thread.sleep
Thread.sleep(5000)
Fixed delay regardless of page state
Wastes time when page is fast
Still fails when page is genuinely slow
Cannot express any condition
Banish it from your codebase
⚠️
Implicit wait
manage().timeouts().implicitlyWait(...)
Applies to EVERY findElement, globally
Slows down negative-path tests
Cannot wait for visibility, clickability, etc.
Mixes badly with explicit waits — UNDEFINED behaviour
Set to 0 and forget it exists
✅
Explicit wait
WebDriverWait + ExpectedConditions.until(...)
Targets a specific element + condition
Custom timeout per call
Polls every 500ms (configurable)
Express visibility / clickability / URL / text
Industry-standard answer
The rule you must internalise: never mix
The Selenium docs are explicit (the irony):
Warning: Do not mix implicit and explicit waits. Doing so can cause unpredictable wait times.
The reason: if you set a 10-second implicit wait and an explicit wait of 5 seconds for visibility, the resulting timeout is implementation-defined. Different drivers produce different behaviour. Worse, the symptoms vary — sometimes you wait 5 seconds, sometimes 10, sometimes 15. The flake hunt that follows is genuinely brutal.
The rule for the rest of this course (and your career): leave implicit wait at 0; use explicit waits everywhere.
// At the top of your test class — DON'T add this. The default of 0 is correct.// driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10)); // ❌
A first explicit-wait test
Rewriting the flaky test from the top of the lesson:
package com.mycompany.tests.tests;import io.github.bonigarcia.wdm.WebDriverManager;import org.openqa.selenium.By;import org.openqa.selenium.WebDriver;import org.openqa.selenium.WebElement;import org.openqa.selenium.chrome.ChromeDriver;import org.openqa.selenium.support.ui.ExpectedConditions;import org.openqa.selenium.support.ui.WebDriverWait;import org.testng.Assert;import org.testng.annotations.AfterMethod;import org.testng.annotations.BeforeMethod;import org.testng.annotations.Test;import java.time.Duration;public class ExplicitWaitTest { WebDriver driver; WebDriverWait wait; @BeforeMethod public void setup() { WebDriverManager.chromedriver().setup(); driver = new ChromeDriver(); wait = new WebDriverWait(driver, Duration.ofSeconds(10)); driver.get("https://www.saucedemo.com"); } @Test public void shouldWaitForInventoryAfterLogin() { driver.findElement(By.id("user-name")).sendKeys("standard_user"); driver.findElement(By.id("password")).sendKeys("secret_sauce"); driver.findElement(By.id("login-button")).click(); // Wait for the inventory page to actually render — not just the URL change WebElement firstItem = wait.until( ExpectedConditions.visibilityOfElementLocated( By.cssSelector("[data-test='inventory-item']") ) ); Assert.assertTrue(firstItem.isDisplayed()); } @AfterMethod public void teardown() { if (driver != null) driver.quit(); }}
Run it twenty times in a row. It should pass twenty times in a row, on a fast laptop and on a throttled CPU alike. That's the difference between a flaky test and a trustworthy one.
How Cypress and Playwright handle this
// Cypress — auto-waits on every command, no explicit wait neededcy.get("[data-test='inventory-item']").should("be.visible");// Playwright — same, with web-first assertionsawait expect(page.getByTestId("inventory-item")).toBeVisible();
Both modern frameworks bake auto-waiting into every command. The trade-off is exactly what this lesson is about: Selenium gives you explicit control (and explicit responsibility); Cypress and Playwright take that responsibility on themselves. Once you've mastered WebDriverWait (next lesson), the gap in DX is small.
⚠️ Common mistakes
Mixing implicit and explicit waits "to be safe." It's the opposite of safe. Setting a 10-second implicit wait alongside WebDriverWait(driver, 5) produces undefined behaviour — sometimes 5s, sometimes 10s, sometimes longer. The Selenium docs explicitly warn against it. Pick one strategy (explicit) and stick to it.
Catching TimeoutException and retrying in a loop. If your code does try { wait.until(...) } catch (TimeoutException e) { wait.until(...) }, you've effectively doubled the timeout while hiding the original failure. If 10 seconds isn't enough, increase the timeout — don't paper over the symptom.
Using Thread.sleep "just for now, I'll fix it later." "Later" never comes. Every Thread.sleep in the codebase is a future flake or a future slowdown. Either fix it now with an explicit wait, or write // FIXME — replace with explicit wait with a JIRA ticket — visible debt beats invisible debt.
🎯 Practice task
Replace timing footguns with proper waits. 30–40 minutes.
Add ExplicitWaitTest from this lesson to your project. Run it. It should pass.
Reproduce a flake. Remove the explicit wait — replace it with a direct driver.findElement(...) call right after loginButton.click(). Run the test 10 times via mvn test -Dtest=ExplicitWaitTest. On a fast machine you'll mostly pass. Now throttle your CPU: open Chrome DevTools → Performance → CPU throttling 6× slowdown. Run again. Watch the flake appear. Add the wait back. Watch it stop.
Try implicit wait. Add driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10)); to @BeforeMethod. Run a negative test that asserts "no error message exists" via findElements(By.id("error")).isEmpty(). Time how long it takes. The implicit wait makes the absence assertion expensive. Remove the implicit wait; the same assertion runs in milliseconds.
Mix implicit and explicit on purpose. Set a 10-second implicit wait and a 5-second explicit wait on the same driver. Add an assertion for an element that doesn't exist. Time how long the failure takes. Run a few times. Note the variance — that's the undefined behaviour the docs warn about. Then remove the implicit wait.
Stretch: add a private helper method to your test class:
Refactor shouldWaitForInventoryAfterLogin to use it: WebElement firstItem = waitVisible(By.cssSelector("[data-test='inventory-item']"));. The waitVisible(...) wrapper is the foundation of every Page Object's BasePage class — chapter 6 puts it there formally.
Next lesson: the catalogue of ExpectedConditions you'll lean on, the wait.until(...) patterns that turn into your daily vocabulary, and how to write your own custom conditions when the built-ins aren't enough.
// tip to track lessons you complete and pick up where you left off across devices.