Skip to main content

Waitless

PyPI version Python versions License: MIT CI

Stop flaky Selenium tests caused by clicking before the page is ready. Waitless wraps your WebDriver so get, find_element and click wait for the DOM and network to settle (and, in strict mode, for animations and layout too): no time.sleep(), less WebDriverWait boilerplate, and fewer StaleElementReferenceException, ElementClickInterceptedException and NoSuchElementException failures caused by timing races.

Automatic UI stabilization for Selenium. Reduce explicit waits and sleeps by automatically evaluating multiple UI stability signals.

Learn more: Waitless v1: the end of flaky tests · Eliminating flaky tests with Waitless · Project page · Follow @dkkkkd on X

Installation

pip install waitless

Quick Start

from selenium import webdriver
from selenium.webdriver.common.by import By
from waitless import stabilize

driver = webdriver.Chrome()
driver = stabilize(driver)

# Navigation, lookups, and common element actions now wait for stability.
driver.get(
    "data:text/html,<input id='username'><button id='login-button'>Log in</button>"
)
driver.find_element(By.ID, "username").send_keys("user")
driver.find_element(By.ID, "login-button").click()
driver.quit()

Why Waitless?

The Problem

Automation tests fail because interactions happen while the UI is still changing:

  • DOM mutations from React/Vue/Angular updates
  • In-flight AJAX requests
  • CSS animations and transitions
  • Layout shifts from lazy-loaded content

Traditional Solutions (and why they fail)

Approach Problem
time.sleep(2) Too slow, still fails sometimes
driver.implicitly_wait(10) Only retries element lookup until the element exists; it does not wait for pending requests, animations or DOM churn
WebDriverWait Requires an explicit condition at each synchronization point
Retries Masks the real problem, adds flakiness

The Waitless Solution

Waitless evaluates page-level stability signals:

  • DOM mutation activity (MutationObserver, including Shadow DOM)
  • Pending network requests (XHR/fetch interception after instrumentation is installed)
  • CSS animations and transitions
  • Layout stability for interactive elements
  • WebSocket/SSE activity (opt-in)
  • Framework hooks (React/Angular/Vue, opt-in)
  • Same-origin iframe load readiness (opt-in; not full child-frame signal injection)

Before supported interactions, Waitless polls until the enabled mandatory signals meet their configured thresholds.

When to use Waitless (and when not to)

Good fit:

  • Selenium 4 test suites in Python, on single-page apps (React, Angular, Vue) where clicks land while the page is still re-rendering
  • Pages with AJAX/fetch calls, lazy-loaded content or long-polling that make tests pass locally and fail in CI
  • Suites full of time.sleep() calls or copy-pasted WebDriverWait boilerplate that you want to remove

Not a good fit:

  • Playwright. Playwright already auto-waits for elements to be actionable, and Waitless only supports Selenium.
  • Specific business conditions. If a test must wait for "the order status says Shipped", keep a WebDriverWait with an explicit condition for that. Waitless waits for the page to be stable, not for your app to reach a particular state. The two work well together.
  • Async Selenium clients or Service Worker traffic. See Current Limitations.
time.sleep implicitly_wait WebDriverWait Waitless
Waits for element presence No (fixed delay) Yes Yes (per condition) Yes (find_element retries until timeout)
Waits for pending XHR/fetch No No Only if you write the condition Yes
Waits for DOM mutations to settle No No Only if you write the condition Yes
Waits for animations/layout No No Only if you write the condition Strict mode
Code needed per interaction One sleep None One condition None after stabilize(driver)

Configuration

from waitless import stabilize, StabilizationConfig

config = StabilizationConfig(
    timeout=10,                    # Max wait time (seconds)
    mutation_rate_threshold=50,    # mutations/sec considered stable (allows animations)
    network_idle_threshold=0,      # Wait for all tracked XHR/fetch requests
    animation_detection=True,      # Track CSS animations (non-blocking in normal mode)
    strictness='normal',           # 'strict' | 'normal' | 'relaxed'
    debug_mode=True                # Enable logging
)

driver = stabilize(driver, config=config)

Strictness Levels

Level What It Waits For
strict DOM + Network + Animations + Layout
normal DOM + Network (default)
relaxed DOM only

Factory Methods

# For strict testing
config = StabilizationConfig.strict()

# For apps with background traffic
config = StabilizationConfig.relaxed()

# For CI environments
config = StabilizationConfig.ci()

Manual Stabilization

If you don't want to wrap the driver:

from waitless import wait_for_stability

wait_for_stability(driver)
driver.find_element(By.ID, "button").click()

Disabling Stabilization

from waitless import unstabilize

driver = unstabilize(driver)  # Back to original behavior

Diagnostics

When tests fail, get detailed analysis:

