The most surprising thing about Playwright’s async patterns is that they fundamentally change how you think about test execution, making your tests more synchronous in practice by ensuring operations complete before the next one starts.
Let’s watch Playwright in action. Imagine we’re testing a simple to-do list application.
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://demoqa.com/text-box")
# Interact with elements
page.fill("#userName", "John Doe")
page.fill("#userEmail", "john.doe@example.com")
page.locator("#currentAddress").fill("123 Main St")
page.locator("#permanentAddress").fill("456 Oak Ave")
# Click a button
page.locator("#submit").click()
# Assertions
assert page.inner_text("#name") == "Name:John Doe"
assert page.inner_text("#email") == "Email:john.doe@example.com"
browser.close()
This looks pretty straightforward, right? We launch, navigate, fill fields, click, and assert. The sync_playwright context manager and the sync_api functions make it feel synchronous. But under the hood, Playwright is still heavily leveraging asynchronous operations. When you call page.fill(), Playwright doesn’t immediately type characters. It queues up the "fill" action, waits for the element to be visible, enabled, and actionable, then performs the fill, and then waits for the action to complete before returning control to your script. This built-in waiting mechanism is key to reliability.
The problem Playwright’s async patterns solve is the classic "flaky test" issue. In older, purely synchronous testing frameworks, you’d often have to manually insert time.sleep() calls or poll for element states. This was brittle: too short a sleep and the test failed, too long and it was agonizingly slow. Playwright abstracts this away. Every interaction method (click, fill, waitForSelector, etc.) has built-in auto-waiting. When you call page.locator("#submit").click(), Playwright automatically waits for the #submit element to be present, visible, and enabled before attempting to click it. It then waits for the navigation (if any) or other side effects of the click to complete. This makes your tests robust against variations in page load times and UI rendering.
The core mental model for Playwright, even when using the synchronous API, is one of command queuing and implicit waiting. You issue commands, and Playwright executes them in order, ensuring each command has completed successfully according to its internal waiting logic before proceeding to the next. This is why you can write page.fill(...); page.click(...) and be confident that the fill operation has finished before the click is attempted.
The page.locator(selector) method is fundamental. It returns a Locator object, which is essentially a pointer to an element on the page. Crucially, a Locator object is inert until an action is performed on it. It doesn’t actually find the element until you call a method like .click(), .fill(), or .textContent() on it. This lazy evaluation is a powerful concept. It means Playwright is always querying the current state of the DOM when an action is requested, not relying on a snapshot taken at the time the locator was created. This is a major contributor to reliability, as it adapts to dynamic content and DOM changes.
Consider this common pattern: you need to wait for a specific element to appear after an action.
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://playwright.dev/docs/dialogs")
# Trigger a dialog
page.on("dialog", lambda dialog: dialog.accept("My reason"))
page.get_by_role("button", name="Show confirm").click()
# Now, wait for and interact with an element that appears *after* the dialog is handled
# Playwright's auto-waiting on the next action handles this implicitly.
page.locator("text=My reason").wait_for() # Explicit wait for clarity, but often implicit.
assert page.inner_text("text=My reason") == "My reason"
browser.close()
In the code above, after clicking "Show confirm," a dialog appears. We handle it with dialog.accept(). The next line, page.locator("text=My reason").wait_for(), demonstrates an explicit wait, but even if we just did assert page.inner_text("text=My reason") == "My reason", Playwright’s auto-waiting would ensure the "My reason" text is present and visible before the assertion is performed. This is the magic: Playwright waits for the action to complete, and then waits for the next action’s target to be ready.
The one thing most people don’t fully grasp is how Playwright’s locators, combined with auto-waiting, allow you to write tests that are resilient to both network latency and complex JavaScript execution. When you use page.locator("selector").click(), Playwright doesn’t just look for a DOM element with that selector. It waits for the element to be visible, enabled, and not obscured by other elements. It then waits for the click event to be handled by the page and for any subsequent navigation or DOM updates to settle. This multi-stage waiting process is what makes tests reliable without explicit sleep calls.
The next concept you’ll want to explore is advanced locator strategies and how to effectively use page.wait_for_load_state() for more granular control over page readiness.