Waits: Implicit, Explicit, and Why sleep Fails
Selenium does not wait for anything. Replace sleeps with WebDriverWait and expected conditions — and never mix implicit and explicit waits, which multiplies your timeouts.
Selenium sends a command and returns. It does not wait for the page, for animations, for an AJAX response, or for React to re-render. Everything about test stability comes from what you put in that gap.
The race
with webdriver.Chrome(options=opts) as driver:
driver.get("https://demo.playwright.dev/todomvc")
driver.find_element(By.CLASS_NAME, "new-todo").send_keys("buy milk", Keys.ENTER)
print(driver.find_element(By.CLASS_NAME, "todo-count").text)
selenium.common.exceptions.NoSuchElementException: Message: no such element: Unable to
locate element: {"method":"css selector","selector":".todo-count"}
(Session info: chrome=133.0.6943.16)
Sometimes. On a fast machine it passes; on a loaded CI runner it does not. A test that passes 90% of the time is worse than one that always fails, because it trains people to re-run rather than investigate.
The fix everybody writes first
import time
driver.find_element(By.CLASS_NAME, "new-todo").send_keys("buy milk", Keys.ENTER)
time.sleep(2)
print(driver.find_element(By.CLASS_NAME, "todo-count").text)
1 item left
It works, and it is wrong in both directions. The element was ready after 80ms, so 1.9 seconds were wasted — multiply by 300 tests and that is ten minutes a run. And on the day CI is slow, two seconds is not enough and the test fails anyway.
Explicit waits
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
with webdriver.Chrome(options=opts) as driver:
driver.get("https://demo.playwright.dev/todomvc")
wait = WebDriverWait(driver, 10)
driver.find_element(By.CLASS_NAME, "new-todo").send_keys("buy milk", Keys.ENTER)
counter = wait.until(EC.visibility_of_element_located((By.CLASS_NAME, "todo-count")))
print(counter.text)
1 item left
WebDriverWait polls every 500ms until the condition returns something truthy or the timeout
expires. It returns as soon as the condition holds — so the fast case stays fast and the slow
case still passes.
Measure the difference:
import time
t0 = time.perf_counter()
wait.until(EC.visibility_of_element_located((By.CLASS_NAME, "todo-count")))
print(f"explicit wait: {time.perf_counter() - t0:.3f}s")
explicit wait: 0.084s
84 milliseconds against a two-second sleep, and it tolerates a five-second delay if one happens.
The conditions worth knowing
wait.until(EC.presence_of_element_located((By.ID, "row"))) # in the DOM
wait.until(EC.visibility_of_element_located((By.ID, "row"))) # and displayed
wait.until(EC.element_to_be_clickable((By.ID, "save"))) # and enabled
wait.until(EC.text_to_be_present_in_element((By.CLASS_NAME, "todo-count"), "2 items"))
wait.until(EC.invisibility_of_element_located((By.CLASS_NAME, "spinner")))
wait.until(EC.staleness_of(old_element)) # the old node is gone
wait.until(EC.number_of_windows_to_be(2))
wait.until(EC.alert_is_present())
wait.until(EC.url_contains("/active"))
wait.until(EC.frame_to_be_available_and_switch_to_it((By.ID, "payment-frame")))
box = driver.find_element(By.CLASS_NAME, "new-todo")
box.send_keys("walk the dog", Keys.ENTER)
wait.until(EC.text_to_be_present_in_element((By.CLASS_NAME, "todo-count"), "2 items left"))
print(driver.find_element(By.CLASS_NAME, "todo-count").text)
driver.find_element(By.LINK_TEXT, "Active").click()
wait.until(EC.url_contains("/active"))
print(driver.current_url)
2 items left
https://demo.playwright.dev/todomvc/#/active
The distinction that catches people:
| Condition | Passes when |
|---|---|
presence_of_element_located | the node exists — may be hidden, may be zero-sized |
visibility_of_element_located | it exists and is displayed with a size |
element_to_be_clickable | visible and enabled |
wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, ".todo-list li .destroy"))).click()
selenium.common.exceptions.ElementNotInteractableException: Message: element not
interactable
The delete button is present and hidden until hover. presence was satisfied; the click was
not possible. Wait for the condition you actually need — if you are going to click, wait
for element_to_be_clickable.
Reading a timeout
wait = WebDriverWait(driver, 5)
wait.until(EC.visibility_of_element_located((By.ID, "never-appears")))
Traceback (most recent call last):
File "waits.py", line 12, in <module>
wait.until(EC.visibility_of_element_located((By.ID, "never-appears")))
selenium.common.exceptions.TimeoutException: Message:
Stacktrace:
GetHandleVerifier [0x00007FF6...]
The empty Message: is Selenium’s least helpful output. Always supply your own:
wait.until(
EC.visibility_of_element_located((By.ID, "never-appears")),
message="order confirmation panel never appeared after submitting the form",
)
selenium.common.exceptions.TimeoutException: Message: order confirmation panel never
appeared after submitting the form
One argument, and a 3am failure explains itself.
Implicit waits, and why they disappoint
driver.implicitly_wait(10)
driver.get("https://demo.playwright.dev/todomvc")
driver.find_element(By.CLASS_NAME, "new-todo").send_keys("buy milk", Keys.ENTER)
print(driver.find_element(By.CLASS_NAME, "todo-count").text)
1 item left
One line, applied to every find_element for the life of the session. Tempting — and it
covers only element lookup. It cannot wait for an element to become visible, to become
enabled, for text to change, or for a spinner to disappear.
It also makes absence checks slow:
t0 = time.perf_counter()
print(driver.find_elements(By.ID, "nope"))
print(f"took {time.perf_counter() - t0:.1f}s")
[]
took 10.0s
Ten seconds to confirm an element is missing, on every such assertion.
Never mix the two
driver.implicitly_wait(10)
wait = WebDriverWait(driver, 10)
t0 = time.perf_counter()
try:
wait.until(EC.presence_of_element_located((By.ID, "never-appears")))
except TimeoutException:
print(f"timed out after {time.perf_counter() - t0:.1f}s")
timed out after 20.3s
Twenty seconds from two ten-second timeouts. Each poll inside the explicit wait blocks for the implicit timeout before returning, so the two compound. The official guidance is unambiguous — pick one, and it should be explicit:
driver.implicitly_wait(0)
Fluent waits
from selenium.common.exceptions import StaleElementReferenceException, NoSuchElementException
wait = WebDriverWait(
driver,
timeout=15,
poll_frequency=0.25,
ignored_exceptions=(NoSuchElementException, StaleElementReferenceException),
)
row = wait.until(lambda d: d.find_element(By.XPATH, "//label[text()='buy milk']"))
print(row.text)
buy milk
ignored_exceptions is what makes a wait survive a re-rendering framework: a
StaleElementReferenceException mid-poll is swallowed and retried rather than failing the
wait. Any WebDriverWait touching a React or Vue page should include it.
Custom conditions
Any callable taking the driver and returning truthy works:
def todo_count_is(n):
def predicate(driver):
items = driver.find_elements(By.CSS_SELECTOR, ".todo-list li")
return len(items) == n
return predicate
def no_pending_requests(driver):
return driver.execute_script("return window.jQuery ? jQuery.active === 0 : true")
wait.until(todo_count_is(2), message="expected exactly 2 todos")
wait.until(no_pending_requests, message="AJAX requests still in flight")
print("both conditions met")
both conditions met
This is the escape hatch that removes the last sleeps from a suite. Anything you can express as “is this true yet?” — a row count, a JavaScript variable, an attribute, a network idle check — becomes a condition instead of a guess.
A small wait vocabulary
Wrap the patterns you use constantly:
class Waits:
def __init__(self, driver, timeout=10):
self.driver = driver
self.wait = WebDriverWait(
driver, timeout,
ignored_exceptions=(StaleElementReferenceException, NoSuchElementException),
)
def visible(self, locator, msg=None):
return self.wait.until(EC.visibility_of_element_located(locator),
message=msg or f"not visible: {locator}")
def clickable(self, locator, msg=None):
return self.wait.until(EC.element_to_be_clickable(locator),
message=msg or f"not clickable: {locator}")
def gone(self, locator, msg=None):
return self.wait.until(EC.invisibility_of_element_located(locator),
message=msg or f"still visible: {locator}")
def text_is(self, locator, text, msg=None):
return self.wait.until(EC.text_to_be_present_in_element(locator, text),
message=msg or f"text never became {text!r} in {locator}")
w = Waits(driver)
w.clickable((By.CLASS_NAME, "new-todo")).send_keys("buy milk", Keys.ENTER)
w.text_is((By.CLASS_NAME, "todo-count"), "1 item left")
print("done")
done
Every wait now carries a message, ignores staleness, and shares one timeout policy.
The contrast worth knowing
If you have used Playwright, this whole lesson is machinery you did not need there — its locators re-resolve and its assertions retry by default. Selenium is explicit instead: the WebDriver protocol is a browser-vendor standard with a much wider language and browser reach, and the price of that portability is that synchronisation is your job.
Get the wait strategy right once, wrap it as above, and the rest of a Selenium suite is straightforward.
Practice
1. Replace a time.sleep(2) with an explicit wait and time both.
sleep: 2.001s
explicit wait: 0.084s
24× faster on the fast path, and it still tolerates a five-second delay. Sleeps are slower and less reliable — there is no case where the trade favours them.
2. Wait for presence, then click a hidden element.
selenium.common.exceptions.ElementNotInteractableException: Message: element not
interactable
presence was satisfied and the click was not. Match the condition to the action:
element_to_be_clickable before a click, visibility before reading text.
3. Set an implicit wait and an explicit wait together, then time a failure.
timed out after 20.3s
Two ten-second timeouts compounding. Set implicitly_wait(0) and the same failure takes ten
seconds — and on a large suite that difference is most of your CI bill.
4. Write a custom condition for a row count.
wait.until(lambda d: len(d.find_elements(By.CSS_SELECTOR, ".todo-list li")) == 2,
message="expected 2 todos")
selenium.common.exceptions.TimeoutException: Message: expected 2 todos
A named failure instead of a bare timeout. Any “is it ready yet” question can be a condition, which is what lets you delete the last sleeps.
Next: interactions — typing, clicking, selects, uploads, and the click that lands on a cookie banner.