from waitless import get_diagnostics, StabilizationTimeout
from waitless.diagnostics import print_report

try:
    driver.find_element(By.ID, "slow-button").click()
except StabilizationTimeout as e:
    diagnostics = get_diagnostics(driver)
    print_report(diagnostics)  # Print detailed report

CLI Doctor Command

python -m waitless doctor --file diagnostics.json

Sample output:

+--------------------------------------------------------------------+
|                     WAITLESS STABILITY REPORT                      |
+--------------------------------------------------------------------+
| BLOCKING FACTORS:                                                  |
|   [!] NETWORK: 2 request(s) still pending                          |
|   -> GET /api/users                                                |
|   [!] ANIMATIONS: 1 active animation(s)                            |
+--------------------------------------------------------------------+
| SUGGESTIONS:                                                       |
|   1. Set network_idle_threshold=2 for background traffic           |
|   2. Use animation_detection=False for infinite spinners           |
+--------------------------------------------------------------------+

Important Notes

Network Threshold Warning

The default and StabilizationConfig.strict() use network_idle_threshold=0, so all tracked XHR/fetch requests must finish before the network is considered idle. This prevents a delayed response from leaving a loading overlay in place when an interaction starts. Set network_idle_threshold=2 to allow up to two requests to remain pending for background analytics or polling.

When upgrading to 1.1.0, apps with constant polling or analytics can set network_idle_threshold=2 to restore the previous allowance. Two other behavior changes apply: clicking a disabled element now waits and raises a timeout if it stays disabled, and a stale retry refuses a replaced element with different text or a different fingerprint rather than clicking it.

Many apps have background traffic that never stops:

  • Analytics calls
  • Long polling
  • Feature flags
  • WebSocket heartbeats

If known background traffic exceeds the default, raise the threshold carefully:

config = StabilizationConfig(network_idle_threshold=3)

Wrapped Elements

The stabilized driver returns wrapped elements that auto-wait before click(), send_keys(), submit(), and clear().

click() also waits for is_enabled() to become true. If the element stays disabled, its timeout message includes the tag, visible text, and configured duration. Each click() and find_element() call shares one timeout budget across its stabilization, polling, and retry waits. If an element permanently disappears before a click retry, the error says it is no longer on the page instead of reporting that it was not enabled.

Elements returned by find_element() retain their locator and a fingerprint captured while the element is live: tag name, visible text, the id, name, data-testid, and aria-label attributes when present, and its parent's trimmed text. Element and parent text are capped at 200 characters in the browser. Fingerprint fields are read in one script call within the existing timeout budget, without scanning all matches of the locator. A stale click can retry once: Waitless waits for the page to settle, looks up the candidate using the same locator, and checks its fingerprint before clicking. If the fingerprint differs, Waitless raises StaleElementReferenceException explaining that it refused to click a different element. Identical replacements can be retried; elements indistinguishable by these fields cannot be distinguished by this check.

Changes to surrounding row text can also cause refusal: if a button is replaced and its parent's text changes from Cart (1) to Cart (2), the stale retry raises StaleElementReferenceException, the same exception plain Selenium raises for a stale element. Use an ID or a row-scoped locator to target the intended button instead of an ambiguous first match. A specific locator does not bypass the parent-text check, so it cannot guarantee a retry after surrounding text changes. If that change is expected, explicitly find the element again after the update.

Elements returned by find_elements() or execute_script() (including execute_async_script()) do not retain a locator and do not retry stale clicks. Their clicks still wait for stability and enabled state. Successful clicks do not read a fingerprint. If the enabled wait times out, one diagnostic script read adds the element's tag and text to the error; this read adds a WebDriver round trip after the wait budget expires. If the element has become stale, the error retains a generic element label. They behave like WebElements but:

  • isinstance(element, WebElement) returns False
  • Use .unwrap() to get the original element if needed
element = driver.find_element(By.ID, "button")
original = element.unwrap()  # Gets the real WebElement

Optional Signals

  • WebSocket/SSE Awareness - Track WebSocket and Server-Sent Events activity
  • Framework Adapters - React, Angular, Vue hooks for framework-specific settling
  • iframe Support - Monitor same-origin iframes
  • Performance benchmarks - Run the repository benchmark against your environment
config = StabilizationConfig(
    track_websocket=True,         # WebSocket monitoring
    track_sse=True,               # SSE monitoring
    framework_hooks=['react'],    # React adapter
    track_iframes=True,           # iframe monitoring
)

Performance

Metric Typical Value
Instrumentation injection Environment-dependent; from a repository checkout, run python benchmarks/overhead_test.py
Per-poll overhead Environment-dependent; run the repository benchmark
Poll interval (default) 50ms
Stabilization time Depends on page activity, thresholds, and environment

Navigation Handling

