Skip to main content
Selenium beginner Lesson 3 of 10

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:

ConditionPasses when
presence_of_element_locatedthe node exists — may be hidden, may be zero-sized
visibility_of_element_locatedit exists and is displayed with a size
element_to_be_clickablevisible 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.

Frequently Asked Questions

Does Selenium wait for elements automatically?
No. `find_element` queries the DOM once and raises immediately if nothing matches. An implicit wait adds polling to element lookups only — it does nothing for visibility, enabled state, text content, or any other condition.
Why should I not mix implicit and explicit waits?
Because they compound unpredictably. Each polling attempt inside an explicit wait can itself block for the implicit timeout, so a 10-second explicit wait with a 10-second implicit wait can take far longer than either. Set the implicit wait to zero and use explicit waits everywhere.
What is the difference between presence_of_element_located and visibility_of_element_located?
`presence` waits for the element to exist in the DOM, even if hidden or zero-sized. `visibility` additionally requires it to be displayed with a non-zero size. Waiting for presence and then clicking is a common source of ElementNotInteractableException.
Is time.sleep ever acceptable in a Selenium test?
Only while debugging. In a committed test it is either too short — and flaky — or too long — and slow — usually both on different machines. Every case where a sleep seems necessary has an expected condition, or can be expressed as a custom one.