Waits and Synchronization

Synchronization means keeping an automated test in step with the application it drives: each step acts or checks only once the application has reached the state that step needs. A test clicks "Pay now" and reads the payment status at once; the page still shows "Processing" because the server has not answered yet, and the test fails although the payment works.

A wait is how a test synchronizes. A good wait names a condition, such as "the status reads Paid", checks it repeatedly and continues as soon as it is true, up to a timeout. Selenium offers implicit and explicit waits; Playwright and Cypress wait automatically before actions and in assertions. One kind of wait is always wrong: the fixed sleep.

Why it matters

Timing is a common cause of flaky UI tests: the test passes on a fast laptop and fails on a busy CI runner, or fails once in twenty runs. Each false failure costs someone an investigation, and a team that sees many of them starts re-running red builds instead of reading them. That is how a real defect gets waved through.

Bad waits also cost time on every run, and a slow suite gets run less often. Good waits make a suite faster and more trustworthy at once: it moves at the application’s pace, and when it fails, the message names the state that never arrived.

Test timing and application timing

A test and the application run on two clocks. A command such as a click returns as soon as the browser has performed it, and the test moves on to its next line. Whatever the click started finishes later, on the application’s schedule: as the lesson on JavaScript basics for testers showed, fetch() returns a pending promise, and the page changes only when the response arrives.

To synchronize, a test needs a condition it can check: something on the page, in the URL or on the network that is true only when the application is ready for the next step. Typical delays to plan for:

  • Network requests: loading, saving and searching finish when the server answers.
  • Rendering: a list or a total appears after its data arrives, sometimes in two steps (a placeholder first, the values later).
  • Animations: a dialog that slides in, a button that moves while the layout settles.
  • Timers: a search box that waits 300 ms after the last keystroke (debounce), a message that disappears after 5 seconds.
  • Navigation: a redirect after sign-in, a new tab, a download.

Why a fixed sleep is never the fix

The quickest way to make an early test pass is a pause: Thread.sleep(3000), time.sleep(3), cy.wait(3000) or page.waitForTimeout(3000). A sleep does not know what it is waiting for; it waits a fixed time and continues, ready or not. The Playwright documentation calls tests that wait for time inherently flaky, and the Cypress best practices list waiting for arbitrary time periods as an anti-pattern.

You can watch the problem with a slow endpoint: the API Status Code Simulator answers after a delay you choose, up to 10 seconds. A sleep goes wrong in three ways:

  • Too short on a slow run: on a busy CI runner the payment response arrives after 3.4 seconds, the test reads at 3.0 and fails, but only on some runs, so the test is flaky.
  • Too long on a fast run: when the response takes 0.3 seconds, the test still waits 3. Two hundred tests with one such sleep each spend a combined 10 minutes per run doing nothing.
  • No intent: sleep(3000) does not say what the author was waiting for, so neither the next reader nor the failure message can tell which condition mattered.
On a slow run a 3-second sleep reads the payment status before the response at 3.4 seconds and fails, while a condition-based wait keeps checking and continues when "Paid" appears.
  • Fixed sleep: waitForTimeout(3000)

    • Test
    • Page the application in the browser
    • Server
    1. 1 From Test To Page: Clicks "Pay now" The command returns as soon as the click is done
    2. 2 From Page To Server: Sends the payment request POST /api/payments
    3. 3 Test internally: Sleeps 3 seconds No condition: it never looks at the page
    4. 4 From Test To Page: Reads the status once, at 3.0 s
    5. 5 From Page To Test: Still "Processing" reply Fail
    6. 6 From Server To Page: 201 Created reply At 3.4 s on a busy CI runner; the page shows "Paid" after the test has failed

    Outcome: Fails although the payment worked; on a fast run it passes but still waits the full 3 seconds. Fail

  • Condition-based wait: the status reads "Paid"

    • Test
    • Page the application in the browser
    • Server
    1. 1 From Test To Page: Clicks "Pay now" The command returns as soon as the click is done
    2. 2 From Page To Server: Sends the payment request POST /api/payments
    3. 3 From Test To Page: Checks: is the status "Paid"? Again and again, until it is or the timeout ends
    4. 4 From Page To Test: Not yet: "Processing" reply
    5. 5 From Server To Page: 201 Created reply At 3.4 s; the page shows "Paid"
    6. 6 From Page To Test: "Paid": the wait ends at 3.4 s reply Pass

    Outcome: Continues as soon as the page is ready, at 0.3 s on a fast run; fails only if "Paid" never appears before the timeout. Pass

