How does Playwright's expect(locator).toHaveText() retry until the text matches?
answer
- It polls, it does not wait once
- The query is re-run every attempt
- Holds a query, not a node
- Own budget per assertion, 5 s default
- Call log shows the last received value
basics
~20 sEach attempt re-runs the locator query from scratch, reads the element's text, normalizes whitespace and compares it. Playwright repeats until the condition holds or the expect timeout (5 seconds by default in 1.63) expires, then reports the last value seen.
solid answer
~50 sThe matcher is a polling loop that owns both the lookup and the comparison. On every attempt it re-runs the locator query against the live DOM, reads the text, normalizes whitespace, and compares it with the expected string or regex; if it does not match it waits briefly and starts again. It gives up at the expect timeout, 5000 ms by default in Playwright 1.63, and each assertion gets its own budget within the test timeout. Re-querying every attempt is what makes it immune to re-renders — the matcher holds the query, not a DOM node. On failure it prints the expected value, the last received value, and a call log of the attempts, which separates 'element never appeared' from 'element was there with the wrong value'. What it never retries is the expected argument or any value you read earlier — those are evaluated once.
code
typescript · 10 linesimport { test, expect } from '@playwright/test';
test('statement rows finish streaming in', async ({ page }) => {
await page.goto('/statements/2026-08');
// Each assertion runs its own retry loop, re-querying the DOM every attempt.
await expect(page.getByTestId('balance')).toHaveText('£1,240.00');
await expect(page.getByRole('row')).toHaveCount(24);
await expect(page.getByRole('button', { name: 'Export' })).toBeEnabled({ timeout: 15_000 });
});go deeper
Know that the assertion is itself the wait: it keeps checking for a few seconds before failing, so you do not need to add anything before it.
Explain the loop out loud — re-query, read, normalize, compare, repeat until the expect timeout — and why re-querying rather than caching the node is what makes it re-render proof.
Use the failure output as a diagnostic: distinguish never-appeared from wrong-value, and scope a longer timeout to the one genuinely slow assertion instead of the global default.
Frame the budget question for the team: long global timeouts hide product slowness and stretch every red run, so decide where waiting is legitimate and make that visible per assertion.
## The loop, step by step A web-first matcher is a small polling loop that owns both halves of the check — finding the element and comparing the value. For `await expect(page.getByTestId('balance')).toHaveText('£1,240.00')` on the statement page, each attempt does this: 1. Re-run the locator query against the live DOM, from the root, as if for the first time. 2. If it resolves to an element, read the value the matcher cares about — here the text content. 3. Normalize it (runs of whitespace collapse to single spaces, leading and trailing whitespace is trimmed) and compare it with the expected string or regex. 4. If it matches, resolve and let the test continue. If not — or if the element is not there at all — wait a moment and go back to step 1. 5. When the expect timeout is reached without a match, fail and print what the last attempts saw. The default expect timeout is **5000 ms in Playwright 1.63**, and each assertion gets its own budget: three assertions in a row can each spend up to the full timeout. The whole test is still bounded by the test timeout (30 s by default), so an assertion that is left waiting can be cut short by the test's own deadline instead. ## Re-querying is what removes staleness The step that matters most is step 1. The matcher does not hold a reference to a DOM node; it holds the **query**. If the statement table re-renders between attempts and every row node is replaced, the next attempt simply finds the new nodes. That is why a retrying matcher survives a framework re-render that would break a captured element handle, and why `expect(locator).toHaveCount(24)` is stable while rows stream in — it keeps asking "how many rows match right now" until the answer is 24. ## What the failure message gives you A timed-out web-first assertion prints the expected value, the last received value, and a call log of the attempts: ``` Error: Timed out 5000ms waiting for expect(locator).toHaveText(expected) Locator: getByTestId('balance') Expected string: "£1,240.00" Received string: "£0.00" Call log: - waiting for getByTestId('balance') - locator resolved to <span data-testid="balance">£0.00</span> - unexpected value "£0.00" ``` That distinction is the diagnosis. **Received: "£0.00"** means the element was found and the app never produced the value — a product or fixture problem. A log that never gets past *waiting for* means the element never appeared, which is a locator or a rendering problem. An empty received string usually means you asserted before the data arrived and the element renders empty first. ## What the loop does not retry - **The expected value.** It is evaluated once, before the call. `toHaveText(computeExpected())` runs that function a single time; the loop only re-reads the page. - **Anything you computed earlier.** A value pulled out with `locator.textContent()` on a previous line is a snapshot; feeding it to a generic matcher gets no retries at all. - **The locator's own strictness.** If the query resolves to several elements, that is an error the loop will not wait out by itself. - **Non-locator subjects.** Retrying an arbitrary function or a value from an API call is a different tool's job, not this matcher's. ## Which clock applies | Clock | Default in 1.63 | Governs | |---|---|---| | expect timeout | 5000 ms | one web-first assertion's retry loop | | test timeout | 30000 ms | the whole test body, including all assertions | | per-assertion `{ timeout }` | inherits the expect timeout | that single assertion only | A single slow assertion is best handled with an inline `{ timeout }` on the one call that legitimately waits for a slow export, rather than by raising the global budget and slowing every failure in the suite down. ## The practical consequences - Assertions are the wait: `await expect(exportButton).toBeEnabled()` both waits for and checks the condition, so there is nothing extra to add before it. - A failing web-first assertion costs its full timeout, so ordering matters — assert the cheap, early-appearing condition first and the derived one after. - Because each attempt re-queries, a matcher on a badly-scoped locator retries a wrong query for the whole timeout and then reports a confusing "unexpected value" from the wrong element. Read the resolved element in the call log before blaming the app.
- If three web-first assertions run back to back, can the test exceed the expect timeout three times over?Yes. The expect timeout is per assertion, not per test, so three failing assertions could each burn 5 s. The bound is the test timeout — 30 s by default — which cuts the test off wherever it happens to be, so a slow assertion can be reported as a test timeout rather than an assertion failure.
- The call log shows the locator resolved but the value stayed wrong for the whole timeout. What does that tell you?That the element exists and the app genuinely never produced the expected value, so it is not a timing problem — extra waiting will not help. Look at the fixture data, the request the page made, or the expected string itself. A log that never leaves 'waiting for' is the opposite case: the element never appeared.
saying these in an interview costs you the question
- Thinks the matcher waits once then compares a single time
- Says it caches the element, so a re-render breaks it
- Believes the expect timeout is shared across the whole test
- Expects the expected argument to be re-evaluated each attempt
- Assumes it can retry any value, not just a locator