Passes Locally, Fails on CI: The Case for Selenium's Explicit Waits
A login test that is green locally and red on CI has a synchronization problem, not a timing problem. The case for explicit waits — and for picking one wait strategy per suite.
18 Sept 2026, 00:36 UTC

A login test that is green on your laptop and red on CI, with no code change in between, almost always has the same root cause: the script assumes the UI is ready the moment it acts. It clicks Sign in, sleeps three seconds, then looks for the dashboard. On a fast machine the dashboard renders in under a second; on a shared CI runner it takes four. The test fails with NoSuchElementException, someone bumps the sleep to five seconds, and the suite gets slower without becoming more reliable.
That is a synchronization problem, not a timing problem. Selenium's supported answer is the explicit wait: a WebDriverWait that polls the browser until a stated condition is true, or gives up with a TimeoutException. The engineering decision that follows matters just as much: pick one wait strategy for the whole suite, and keep the implicit wait out of it.
Three ways to wait, and two are traps
Selenium gives you three synchronization tools, and they are not interchangeable:
| Strategy | What it does | Core weakness |
|---|---|---|
| Fixed sleep | Pauses for a hardcoded duration | Wasteful on fast runs, still flaky on slow ones |
| Implicit wait | One global retry window applied to every find_element call | Cannot express 'wait until clickable'; taxes absence checks, since asserting an element is gone blocks for the full timeout |
| Explicit wait | Polls until a condition is satisfied, else raises TimeoutException | Slightly verbose; overly long timeouts can mask real bugs |
The fixed sleep fails in both directions at once: it is the slowest possible passing test and still an unreliable one. The implicit wait is better but blunt. The explicit wait is the only tool that can describe what a step actually needs, which is why Selenium's documentation recommends it and warns against the alternatives' failure modes.
Worked example: a submit button that does not exist yet
Consider a login form where the Submit button is only rendered after an asynchronous validation call finishes — a common pattern in single-page apps. The sleep-based version looks like this:
# Run in your project environment: pip install selenium
# Assumes Selenium 4.x Python bindings and a working Chrome setup
import time
from selenium import webdriver
from selenium.webdriver.common.by import By
driver = webdriver.Chrome()
driver.get('https://staging.example.com/login')
driver.find_element(By.ID, 'email').send_keys('[contact removed]')
driver.find_element(By.ID, 'password').send_keys('placeholder-password')
driver.find_element(By.ID, 'login').click()
time.sleep(3) # hope the validation call has finished
driver.find_element(By.ID, 'submit').click()On a fast connection this burns nearly three seconds per run. On a slow one, three seconds is not enough and the last line throws NoSuchElementException — a message that says nothing about why the button was missing.
The explicit-wait version:
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
driver = webdriver.Chrome()
wait = WebDriverWait(driver, 10) # max seconds; polls ~every 0.5 s by default
driver.get('https://staging.example.com/login')
driver.find_element(By.ID, 'email').send_keys('[contact removed]')
driver.find_element(By.ID, 'password').send_keys('placeholder-password')
driver.find_element(By.ID, 'login').click()
submit = wait.until(EC.element_to_be_clickable((By.ID, 'submit')))
submit.click()element_to_be_clickable keeps polling until the button is present, visible, and enabled, then returns it immediately — typically well under a second on a healthy build. If the validation endpoint is genuinely broken, the wait fails after ten seconds with a TimeoutException tied to the condition you were waiting for, which is far more actionable than a bare NoSuchElementException.
Choosing the condition is the craft: presence_of_element_located for bare existence, text_to_be_present_in_element for loaded content, invisibility_of_element_located for a spinner disappearing. Pick the narrowest condition that matches what 'ready' means for that step. Java offers the same primitives via org.openqa.selenium.support.ui, and both bindings allow tuning — Python's WebDriverWait accepts poll_frequency and ignored_exceptions, and Java's FluentWait (which WebDriverWait extends) does the same. The half-second default poll is a long-standing default but version-sensitive, so confirm it against your installed binding.
One strategy per suite: do not mix implicit and explicit waits
Selenium's documentation warns against combining the two. The implicit wait applies to the element lookups that explicit-wait polling performs internally, so a two-second explicit wait running under a ten-second implicit wait can block far longer than either number suggests — unpredictable, compounded delays, and failures that surface at the wrong moment. The standard decision is to standardize on explicit waits and leave the implicit wait at its default of zero; if a legacy suite sets it, remove that setting rather than layering explicit waits on top.
A related habit: do not cache WebElement references across navigation or SPA re-renders. When an element is detached and re-created between lookup and use, you get StaleElementReferenceException. A condition built from a locator re-finds the element on every poll, which is exactly what you want.
The trade-off: generous waits hide real bugs
Explicit waits have their own failure mode. A 30-second timeout for a button can mask a broken endpoint — the test passes, only slower, while real users stare at a spinner. Keep timeouts tight (a few seconds for most conditions), let failures surface, and treat a timeout as a signal to investigate rather than a number to raise. Note also that the exception semantics differ: explicit waits raise TimeoutException while plain find_element raises NoSuchElementException, so error handling and reporting should distinguish them.
Check it yourself in five minutes
You do not have to take the reliability gap on faith. Serve a tiny page locally that reveals a button after a delay:
<!-- delay.html -->
<button id='submit' style='display:none'>Go</button>
<script>
setTimeout(() => document.getElementById('submit').style.display = 'block', 4000);
</script>Run python -m http.server in that directory, point both script versions at http://localhost:8000/delay.html, and time them. The sleep version burns the full four seconds every run — or fails outright if you sleep for less. The explicit-wait version returns the moment the button becomes visible. To see the mixed-wait pitfall firsthand, set a large implicit wait plus a short explicit wait for an element that never appears and watch the delay compound.
Make the decision stick
If your suite is flaky in CI but stable locally, audit for three things: delete every fixed sleep; make sure no code path sets an implicit wait; and replace both with explicit waits whose conditions match what 'ready' actually means for each step — clickable for actions, text present for content, invisible for spinners. Keep the timeouts tight, confirm the condition names and polling defaults against your installed Selenium version (pip show selenium, or your Maven/Gradle dependency tree), and your tests will stop measuring your hardware and start measuring your app.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.