Try it: API Status Code Simulator

Implicit and explicit waits

Every condition-based wait has three parts: the condition, a timeout that ends the wait with an error, and a polling interval, which is how often the condition is checked. Selenium WebDriver offers two kinds.

An implicit wait is one session-wide setting: every element lookup retries until the element is present in the DOM or the timeout ends (the default is 0, which fails at once). It waits for presence only, and an element can be present yet hidden, disabled or covered. It also slows lookups that should find nothing: checking with findElements that an error message is absent costs the full implicit timeout.

An explicit wait is written at the step that needs it: a WebDriverWait with a timeout and a condition. ExpectedConditions (expected_conditions in Python) provides common ones, such as visible, clickable, invisible, text present and URL contains, and you can pass your own function: d -> … in Java or lambda d: … in Python, the same idea as a JavaScript arrow function, where d is the driver. By default WebDriverWait checks every 500 milliseconds and returns as soon as the condition holds. The Selenium documentation warns against mixing implicit and explicit waits: the timeouts interact, and a step can fail later than either value suggests.

// A) Implicit wait: one session-wide setting, used by every findElement call.
//    It waits until the element is present in the DOM, not visible or clickable.
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(5));

// B) Explicit wait: a condition at the step that needs it. Use B instead of A,
//    never both in the same session.
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
wait.until(ExpectedConditions.textToBePresentInElementLocated(
    By.cssSelector("[data-testid=payment-status]"), "Paid"));
wait.until(ExpectedConditions.elementToBeClickable(
    By.cssSelector("[data-testid=download-receipt]"))).click();

// Your own condition: a function the wait calls until it returns true
wait.until(d -> d.findElements(By.cssSelector("[data-testid=result-row]")).size() == 1);

Auto-waiting in Playwright and Cypress

Playwright runs actionability checks before every action. For click() the element must be visible, stable (not moving), able to receive events (not covered by another element) and enabled; for fill() it must be visible, enabled and editable. The checks repeat until they pass or the timeout ends. The retrying assertions from the lesson on assertions wait for an expected state in the same way. A locator is resolved again on every use, so a re-rendered element cannot leave the test holding an outdated reference, the StaleElementReferenceException that Selenium users know.

Cypress retries queries such as cy.get() and cy.contains() together with the assertion chained to them, for 4 seconds by default, and waits for an element to be actionable before .click() or .type(). To wait for the server, give the request an alias with cy.intercept() and wait for the alias, never for a number.

Auto-waiting checks that an element can be used, not that the application is ready. A "Pay now" button may be enabled before the order total has loaded, and Playwright will click it. When a step depends on data, assert that data first. To wait for a response, start listening before the action that sends the request, or a fast response can arrive before the wait begins. The Playwright documentation also discourages waiting for the networkidle state in tests.

// Playwright: click() waits until the button is visible, stable, receives
// events and is enabled; toHaveText() retries until it matches (5 s default).
await page.getByRole('button', { name: 'Pay now' }).click();
await expect(page.getByTestId('payment-status')).toHaveText('Paid');

// Playwright: to wait for the server, start listening before the click.
const response = page.waitForResponse('**/api/payments');
await page.getByRole('button', { name: 'Pay now' }).click();
expect((await response).status()).toBe(201);

// Cypress: queries retry with their assertion (4 s default);
// an intercept alias waits for the request instead of a number.
cy.intercept('POST', '/api/payments').as('pay');
cy.contains('button', 'Pay now').click();
cy.wait('@pay');
cy.get('[data-testid=payment-status]').should('have.text', 'Paid');

Worked example: fixing a flaky payment test

A Playwright test of the payment page fails in about one pipeline run in ten with Expected: "Paid", Received: "Processing"; on its author’s laptop it always passes. The trace shows the status read 3.0 seconds after the click and the payment response at 3.4. The page also loads the order total after the page itself, which is what the first sleep was covering.