Wrapped get(), refresh(), back(), and forward() wait after Selenium's synchronous navigation call returns. For SPA route changes initiated by page JavaScript, Waitless validates/re-injects instrumentation on the next wait:

  1. Checks __waitless__.isAlive() before each wait
  2. Detects URL changes via driver.current_url
  3. Re-injects if instrumentation is missing

This does not observe routes continuously, and cross-origin iframe internals remain outside the browser same-origin boundary.

Because instrumentation is installed after Selenium's synchronous navigation call returns, requests that start and finish during navigation are not observed. Requests started after instrumentation is installed are tracked.

find_elements() keeps Selenium's immediate-empty lookup semantics: after the page-stability wait, it performs one lookup and returns [] when there are no matches. By contrast, find_element() retries NoSuchElementException until the configured timeout after the page-stability wait.

Current Limitations

  • Selenium only - No Playwright integration
  • Sync only - No async/await support yet
  • No Service Workers - SW network requests not intercepted

See CHANGELOG.md for version history.

FAQ and Troubleshooting

Does it work with pytest? Yes. Waitless has no runner dependency; wrap the driver in your fixture:

import pytest
from selenium import webdriver
from waitless import stabilize

@pytest.fixture
def driver():
    d = stabilize(webdriver.Chrome())
    yield d
    d.quit()

Does it work with Shadow DOM? Yes, for open shadow roots. The MutationObserver also watches open shadow roots, and elements and shadow roots returned from searches are wrapped so they auto-wait too. Closed shadow roots are not visible to page JavaScript, so they are not observed.

Does it work with SeleniumBase or other wrappers? It isn't tested against SeleniumBase in this repository. stabilize() accepts any WebDriver-like object that has execute_script, find_element and current_url, so you can wrap the underlying Selenium driver. If a framework needs a real WebDriver instance, pass it driver.unwrapped.

My app has WebSockets, analytics beacons or long polling and never goes idle. I get StabilizationTimeout. Run the diagnostics (see Diagnostics) to see which signal is blocking. WebSocket/SSE tracking is off by default, so it doesn't block unless you enable it. For background XHR/fetch traffic, raise network_idle_threshold or use StabilizationConfig.relaxed() (DOM only). For infinite spinners, set animation_detection=False.

How much slower does it make my tests? Waitless polls every 50 ms (poll_interval) while waiting for stability. The DOM must have been quiet for 0.1 s (dom_settle_time), and the other configured signals must also be stable. An already settled page can pass the check immediately; Waitless does not add a fixed pause to every interaction. New activity extends the wait within the call's timeout budget (10 s by default). find_element() checks stability before and after locating an element, and click() also waits for enabled state. WebDriver commands themselves, including any Selenium implicit waits, may extend wall-clock time beyond that budget. Measure it in your environment with python benchmarks/overhead_test.py from a repository checkout.

isinstance(element, WebElement) is False. That's expected: the stabilized driver returns wrapped elements. Call .unwrap() on an element, or use driver.unwrapped for the original driver. See Wrapped Elements.

API Reference

Functions

Function Description
stabilize(driver, config=None) Enable auto-stabilization
unstabilize(driver) Disable and return original driver
wait_for_stability(driver, timeout=None) Manual one-time wait
get_diagnostics(driver) Get diagnostic data

Classes

Class Description
StabilizationConfig Configuration options
StabilizedWebDriver Wrapped driver with auto-wait
StabilizedWebElement Wrapped element with auto-wait
StabilizationTimeout Exception when UI doesn't stabilize

License

MIT. See LICENSE.

Metadata

Release files for waitless 1.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for waitless 1.1.0
File Size Uploaded Provenance
waitless-1.1.0.tar.gz 44.8 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for waitless 1.1.0
File Interpreter ABI Platform
waitless-1.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 88.8 kB

Release files / waitless-1.1.0.tar.gz

Download URL waitless-1.1.0.tar.gz
Size 44.8 kB
Tags Source
SHA-256 checksum
How to use checksums
8ff816d2e89cf16ffdf42d0632888a7d314d7209141c99bfe8393f92693ef9e2
BLAKE2b-256 checksum
How to use checksums
687c02cfcc588895e473c239daa76b63c71446a257c31fa5f3f07db3d068a215
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 24, 2026.

Transparency log

Release files / waitless-1.1.0-py3-none-any.whl

Download URL waitless-1.1.0-py3-none-any.whl
Size 44.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
8a0557001b3b3c0164982f766c5827cc86781d5897866ac1b205e51b828b3af4
BLAKE2b-256 checksum
How to use checksums
62e728d160d2346ca8259409b52d89cc9cf58468c1d47ef1d5216a759be3cb16
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 24, 2026.

Transparency log

Security for waitless 1.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for waitless-1.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 24, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page