Porting an old browser suite to Playwright, what replaces its explicit waits and sleeps?
answer
- Most waiting is not ported at all
- The lookup runs again on every use
- Waiting moved inside the assertion
- Retrying matchers replace wait-for-state
- Keep response and URL waits only
basics
~20 sMost of them disappear. A Playwright locator is re-resolved every time it is used, and actions wait for the element to be ready, so waits for a state become retrying assertions like expect(locator).toBeVisible(). Fixed sleeps are deleted, not translated.
solid answer
~40 sMost of the waiting code does not survive, by design. A Playwright **locator** — `page.getByRole('button', { name: 'Get quote' })` or `page.locator('#quote-total')` — describes how to find an element rather than holding one, and it is resolved again on every use, so `locator.click()` already waits for the element to appear, become enabled and stop moving. Waits for a *state* become assertions: `await expect(locator).toBeVisible()` or `toHaveText()` re-checks until it passes or the expect timeout (5 s by default) runs out. Fixed sleeps are deleted; `page.waitForTimeout()` exists but is meant for debugging. What you keep are waits for events an assertion cannot see — `page.waitForResponse()` around the pricing call, `page.waitForURL()` after a redirect.
code
typescript · 9 linesimport { test, expect } from '@playwright/test';
test('quote total updates after adding a driver', async ({ page }) => {
await page.goto('https://quotes.example.com/motor');
await page.getByLabel('Annual mileage').fill('8000');
await page.getByRole('button', { name: 'Add driver' }).click();
// No sleep and no explicit wait: the assertion retries until it passes.
await expect(page.getByTestId('quote-total')).toHaveText('£412.60');
});go deeper
Remember the headline: you do not translate sleeps. Build a locator, act on it, and assert with expect(locator); the waiting is already inside those two steps.
Be able to explain why it works: a locator is re-resolved on every use, actions check readiness first, and expect matchers re-check until the expect timeout of five seconds expires.
Show judgment about which waits survive. Network and navigation waits carry intent an assertion cannot express, and a raised expect timeout during a port is usually a masked defect rather than a slow app.
Own the rule the team ports by. Decide up front that timing constants do not cross into the new suite, and that a ported case which needs a longer timeout gets investigated rather than translated as-is.
A legacy browser suite is usually built from two primitives: a **finder** that returns an element reference, and a **wait** that blocks until the page looks ready enough to use it. A port to Playwright keeps the first idea in a changed form and dissolves most of the second. ## A finder becomes a locator, not an element `page.locator('#quote-total')` does **not** query the page. It returns a `Locator`, a description of how to find the element, and the query runs again every time you act on it or assert against it. That one property is what makes the waiting code redundant: there is no stored reference that can go out of date between the lookup and the click. The lookups themselves port almost mechanically: - an id lookup becomes `page.locator('#quote-total')`; - a CSS lookup becomes `page.locator('.premium-row td')`; - an XPath lookup can be pasted straight in — `page.locator('xpath=//table[@id="drivers"]//tr')` (a string starting with `//` or `..` is treated as XPath anyway); - a link-text lookup becomes `page.getByRole('link', { name: 'Compare cover' })`; - a label-driven field becomes `page.getByLabel('Annual mileage')`, and a test-attribute lookup becomes `page.getByTestId('quote-total')`. ## A wait becomes an assertion Playwright's `expect()` matchers for a locator are **auto-retrying**: `await expect(page.getByTestId('quote-total')).toHaveText('£412.60')` re-queries and re-checks until the condition holds or the expect timeout expires. That timeout defaults to **5000 ms** and is set separately from the test timeout, under `expect: { timeout: ... }` in `playwright.config.ts`. So a wait-for-visible, wait-for-text, wait-for-enabled or wait-for-count stops being a preamble and becomes the check the test was going to make anyway. | In the old suite | In the ported test | |---|---| | wait until element is displayed | `await expect(locator).toBeVisible()` | | wait until element is clickable | nothing — `locator.click()` waits by itself | | wait until text equals X | `await expect(locator).toHaveText('X')` | | wait until element count is 3 | `await expect(locator).toHaveCount(3)` | | a fixed sleep | delete it | | wait for the URL to change | `await page.waitForURL(/\/quote\/\d+/)` | ## What you still wait for on purpose Deleting *all* waiting is the opposite mistake. Keep an explicit wait when the thing you care about is an event the DOM does not show: 1. `page.waitForResponse('**/api/pricing')` when the test must inspect or race the network call, not just its rendered result. 2. `page.waitForURL()` after a redirect chain that the next assertion would otherwise start on the wrong page. 3. `locator.waitFor({ state: 'detached' })` when the meaningful signal is a node going away and there is no assertion to hang it on. 4. `page.waitForLoadState()` in the rare case where a later step depends on the load lifecycle rather than on any one element. `page.waitForTimeout(ms)` is real and it is documented as something to avoid outside debugging: porting a sleep into it reproduces the original flakiness with a Playwright badge on it. ## Two traps in a literal, line-for-line port - **Ambiguity now fails loudly.** A finder that used to return "the first match" maps onto a locator that resolves to *all* matches, and acting on more than one is an error rather than a silent pick of the first. Ported lookups usually need narrowing — `.filter()`, `.first()`, or a better role/name query — rather than a retry. - **Element references do not survive re-renders.** If the old test stored a reference and re-used it after the quote table redrew, the fix on the Playwright side is to keep the locator and re-use *it*; locators are cheap to declare at the top of the test and safe to use after any number of updates. ## Worked example: the quote-total test The legacy version clicked *Add driver*, slept two seconds for the pricing call, then read the total. Ported, it clicks the button and asserts on the total; the assertion absorbs the pricing round-trip because it keeps re-reading the cell until it shows the new figure. The test gets shorter, the timing constant disappears, and the failure message on a real regression names the element and shows the last observed value instead of "element not found after sleep".
- If the assertion does the waiting, why does the ported suite still get a timeout budget?Because a retrying matcher has to give up eventually. `expect` retries for its own timeout, 5 s by default and set under `expect: { timeout }`, inside the larger test timeout. A short expect timeout keeps a genuine regression fast to report; raising it globally to silence a flake hides the defect instead.
- How do you port a wait that guarded a network call rather than an element?Keep it. Wrap the triggering action with `page.waitForResponse('**/api/pricing')` so the wait starts before the click, then assert on the rendered result. That preserves the original intent — the test cared about the call — while the DOM-level waiting still collapses into the assertion.
A locator is a saved search, not a search result: you re-run it every time you look, so it is never stale.
saying these in an interview costs you the question
- Claiming Playwright needs a global implicit wait configured
- Porting every sleep across as page.waitForTimeout to be safe
- Assuming expect and the test share one timeout value
- Believing a stored element reference survives a re-render
- Deleting network and navigation waits along with the sleeps