The fix replaces each sleep with the condition it was guessing at. The team measured the payment step on the shared staging environment: usually under one second, at worst about 8 seconds. That measurement sets the one longer timeout. For a publicly reachable endpoint, the Response Time Analyzer gives the same kind of data: median, maximum and p95 (the time 95% of the samples stayed within) over several samples.

  • [1] The first sleep becomes an assertion on the visible total, 172.50 SAR. It says what "loaded" means and also catches a wrong total.
  • [2] and [3] become one retrying assertion. On a normal run it continues within a second of the click; only this step may take up to 15 seconds, and the comment records why.
  • The last click needs no wait of its own: Playwright waits until "Download receipt" is enabled.
  • If the payment later needs more than 15 seconds, report it as a performance problem with the timings instead of raising the timeout again.
// BEFORE: always passes locally, fails in about one CI run in ten
await page.goto('/orders/1042/pay');
await page.waitForTimeout(2000);                    // [1] "let the total load"
await page.getByRole('button', { name: 'Pay now' }).click();
await page.waitForTimeout(3000);                    // [2] "let the payment finish"
const status = await page.getByTestId('payment-status').textContent();
expect(status).toBe('Paid');                        // [3] reads once
await page.getByRole('button', { name: 'Download receipt' }).click();

// AFTER: each sleep replaced by the condition it was guessing at
await page.goto('/orders/1042/pay');
await expect(page.getByTestId('order-total')).toHaveText('172.50 SAR');       // [1]
await page.getByRole('button', { name: 'Pay now' }).click();
// [2] + [3]: payment sandbox measured at up to 8 s on shared staging
await expect(page.getByTestId('payment-status'))
  .toHaveText('Paid', { timeout: 15_000 });
await page.getByRole('button', { name: 'Download receipt' }).click();   // auto-waits

Try it: Response Time Analyzer

Common mistakes

  • Fixing a flaky failure by adding a sleep or making an existing one longer.
  • Setting an implicit wait and also using explicit waits in the same Selenium session.
  • Waiting for a spinner to disappear instead of for the end state: if the spinner has not appeared yet, the wait passes at once.
  • Reading a value once right after an action (textContent(), .text) instead of using a retrying assertion or an explicit wait.
  • Starting to wait for a network response after the click that sends the request; a fast response is missed and the wait times out.
  • Raising the timeout for the whole suite because one step is slow, which hides performance regressions and makes every real failure slower to report.

The QA perspective

On a team, synchronization problems arrive as flaky pipeline failures. When you triage one, open the trace or video and compare two timelines: when the test acted or read, and when the application changed. If the test was early, fix the wait. If the application was slower than the agreed limit, or reached a wrong state, that is a defect to report with the timings. A double submission after an impatient second click is a race in the product, not noise in the test.

In code review, ask for no sleeps in committed tests, the framework’s default timeouts, and a longer timeout only on a single step, with a comment that says why. Ask developers for signals tests can wait for: a status text, a button disabled while saving, aria-busy="true" on a region while it loads (an accessibility attribute that tells screen readers the content is updating), a test id on the result. Clear signals help people who use assistive technology too.

Practice exercise

Remove the sleeps from a flaky Selenium test

Your task

The Python test below signs in, searches for an order and checks its status. It fails on CI about twice a week, each time at a different line. After sign-in the app redirects to the orders page, which first lists all of the customer’s orders; typing in the search box filters them after a 300 ms pause, and a spinner, always in the page but hidden, becomes visible while the filtered results load.

Find every synchronization problem, explain how each one can fail, and rewrite the test with explicit waits only.

Material
driver.implicitly_wait(10)
wait = WebDriverWait(driver, 10)

driver.get(BASE_URL + "/login")
driver.find_element(By.ID, "email").send_keys("layla.h@example.com")
driver.find_element(By.ID, "password").send_keys(PASSWORD)
driver.find_element(By.ID, "sign-in").click()
time.sleep(5)

