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.
Fixed sleep:
waitForTimeout(3000)- Test
- Page the application in the browser
- Server
- 1 From Test To Page: Clicks "Pay now" The command returns as soon as the click is done
- 2 From Page To Server: Sends the payment request
POST /api/payments - 3 Test internally: Sleeps 3 seconds No condition: it never looks at the page
- 4 From Test To Page: Reads the status once, at 3.0 s
- 5 From Page To Test: Still "Processing" reply Fail
- 6 From Server To Page:
201 Createdreply 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 From Test To Page: Clicks "Pay now" The command returns as soon as the click is done
- 2 From Page To Server: Sends the payment request
POST /api/payments - 3 From Test To Page: Checks: is the status "Paid"? Again and again, until it is or the timeout ends
- 4 From Page To Test: Not yet: "Processing" reply
- 5 From Server To Page:
201 Createdreply At 3.4 s; the page shows "Paid" - 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
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-waitsCommon 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
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.
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- Mark every fixed pause and write down the condition it is really waiting for.
- Find the wait that can pass before the thing it waits for has even started.
- Mark every line that reads the page once and say what it could read too early.
- Check the timeout settings for a combination the Selenium documentation warns against.
- Rewrite the test with one
WebDriverWaitand one condition per step.
Show the 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 — SeleniumWhy browser tests race the page, and how implicit and explicit waits synchronise a WebDriver script with the application.
- Auto-waiting — PlaywrightThe actionability checks Playwright runs before each action — visible, stable, enabled, receiving events — which remove most hand-written waits.
- Best Practices — CypressCypress guidance on selecting elements with dedicated attributes, avoiding fixed waits and keeping tests independent.
- WebDriver (W3C)The 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.
Measure an endpoint's response time over several samples: min, max, average, median and p95.
Go deeper
Your progress on this topic
Not started
Mark the topic complete when you have studied it and done the exercise.
Sign in to track your progress
Mark topics complete, continue where you left off and unlock specialisation exams.