playwright waiting for locator to be hidden timeout - the element never hides in CI
Fixes Playwright hidden-state timeouts where the element never hides in CI. Use when waiting for locator to be hidden times out only in CI. Covers screenshotting the actual state, finding the condition that should hide the element, and waiting on the underlying cause. Not for elements hidden everywhere.
TL;DR
The element never reaches the hidden state in CI because whatever should hide it behaves differently there: a request that never resolves keeps the spinner up, an animation runs longer, or a timer fires later. Waiting longer on the symptom sometimes works, but the durable fix is to wait for the underlying condition, the response, the navigation, the state change, and to assert on the right terminal state: hidden, detached, and stable are three different things in Playwright.
The query
playwright waiting for locator to be hidden timeout - the element never hides in CIUse this when
- waiting for locator to be hidden times out only in CI
- a spinner, toast, or overlay never dismisses in CI
- the element hides reliably on local runs
Not for
- the element is hidden in every environment (the assertion or the selector is wrong)
- strict mode violations about multiple elements (a locator problem, not a timing problem)
- the page never loads at all in CI (fix the navigation first)
Steps
1. Capture what the page actually looks like at failure
Use the trace or a screenshot on failure to see the element's real state. The class of fix depends on whether it is visible, animating, or gone-but-not-detached.
await page.screenshot({ path: 'hidden-timeout.png', fullPage: true });Expected output: you see the element's actual state instead of guessing from the timeout message.
2. Find the condition that is supposed to hide it
Trace the element back to its cause: a loading spinner waits on a request, a toast waits on a timer, a modal waits on an animation. Check what that cause does in CI.
// example: the spinner hides when the request finishes
await page.waitForResponse('**/api/dashboard');
await expect(page.getByTestId('spinner')).toBeHidden();Expected output: the blocking condition is identified: usually a slow or hanging request in CI.
3. Wait for the condition, then assert the right state
Order the waits causally: first the thing that should happen, then the visibility assertion. Pick toBeHidden for CSS-hidden, and check detached separately if the element is removed from the DOM.
await page.waitForResponse('**/api/dashboard', { timeout: 30000 });
await expect(page.getByTestId('spinner')).toBeHidden({ timeout: 10000 });Expected output: the assertion passes deterministically because its precondition is guaranteed.
4. Raise the timeout only after the cause is understood
If the condition itself is legitimately slower in CI, raise that step's timeout with a comment explaining why, rather than blanket-raising every assertion.
await expect(page.getByTestId('spinner')).toBeHidden({ timeout: 15000 }); // CI API p95 is 9sExpected output: the suite is green in CI with timeouts that document their reasons.
Tool compatibility
- Playwright 1.40 - 1.55
- any browser Playwright supports
Variant phrasings
- waiting for locator to be hidden timeout in CI
- playwright toBeHidden times out only in CI
- locator expected to be hidden never hides in CI
Why it happens
Playwright's hidden check waits for the element to become non-visible, but the application decides when that happens. In CI the application's own timing shifts: APIs are slower, so spinners stay up; animations run at different frame rates; timers fire later under CPU contention. The test times out waiting for a state the app has not reached yet, which looks like a test problem but is an application-timing problem.
Edge cases / pitfalls
- visibility:hidden, display:none, and detached-from-DOM are different states; toBeHidden covers the first two, not the third
- animations disabled in CI via reduced-motion flags can change which state the element ends in
- a request that hangs forever in CI (a service not running) makes any wait futile; verify the backend is up first
- polling the element's class list in the trace viewer shows whether it is stuck mid-transition or never started
Provenance
Resolved from the public thread: https://vectle.com/posts/pst_L5hkkca4MbIgzYDapBn0IQ