You now have the tools — WebDriverWait, ExpectedConditions, FluentWait. This lesson is the map of failures you'll see in real codebases and the wait pattern that solves each. Six categories cover ~95% of timing-related test failures: StaleElementReferenceException, ElementClickInterceptedException, "exists but not interactable," dynamic content, SPA navigation, and animations. Each has a specific shape, and once you recognise the shape, the fix is mechanical.
1. StaleElementReferenceException
You found an element. The page re-rendered. Your reference now points to an element that no longer exists in the DOM:
// ProblemWebElement saveButton = driver.findElement(By.id("save"));// ... AJAX call updates the form ...saveButton.click(); // → StaleElementReferenceException
The fix: don't hold WebElement references across DOM mutations. Find immediately before use:
If the element is in a list you're iterating, reload the list on each iteration rather than caching the loop variable:
// Bad — list captured once, references go stale on re-renderList<WebElement> rows = driver.findElements(By.cssSelector("tr"));for (WebElement row : rows) { row.findElement(By.cssSelector(".delete-btn")).click(); // re-render after delete → next iteration references a stale row}// Good — count first, re-find each iterationint rowCount = driver.findElements(By.cssSelector("tr")).size();for (int i = 0; i < rowCount; i++) { driver.findElements(By.cssSelector("tr")) .get(0) .findElement(By.cssSelector(".delete-btn")) .click(); wait.until(ExpectedConditions.numberOfElementsToBe(By.cssSelector("tr"), rowCount - i - 1));}
2. ElementClickInterceptedException
The element exists, is visible, should be clickable — but another element is on top of it. A modal, a sticky header, a loading spinner that lingers half a beat too long:
ElementClickInterceptedException: Other element would receive the click:
<div class="loading-overlay">
The fix: wait for the obstructing element to go away first.
// Wait for the spinner/overlay to vanish, then clickwait.until(ExpectedConditions.invisibilityOfElementLocated(By.id("loading-overlay")));wait.until(ExpectedConditions.elementToBeClickable(By.id("submit"))).click();
The interception message tells you which element is blocking the click — read it. The fix is almost always a wait for that specific overlay's invisibility, not a wait for the click target's clickability (which is already true).
3. "Exists but not interactable"
ElementNotInteractableException — present in DOM but disabled, hidden by CSS (display: none), or rendered with zero size. presenceOfElementLocated happily returns it; the click then fails:
The rule: whenever the next call is a .click(), wait for elementToBeClickable, not visibilityOfElementLocated (and definitely not presenceOfElementLocated).
4. Dynamic content loading
An AJAX call updates a part of the page — the URL doesn't change, no full page navigation, but the element you care about isn't there yet:
// Click triggers a server call that fills in the order statusdriver.findElement(By.id("place-order")).click();// Wait for the SPECIFIC text to appear — not just "the page is loaded"wait.until(ExpectedConditions.textToBePresentInElementLocated( By.id("order-status"), "Confirmed"));
textToBePresentInElementLocated is the right tool when the element exists immediately but its content fills in asynchronously. The number of suites that incorrectly wait for the element (which is already present) and then read empty text is large.
5. SPA page transitions
Single-page apps change the URL via history.pushState without a full reload. The URL flips immediately; the new page's content arrives milliseconds later. Wait for both:
// Click triggers SPA navigation to /dashboarddriver.findElement(By.id("dashboard-link")).click();// Wait for URL change AND for the destination's main contentwait.until(ExpectedConditions.and( ExpectedConditions.urlContains("/dashboard"), ExpectedConditions.visibilityOfElementLocated(By.id("dashboard-widget"))));
Waiting only for the URL is the classic SPA pitfall — the URL changes a beat before React has rendered the new page. Pair the URL check with a content check, every time.
6. Animations
The element is "visible" by every measure, but it's still moving — sliding in from the side, fading from 50% to 100% opacity. Click during the animation and the click can land in the wrong place:
// Wait for the modal to be visible AND in its final geometric positionwait.until(driver -> { WebElement modal = driver.findElement(By.id("modal")); return modal.isDisplayed() && modal.getLocation().getY() > 100;});// Better — wait for the CSS transition to be overwait.until(driver -> { WebElement modal = driver.findElement(By.id("modal")); String opacity = modal.getCssValue("opacity"); return modal.isDisplayed() && "1".equals(opacity);});
A generic alternative that works almost everywhere: use the reduced-motion CSS preference by setting browser options to disable animations during tests. Cleaner than waiting for them to finish.
The decision tree
Test failed mid-actionRead the exception name carefully — it p…
StaleElementReferenceExceptionDOM changed since you found the element
ElementClickInterceptedExceptionAnother element is over the click target
ElementNotInteractableExceptionElement exists but is hidden or disabled
NoSuchElementExceptionElement wasn't in the DOM at the time of…
Re-find before usewait.until(elementToBeClickable(by)).cli…
Wait for overlay invisibility firstwait.until(invisibilityOfElementLocated(…
Use elementToBeClickableStricter than visibility — confirms visi…
Wait for visibility / presencewait.until(visibilityOfElementLocated(by…
The cheat-sheet rule: read the exception name, walk the tree to the wait that fixes it.
A complete sync-aware test
Putting every pattern from this lesson into one test against Sauce Demo:
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 SyncIssuesTest { 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"); // Robust login — wait for clickable on every interactive element wait.until(ExpectedConditions.elementToBeClickable(By.id("user-name"))) .sendKeys("standard_user"); driver.findElement(By.id("password")).sendKeys("secret_sauce"); driver.findElement(By.id("login-button")).click(); } @Test public void shouldHandleSpaNavigationToCart() { // Add an item — page re-renders the badge wait.until(ExpectedConditions.elementToBeClickable( By.id("add-to-cart-sauce-labs-backpack") )).click(); // Wait for the badge to update — content change without URL change wait.until(ExpectedConditions.textToBePresentInElementLocated( By.cssSelector(".shopping_cart_badge"), "1" )); // Navigate to cart — URL changes, then content renders driver.findElement(By.cssSelector(".shopping_cart_link")).click(); wait.until(ExpectedConditions.and( ExpectedConditions.urlContains("/cart.html"), ExpectedConditions.visibilityOfElementLocated(By.cssSelector(".cart_item")) )); Assert.assertEquals( driver.findElements(By.cssSelector(".cart_item")).size(), 1 ); } @Test public void shouldRefindAcrossReRenders() { // Add to cart, remove, add again — the button text/locator changes between Add and Remove // Find fresh each time to avoid staleness wait.until(ExpectedConditions.elementToBeClickable( By.id("add-to-cart-sauce-labs-bike-light") )).click(); wait.until(ExpectedConditions.elementToBeClickable( By.id("remove-sauce-labs-bike-light") // re-fetch — new id after add )).click(); wait.until(ExpectedConditions.elementToBeClickable( By.id("add-to-cart-sauce-labs-bike-light") // re-fetch — original id is back )).click(); wait.until(ExpectedConditions.textToBePresentInElementLocated( By.cssSelector(".shopping_cart_badge"), "1" )); } @AfterMethod public void teardown() { if (driver != null) driver.quit(); }}
Two tests, four sync patterns: SPA navigation with and(...), content-only update with textToBePresentInElementLocated, repeated re-finding to avoid staleness, and elementToBeClickable for safe clicks.
Catching StaleElementReferenceException and retrying. A try/catch with retry hides a design problem — the reference shouldn't be held across DOM mutations in the first place. Find immediately before use; the issue disappears, no retry needed.
Using Thread.sleep to "give animations time to finish." It works locally and fails on CI under load. Either disable animations via browser options (--disable-animations in some chrome flag set, or set * CSS animations to 0s), or wait on a stable property like opacity or final geometric position.
Treating every flake as "needs more wait time." Bumping every timeout to 30 seconds slows the suite without fixing the root cause. The right fix is almost always a different condition (clickable instead of visible, text-present instead of element-present), not a longer timeout on the wrong condition.
🎯 Practice task
Hunt and fix sync issues. 35–45 minutes.
Add SyncIssuesTest from this lesson to your project. Run both tests; both should pass.
Cause each exception on purpose. Write four short failing tests:
causeStaleElement — find the cart link before login, log in, then click the cached reference.
causeClickIntercepted — open Sauce Demo's burger menu and click an underlying inventory button while the menu overlay is open.
causeNotInteractable — find a hidden element (any element with display: none via CSS) and click it.
causeNoSuchElement — find an element by an ID that doesn't exist.
Read each exception. Then fix each one with the wait pattern from the lesson.
Refactor for reuse. Notice you used elementToBeClickable(...).click() four times in your sync tests. Add a helper to WaitHelpers:
public static void clickWhenReady(WebDriver driver, By locator) { new WebDriverWait(driver, Duration.ofSeconds(10)) .until(ExpectedConditions.elementToBeClickable(locator)) .click();}
Use it across your test classes. Test code shrinks; reliability stays the same.
Time the savings. Measure how long SyncIssuesTest takes with your wait patterns vs the same logic with Thread.sleep(2000) everywhere. The wait version finishes in ~80% of the time, on average — and never flakes.
Stretch — animation handling. Add Chrome options that disable animations:
ChromeOptions options = new ChromeOptions();options.addArguments("--disable-animations");options.addArguments("--no-default-browser-check");// or via prefs:options.addArguments("--force-prefers-reduced-motion");
Run a test against any animation-heavy page (Material Design demos, GitHub PRs). Tests should be measurably faster and never race a transition.
Chapter 3 is done. Synchronisation is the single biggest source of Selenium flake; if you've built the muscle memory in this chapter, your suites are already in the top 20% by reliability. Chapter 4 covers the advanced interactions — hover, drag-and-drop, alerts, iframes, multiple windows — every one of which depends on the wait patterns you've just learned.
// tip to track lessons you complete and pick up where you left off across devices.