The Hidden Flaw: Why Selenium Explicit Waits Are a Code Smell in Modern Test Automation

September 1, 2025

In the world of test automation, the command to 'wait' is both a savior and a saboteur. For years, test engineers have been diligently taught to graduate from the brittle Thread.sleep() to the more intelligent selenium explicit waits. WebDriverWait became the gold standard, a celebrated solution to the rampant flakiness caused by the asynchronous nature of modern web applications. But what if this celebrated best practice is, in fact, an anti-pattern in disguise? What if every new WebDriverWait(...) scattered throughout your test suite is a symptom of a deeper, more insidious architectural problem? This article challenges the conventional wisdom, arguing that while explicit waits are a tool, their widespread and unmanaged use is a significant code smell. It indicates a failure in abstraction, an increase in maintenance overhead, and a missed opportunity to build truly resilient, self-healing automation frameworks. We will deconstruct why this pattern emerges, explore its detrimental effects, and chart a course toward a more modern, robust approach to handling synchronization in your tests.

Understanding the Wait Hierarchy: From `Thread.sleep()` to Selenium Explicit Waits

To appreciate why selenium explicit waits can be problematic, we must first understand their context in the evolution of test synchronization. Early web applications were simpler, with page loads that were largely synchronous. However, with the rise of AJAX, Single Page Applications (SPAs), and third-party scripts, the web became a dynamic and unpredictable environment. A test script attempting to interact with an element might fail simply because the element hadn't been rendered by the JavaScript framework yet.

This gave rise to the most primitive solution: the hard-coded wait.

The Era of Static Waits: Thread.sleep()

The initial, and most naive, approach was to simply pause the test execution for a fixed duration.

// Anti-pattern: Hard-coded wait
driver.findElement(By.id("username")).sendKeys("user");
Thread.sleep(5000); // Wait 5 seconds for the next element to appear
driver.findElement(By.id("submit")).click();

This is universally condemned for two primary reasons. First, it's inefficient. If the element appears in 500 milliseconds, the test still wastes 4.5 seconds. Across a suite of hundreds of tests, this adds up to hours of wasted execution time. Second, it's unreliable. If the network is slow or the server is under load, the element might take 6 seconds to appear, causing the test to fail. As noted in analysis on non-deterministic tests, this kind of fixed timing is a primary source of flakiness.

A Small Step Forward: Implicit Waits

Selenium introduced the implicit wait as a global setting. You configure it once per driver session, and it tells WebDriver to poll the DOM for a certain amount of time when trying to find any element.

// A global setting
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10));
// This will now wait up to 10 seconds before throwing an exception
driver.findElement(By.id("submit")).click(); 

This was an improvement. It eliminated fixed pauses and only waited as long as necessary. However, its global nature is a double-edged sword. It applies to every findElement call, which can hide performance issues in the application. An element that should appear instantly might be taking 8 seconds to load, but the test will pass silently. Furthermore, it only covers one condition: the presence of an element in the DOM. It doesn't help if the element is present but not visible or clickable, a common scenario detailed in the W3C WebDriver specification.

The Reigning Champion: Selenium Explicit Waits

This brings us to selenium explicit waits. They are local, specific, and conditional. Using the WebDriverWait class in conjunction with ExpectedConditions, you can instruct the driver to wait for a very specific state before proceeding.

// The 'correct' way in traditional Selenium
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
WebElement submitButton = wait.until(ExpectedConditions.elementToBeClickable(By.id("submit")));
submitButton.click();

This approach is granular and powerful. You can wait for visibility, clickability, the presence of text, a specific number of elements, and more. For over a decade, this has been hailed as the definitive best practice for handling synchronization in Selenium. The official Selenium documentation itself promotes this as the most effective strategy. This solved the major problems of the previous methods, leading to more stable tests. But as frameworks and applications have grown in complexity, the limitations of this approach have become a significant architectural burden.

The Code Smell Diagnosis: When Good Intentions Lead to Brittle Tests