driver.find_element(By.ID, "search").send_keys("ORD-1042")
wait.until(EC.invisibility_of_element_located((By.CSS_SELECTOR, ".spinner")))
time.sleep(1)
results = driver.find_elements(By.CSS_SELECTOR, "[data-testid=result-row]")
assert len(results) == 1
assert "Paid" in results[0].text
Steps
  1. Mark every fixed pause and write down the condition it is really waiting for.
  2. Find the wait that can pass before the thing it waits for has even started.
  3. Mark every line that reads the page once and say what it could read too early.
  4. Check the timeout settings for a combination the Selenium documentation warns against.
  5. Rewrite the test with one WebDriverWait and one condition per step.
Show the model answer
Model answer

time.sleep(5) guesses how long sign-in and the redirect take. Wait for the next page’s own signal instead: wait.until(EC.visibility_of_element_located((By.ID, "search"))), then type into the element it returns.

The spinner wait runs right after typing, while the spinner is still hidden because the 300 ms pause has not ended, so invisibility_of_element_located passes at once and proves nothing. time.sleep(1) then guesses how long the pause and the search request take.

The find_elements line reads the list once: on a slow run it still holds the unfiltered orders and len(results) == 1 fails; results[0].text is also read once. Replace the spinner wait, the sleep and both reads with two conditions: wait.until(lambda d: len(d.find_elements(By.CSS_SELECTOR, "[data-testid=result-row]")) == 1), then wait.until(EC.text_to_be_present_in_element((By.CSS_SELECTOR, "[data-testid=result-row]"), "Paid")).

implicitly_wait(10) together with WebDriverWait mixes implicit and explicit waits, which the Selenium documentation warns makes wait times unpredictable; it would also make every find_elements call that finds no row block for up to 10 seconds inside the wait. Remove it and create wait = WebDriverWait(driver, 10) once. The login lines stay safe without it if the form is part of the login page’s HTML, because driver.get() waits for the page to load; if a script draws the form after loading, wait for the email field to be visible first.

The rewritten test has no sleeps and three explicit conditions: search box visible, exactly one result row, the row contains "Paid". A fast run takes the application’s time instead of 6 fixed seconds, and a failure names the condition that never became true.

Frequently asked questions

Is an implicit wait ever the right choice?

It can work on simple pages where elements appear late but are usable as soon as they exist. Its limits (presence only, applied to every lookup including those that should find nothing, unpredictable together with explicit waits) lead many teams to leave it at 0 and use explicit waits only.

With Playwright’s auto-waiting, do I still write waits?

Rarely as separate lines. Actions wait for actionability and retrying assertions wait for states, so most synchronization is an assertion before the next step. You still decide what to wait for, and use page.waitForResponse() or page.waitForEvent() for a response, a download or a new page. You never need waitForTimeout() in a committed test.

How long should a timeout be?

Long enough for the slowest legitimate run on your slowest environment, based on measurements, and no longer. A timeout is a ceiling, not a delay: passing runs never pay it. Keep the framework defaults (in Playwright Test 5 seconds per assertion and 30 seconds per test, in Cypress 4 seconds per command) and raise only the step you know is slow.

How do I test something that happens after a long time, such as an inactivity warning after 15 minutes?

Do not sleep for 15 minutes. If the timer runs in the page, control the clock: Playwright’s page.clock.install() with page.clock.fastForward(), or Cypress’s cy.clock() with cy.tick(), move the page’s timers forward at once. If the server enforces the time, ask for a test configuration with a shorter limit.

References

  • Waiting Strategies — SeleniumSelenium ProjectWhy browser tests race the page, and how implicit and explicit waits synchronise a WebDriver script with the application.
  • Auto-waiting — PlaywrightMicrosoft (Playwright)The actionability checks Playwright runs before each action — visible, stable, enabled, receiving events — which remove most hand-written waits.
  • Best Practices — CypressCypress.ioCypress guidance on selecting elements with dedicated attributes, avoiding fixed waits and keeping tests independent.
  • WebDriver (W3C)W3C · 2026The W3C protocol browsers implement for remote control by automation tools such as Selenium (the Level 2 edition is a Working Draft).

Practise with Mutqan tools

Get a public endpoint that returns any HTTP status code, with optional delay and custom JSON body, to test client error handling.

API Testing

Measure an endpoint's response time over several samples: min, max, average, median and p95.

Performance

Go deeper

Your progress on this topic

Part of the QA Roadmap