How to Wait for Elements to Disappear in a Single-Page App
Wait for a spinner, modal, or other element to disappear reliably. Choose the right condition in Playwright, Selenium, or Cypress instead of using a fixed sleep.
Use a condition-based wait that matches what “disappear” means in your test. Wait for hidden when the element may remain in the DOM but should no longer be visible; wait for detached or not exist when it must be removed from the DOM. Avoid fixed sleeps: they can be too short when a transition is slow and waste time when it is fast.
This guide shows the current patterns for Playwright, Selenium with Python, and Cypress. The APIs have different semantics, so pick the condition your test actually needs.
1. Decide what “disappear” means
Before writing a wait, determine the expected page state:
- Hidden: the element is still mounted, but it is not visible to the user. For example, it may have
display: noneor no rendered box. - Detached / absent: the element is no longer in the DOM. This is common when a framework unmounts a loading indicator after a request.
These conditions are not interchangeable. A hidden-element wait can pass while the element remains mounted. A removal assertion checks that it is absent.
2. Playwright: wait for hidden or detached
Use a locator’s waitFor method. The following is a complete runnable example using Playwright Test. Save it as disappear.spec.js and run it with npx playwright test disappear.spec.js after setting up Playwright Test and its browser.
import { test, expect } from '@playwright/test';
test('loading indicator disappears after submit', async ({ page }) => {
await page.goto('https://example.com');
await page.getByRole('button', { name: 'Submit' }).click();
const loading = page.locator('[data-testid="loading"]');
await loading.waitFor({ state: 'hidden', timeout: 10_000 });
await expect(page.getByText('Complete')).toBeVisible();
});
hidden passes when the locator is hidden or detached. If the test specifically requires DOM removal, use detached:
await page.locator('[data-testid="loading"]').waitFor({
state: 'detached',
timeout: 10_000,
});
The supported wait states are attached, detached, visible, and hidden. If the requested state already holds, the wait completes immediately. If it does not become true within the timeout, Playwright throws. Locator waits for hidden or detached return null.
For an assertion-oriented test, a web-first assertion is also useful when checking visibility:
await expect(page.locator('[data-testid="loading"]')).toBeHidden();
Use the current locator APIs rather than older selector-based page waits. page.isHidden() is an immediate check, not a wait; its timeout option is ignored.
3. Selenium with Python: wait for the inverse condition
Use WebDriverWait with until_not to wait until a visibility condition is false. This complete example uses Selenium’s Python binding and a CSS selector:
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
driver = webdriver.Chrome()
try:
driver.get("https://example.com")
driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
loading = (By.CSS_SELECTOR, ".loading")
WebDriverWait(driver, 10).until_not(
EC.visibility_of_element_located(loading)
)
print("Loading indicator is no longer visible")
finally:
driver.quit()
Remove the accidental leading space before driver = webdriver.Chrome() if copying the snippet into a file: Python top-level statements must begin at column one. Corrected standalone form:
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
driver = webdriver.Chrome()
try:
driver.get("https://example.com")
driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
loading = (By.CSS_SELECTOR, ".loading")
WebDriverWait(driver, 10).until_not(
EC.visibility_of_element_located(loading)
)
print("Loading indicator is no longer visible")
finally:
driver.quit()
For an ID, use (By.ID, "exampleId"). The condition shown waits for the element to stop being visible; choose a different predicate if your requirement is specifically that it be absent from the DOM. Selenium’s wait API polls the supplied condition until it returns the requested result or times out.
4. Cypress: assert that the element no longer exists
For DOM removal, use a retrying negative assertion. Cypress retries the DOM query and assertion until the condition passes or the command times out.
describe('loading indicator', () => {
it('is removed after submitting', () => {
cy.visit('https://example.com');
cy.get('[data-testid="submit"]').click();
cy.get('[data-testid="loading"]').should('not.exist');
});
});
not.exist checks DOM absence. If the element remains mounted but should be invisible, assert visibility instead:
cy.get('[data-testid="loading"]').should('not.be.visible');
Use the assertion that matches the application behavior. Cypress DOM queries retry, and action commands wait for actionability before performing the action.
5. Make the wait reliable
- Start the wait after the triggering action. Click the submit button or start the navigation before waiting for its resulting state.
- Use a stable locator. Prefer a test ID or another selector that identifies the intended element without relying on fragile styling.
- Wait for the meaningful state. If a spinner disappears before the results are ready, also wait for a result-specific condition, such as a success message becoming visible.
- Set a bounded timeout. Use a timeout suited to the operation and your test environment. Keep the failure actionable so a timeout identifies the condition that never arrived.
- Keep hidden and removed assertions distinct. Assert removal only when unmounting is part of the behavior under test.
Single-page apps can continue changing after the initial document is ready. Synchronize on the application state instead of assuming that page load or a fixed delay means the UI is settled.
6. Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| The wait passes, but the element is still in the DOM. | You waited for hidden state, which can accept a hidden element. | Use Playwright detached or Cypress should('not.exist') when removal is required. |
| The wait times out even though the UI looks finished. | The selector points at a different node, or the element remains visible while another part of the UI changes. | Inspect the matched element and choose a selector and condition that describe the intended outcome. |
| A check reports the current state immediately. | An immediate query was used in place of a wait. | Use Playwright locator waitFor or a web-first assertion. page.isHidden() does not wait. |
| The test is flaky with a fixed delay. | The transition sometimes takes longer than the delay, or usually takes less. | Replace the sleep with an explicit wait or retrying assertion on the expected state. |
| Selenium waits take an unpredictable amount of time. | Implicit and explicit waits are mixed. | Use one synchronization strategy; Selenium warns against mixing implicit and explicit waits. |
| The spinner is gone but the test still races the result. | Spinner disappearance is not the same as the app’s completion state. | Wait for the result, success message, or other user-visible completion condition too. |
7. Performance, reliability, and cost
Condition-based waits are usually more efficient than choosing one large fixed sleep: they can complete as soon as the condition is true, while a fixed delay always holds the test for its full duration. A timeout is a ceiling, not a requested pause. No framework-specific benchmark is implied here.
For reliability, keep the condition tied to the user-visible outcome the test cares about. If the product has several independent loading indicators, target the one associated with the action. A wait can be logically correct and still test the wrong part of the page if its selector is too broad.
These framework waits do not add a separate per-wait service cost; the practical cost is browser and test-run time. Avoid excessive timeouts that hide a stalled app, and avoid short limits that fail under ordinary environment variation.
8. FAQ
Should I wait for a spinner or for the results?
Prefer the condition that proves the behavior under test. If the requirement is that the spinner goes away, wait for that; if the requirement is that the request’s results are ready, wait for a result-specific condition.
Can a hidden element still exist?
Yes. Hidden describes visibility; detached or not-exist describes DOM presence.
What if the element never appears?
A disappearance wait may pass immediately if its target state already holds. If the test must prove that the element appeared before it disappeared, assert or wait for visibility first, then wait for hidden or detached.
Or skip the browser setup
If your goal is to capture a page after its UI settles, ScreenshotNeo is a website screenshot API and MCP server for developers. The framework waits above are for browser tests; ScreenshotNeo is an option when you need the resulting page image or PDF.
One GET request captures a page. See the ScreenshotNeo API documentation for the available parameters.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://example.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
- Cookie banners, popups, and chat widgets are removed before the shot; each cleanup step can be turned off.
- Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed. Response headers report the page verdict and billing status.
- An MCP server provides
take_screenshot,get_page_info, andcapture_pdftools for AI agents. - The free plan includes 1,000 screenshots a month with no card. Paid plans start at $5 for 3,000 screenshots.
Sign up free for 1,000 screenshots a month, no card required.