A 'code smell' isn't a bug; it's a surface-level indicator of a potentially deeper problem in system design. The prolific use of selenium explicit waits directly within test logic is a classic code smell. While each individual wait seems logical and necessary, their cumulative effect degrades the quality, readability, and maintainability of the entire test suite. According to research from the Software Engineering Institute, patterns like this contribute significantly to technical debt, making future changes slower and more expensive.

Symptom 1: Violation of DRY and Leaky Abstractions

The Don't Repeat Yourself (DRY) principle is fundamental to clean code. When you see the same new WebDriverWait(...) boilerplate code repeated across dozens or hundreds of tests, you have a clear violation.

Consider this typical test flow:

@Test
public void userCanSubmitForm() {
    WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));

    wait.until(ExpectedConditions.visibilityOfElementLocated(By.id("name"))).sendKeys("John Doe");
    wait.until(ExpectedConditions.visibilityOfElementLocated(By.id("email"))).sendKeys("[email protected]");
    wait.until(ExpectedConditions.elementToBeClickable(By.cssSelector(".submit-btn"))).click();

    WebElement successMessage = wait.until(ExpectedConditions.visibilityOfElementLocated(By.id("success-msg")));
    Assert.assertTrue(successMessage.getText().contains("Thank you"));
}

This code mixes the what (the business logic of the test) with the how (the synchronization logic). The test method shouldn't need to know that it has to wait for the submit button to be clickable; it should only know that it needs to click the submit button. This is a leaky abstraction. The implementation details of the UI's timing and rendering behavior have leaked into the high-level test script, coupling the test tightly to the front-end's behavior. As software design experts at Refactoring Guru point out, this kind of duplication makes the system rigid and fragile.

Symptom 2: Increased Cognitive Load and Maintenance Overhead

Every explicit wait is a micro-decision for the engineer. Should I wait for presence, visibility, or clickability? Is 10 seconds enough, or should it be 15? What if the element is visible but an animation overlay makes it unclickable? This decision-making process is repeated for nearly every interaction, increasing the cognitive load on the development team.

This leads to inconsistency. One engineer might use visibilityOfElementLocated, while another uses elementToBeClickable for the same button in a different test. When the UI changes, maintenance becomes a nightmare. A simple change to an element's ID might require updating not just the locator, but also several different wait conditions scattered across the test suite. This directly impacts development velocity, a key metric in McKinsey's research on developer velocity, which links software excellence to business performance.

Symptom 3: Masking Deeper Application or Test Issues

Perhaps the most dangerous aspect of selenium explicit waits is their ability to act as a band-aid, masking more severe underlying problems.

  • Performance Problems: If a page consistently requires a 20-second wait for a critical element to load, the test might be passing, but it's hiding a severe application performance issue that real users are experiencing.
  • Flaky Locators: A test might need to wait for elementToBeClickable because the initial locator was not specific enough and was matching a different, non-interactable element first. The wait 'fixes' the test, but the root cause—a poor locator strategy—remains.
  • Race Conditions: A test might be failing intermittently because it depends on the state from a previous test. Adding a long wait might make it pass more often, but it doesn't solve the fundamental issue of test dependency.

By simply increasing the wait time, teams often treat the symptom rather than diagnosing the disease. This leads to a test suite that is not only slow but also provides a false sense of security about the application's health.

The Modern Alternative: A Paradigm Shift to Resilient Automation

The solution isn't to stop waiting; it's to stop waiting explicitly in your test logic. The responsibility for synchronization should be shifted away from the test author and into the underlying framework or abstraction layer. This paradigm shift is at the heart of modern test automation.

Principle 1: The Auto-Waiting Framework

Newer-generation automation tools like Playwright and Cypress were built from the ground up with this problem in mind. Their core philosophy is 'auto-waiting' and 'actionability'. When you issue a command, the tool automatically performs a series of checks before executing the action.

For example, the single line page.getByRole('button', { name: 'Submit' }).click() in Playwright will automatically:

  1. Wait for an element matching the locator to appear in the DOM.
  2. Wait for it to become visible (i.e., not have display: none or visibility: hidden).
  3. Wait for it to be stable (i.e., not animating).
  4. Wait for it to be enabled (i.e., not have the disabled attribute).
  5. Wait for it to receive pointer events at the action point.

Only when all these conditions are met will it attempt the click. This is all handled internally, with configurable timeouts. The official Playwright documentation on Actionability details this robust, built-in mechanism.

Let's compare:

Selenium with Explicit Waits:

WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
WebElement submitButton = wait.until(
    ExpectedConditions.elementToBeClickable(By.xpath("//button[text()='Submit']"))
);
submitButton.click();

Playwright with Auto-Waiting:

await page.getByRole('button', { name: 'Submit' }).click();

The difference in clarity and conciseness is stark. The test author focuses solely on the user's actions, and the framework handles the complexities of synchronization. Similarly, the Cypress documentation on Retry-ability explains its intelligent waiting and retry mechanism, which has made it a favorite among front-end developers.

Principle 2: The Page Object Model as a Synchronization Layer

If you are committed to the Selenium ecosystem, you can still achieve a similar level of abstraction. The key is to rigorously enforce the Page Object Model (POM) and treat it as your synchronization layer. Selenium explicit waits should exist, but they must be hidden inside your page object methods, completely invisible to the test scripts.

Bad (Waits in the Test):

// Test Script
loginPage.enterUsername("user");
loginPage.enterPassword("pass");
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
wait.until(ExpectedConditions.elementToBeClickable(loginPage.loginButton)).click();

Good (Waits Encapsulated in the Page Object):

// Test Script
loginPage.enterUsername("user");
loginPage.enterPassword("pass");
loginPage.clickLoginButton(); // The wait is hidden inside this method

// LoginPage.java (Page Object Class)
public class LoginPage {
    private WebDriverWait wait;
    private By loginButton = By.id("login");

    public LoginPage(WebDriver driver) {
        this.driver = driver;
        this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
    }

    public void clickLoginButton() {
        wait.until(ExpectedConditions.elementToBeClickable(loginButton)).click();
    }
    // ... other methods
}

In this improved pattern, the test script is clean, readable, and focused on behavior. The responsibility for waiting is correctly placed within the page object that owns the element. This centralizes the wait logic, making it reusable and much easier to maintain. A change to the login button's behavior only requires a change in one place: the clickLoginButton method. This aligns with the core principles of the Page Object pattern as described by the Selenium community.

Principle 3: Waiting for Application State, Not UI State

A more advanced and even more robust strategy is to synchronize your tests based on the application's underlying state, typically by monitoring network traffic. Instead of waiting for a UI element like a 'Success!' spinner to disappear, you wait for the background API call that triggers this UI change to complete.

Modern tools excel at this. With Playwright, you can do:

// Start the action that triggers a network request
await page.getByRole('button', { name: 'Save' }).click();

// Wait for the specific API call to finish with a 200 OK status
await page.waitForResponse(response => 
    response.url().includes('/api/save-data') && response.status() === 200
);

// Now you can safely assert the outcome
await expect(page.locator('#status-message')).toHaveText('Data saved successfully!');

This approach decouples the test from fragile UI details like animation timings or CSS class names. It ties the test directly to the application's ground truth—the successful completion of a network request. While more complex to set up in traditional Selenium, it is achievable using the new Selenium BiDi APIs, which allow for listening to network events, bringing its capabilities closer to modern frameworks.

Refactoring Your Test Suite: A Step-by-Step Guide

Transitioning from a codebase littered with explicit waits to a clean, maintainable one is a manageable process. It requires a systematic refactoring effort focused on centralizing and abstracting synchronization logic. Here is a practical guide to reclaiming your test suite.

Step 1: Audit and Categorize Your Waits

Begin by performing a codebase-wide search for new WebDriverWait. This will give you a clear picture of the scale of the problem. Don't just count them; categorize them.

  • By Condition: How many are waiting for visibilityOf, elementToBeClickable, presenceOfElementLocated, textToBePresentInElement, etc.?
  • By Timeout: Are the timeout values consistent? Do you see arbitrary values like 7, 12, or 25 seconds?
  • By Location: Are they concentrated in specific test classes or spread evenly?

This audit, as recommended by industry analysis from firms like Gartner on managing technical debt, provides the data needed to justify the refactoring effort and plan your approach.

Step 2: Create a Centralized Wait Utility

Before refactoring your Page Objects, create a wrapper or utility class for your wait logic. This ensures consistency and makes future changes trivial. This class will contain reusable methods that hide the WebDriverWait and ExpectedConditions boilerplate.

public class Waiter {
    private WebDriverWait wait;

    public Waiter(WebDriver driver) {
        this.wait = new WebDriverWait(driver, Duration.ofSeconds(15)); // A sensible default
    }

    public WebElement forElementToBeClickable(By locator) {
        return wait.until(ExpectedConditions.elementToBeClickable(locator));
    }

    public WebElement forElementToBeVisible(By locator) {
        return wait.until(ExpectedConditions.visibilityOfElementLocated(locator));
    }

    public void forTextToAppear(By locator, String text) {
        wait.until(ExpectedConditions.textToBePresentInElementLocated(locator, text));
    }
}

This simple abstraction immediately improves consistency. All waits now use the same default timeout, and the method names are more expressive.

Step 3: Refactor Page Objects to Use the Wait Utility

Now, go through your Page Object classes and replace all direct WebDriverWait instantiations with your new Waiter utility. Your page object methods will become cleaner and more focused on their interaction logic.

Before:

public void clickLoginButton() {
    WebDriverWait localWait = new WebDriverWait(driver, Duration.ofSeconds(10));
    localWait.until(ExpectedConditions.elementToBeClickable(loginButton)).click();
}

After:

// Assuming 'waiter' is initialized in the Page Object's constructor
public void clickLoginButton() {
    waiter.forElementToBeClickable(loginButton).click();
}

The final and most important step is to remove every single WebDriverWait call from your @Test methods. A test script should never be responsible for synchronization. Its only job is to call methods on page objects and perform assertions.

Step 4: Evaluate the Next Generation of Tools

For new projects, or during a major overhaul of an existing test suite, it is crucial to evaluate whether your current toolset is still the best fit. According to a Forrester Wave™ report on Continuous Automation Testing, the landscape of tools is constantly evolving, with newer platforms offering significant productivity gains. If your team spends a substantial amount of time managing selenium explicit waits, the developer experience and built-in resilience of a framework like Playwright or Cypress could offer a compelling return on investment by reducing test development and maintenance time.

The journey from Thread.sleep to selenium explicit waits was a necessary and important evolution in test automation. For a long time, WebDriverWait was the sharpest tool we had for taming the wild, asynchronous nature of the web. However, the industry has moved forward. The continued practice of scattering explicit waits throughout test logic is no longer a best practice but a code smell—an indicator of a brittle architecture that is difficult to read and costly to maintain. True resilience in automation comes from abstraction. Whether it's through the built-in, auto-waiting capabilities of modern frameworks like Playwright and Cypress, or through a disciplined, well-architected Page Object Model in Selenium, the goal remains the same: push synchronization logic down the stack. Let your tests describe what the user does, and let your framework handle the messy details of how and when. By embracing this paradigm, we can build test suites that are not only more reliable and faster but also a pleasure to write and maintain.

What today's top teams are saying about Momentic:

"Momentic makes it 3x faster for our team to write and maintain end to end tests."

- Alex, CTO, GPTZero

"Works for us in prod, super great UX, and incredible velocity and delivery."

- Aditya, CTO, Best Parents

"…it was done running in 14 min, without me needing to do a thing during that time."

- Mike, Eng Manager, Runway

Increase velocity with reliable AI testing.

Run stable, dev-owned tests on every push. No QA bottlenecks.

Ship it

FAQs

Momentic tests are much more reliable than Playwright or Cypress tests because they are not affected by changes in the DOM.

Our customers often build their first tests within five minutes. It's very easy to build tests using the low-code editor. You can also record your actions and turn them into a fully working automated test.

Not even a little bit. As long as you can clearly describe what you want to test, Momentic can get it done.

Yes. You can use Momentic's CLI to run tests anywhere. We support any CI provider that can run Node.js.

Mobile and desktop support is on our roadmap, but we don't have a specific release date yet.

We currently support Chromium and Chrome browsers for tests. Safari and Firefox support is on our roadmap, but we don't have a specific release date yet.

© 2025 Momentic, Inc.
All rights reserved.