Playwright tests can feel like watching paint dry, but the slowness isn’t inherent to the browser automation itself; it’s usually a symptom of how we’re interacting with the application under test.

Let’s see what a slow test looks like in the wild. Imagine this simple test. It navigates to a page, types into a search box, and asserts that the search results are displayed.

import pytest
from playwright.sync_api import sync_playwright

def test_slow_search():
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        page.goto("https://example.com/search") # Imagine this is a slow loading page

        # This is where the slowness often creeps in
        page.fill("input#search-box", "some query")
        page.click("button#search-button")

        # Waiting for results can be tricky
        page.wait_for_selector("div.search-results")

        assert page.is_visible("div.search-results")
        browser.close()

This test might seem straightforward, but if example.com/search is a complex application with many network requests, client-side rendering, or slow API responses, the test will spend most of its time waiting. The page.goto(), page.fill(), page.click(), and especially page.wait_for_selector() calls are all implicitly waiting for something to happen. If that "something" is slow, the test is slow.

The Core Problem: Implicit Waits and Unresponsive UI

The primary reason Playwright tests become slow is that they’re often waiting for the application to become ready, and the application itself is slow to respond. This isn’t Playwright’s fault; it’s a reflection of the application’s performance. When Playwright actions like fill, click, or waitForSelector are called, they have default timeouts and internal waiting mechanisms. If the DOM elements aren’t ready, or if network requests are taking too long, Playwright will patiently wait, and your test execution time balloons.

Profiling Your Slowdown

Before you can speed things up, you need to know where the time is being spent. Playwright has a built-in tracing tool that’s invaluable here.

Diagnosis: Run your test with tracing enabled.

playwright test --trace on

This will create a trace.zip file in the test-results directory after each test run. Open this file in Playwright’s trace viewer:

npx playwright show-trace test-results/trace.zip

In the trace viewer, you’ll see a timeline of all actions, network requests, and DOM snapshots. Look for the longest-running operations. These are often page.goto, page.waitForSelector, or custom page.waitForFunction calls. Pay close attention to the network tab within the trace viewer to identify slow API calls or asset loading.

Common Causes and Fixes

  1. Slow Page Load: The initial page.goto() or subsequent navigation is taking too long because the browser is waiting for numerous network requests (JS, CSS, images, API calls).

    • Diagnosis: In the trace viewer, observe the duration of the page.goto action and the network requests that occur immediately after.
    • Fix:
      • Optimize Application Assets: This is an application-level fix. Compress images, minify JS/CSS, use lazy loading, and ensure efficient API calls.

      • Selective Network Interception: If specific, slow, non-critical network requests are blocking the page load, you can intercept and mock them.

        page.route("**/*", lambda route: route.continue_() if route.request.url.endswith((".png", ".jpg")) else route.abort())
        page.goto("https://example.com/slow-page")
        

        This example aborts all image requests, speeding up the load if images are not essential for the test’s assertion.

      • waitUntil Option: Playwright’s goto has a waitUntil option. The default is 'load', which waits for the load event. 'domcontentloaded' is faster as it only waits for the HTML to be parsed. 'commit' is even faster, waiting only for the initial navigation response.

        page.goto("https://example.com/slow-page", waitUntil="domcontentloaded")
        

        This reduces the wait time by not waiting for all resources like images and stylesheets to fully load.

  2. Overly Broad waitForSelector: Using page.waitForSelector("div") waits for any div, which might appear very early but doesn’t guarantee the content you need is ready.

    • Diagnosis: In the trace, see how long waitForSelector is active and if it’s waiting for a generic element.

    • Fix: Be specific. Wait for the actual element that signifies your test condition is met. If you’re waiting for search results, wait for an element that contains the results, not just a container div.

      # Instead of:
      # page.wait_for_selector("div.results-container")
      
      # Wait for a specific result item, or a count:
      page.wait_for_selector("div.search-results > div.result-item:nth-child(3)") # Waits for the 3rd result item
      # Or:
      page.wait_for_selector("div.search-results", state="visible", timeout=30000) # Ensure it's visible
      

      This ensures you’re waiting for the meaningful content to appear, not just a placeholder.

  3. Unnecessary Waits for Network Activity: Tests often include page.wait_for_timeout(milliseconds) or page.wait_for_selector on elements that are already present but not yet interactive due to ongoing client-side JavaScript.

    • Diagnosis: Trace shows a static wait or a waitForSelector that resolves quickly, but the subsequent action fails or is slow.

    • Fix: Use page.wait_for_load_state() with specific states or page.wait_for_function().

      # Instead of: page.wait_for_timeout(5000)
      page.wait_for_load_state("networkidle") # Waits until there are no more than 0 network connections for at least 500 ms.
      # Or, if waiting for a specific JS condition:
      page.wait_for_function("window.myApp.isReady()", timeout=10000)
      

      networkidle is powerful for ensuring all background AJAX calls have completed, preventing race conditions where an element is rendered but not yet populated with data.

  4. Flaky Interactions Due to Animation: Elements might be visible but not yet fully animated into place, leading to interaction failures or delays.

    • Diagnosis: Trace shows an action (like click) succeeding quickly after a waitForSelector, but the application doesn’t react as expected, or subsequent actions are delayed.

    • Fix: Wait for the element to be stable or for a specific state that indicates the animation is complete.

      # Wait for an element to be visible and stable (not animating or being added/removed)
      page.wait_for_selector("button.animated-button", state="attached")
      # Then, if needed, wait for a specific property change or a short delay if the app is poorly designed:
      page.wait_for_function("document.querySelector('button.animated-button').style.opacity === '1'")
      

      Waiting for attached or visible states is generally sufficient, but sometimes a specific DOM property or setTimeout within waitForFunction is necessary for stubborn animations.

  5. Inefficient Locators: Using very broad or brittle locators (e.g., xpath=//div[contains(text(), 'some text')]) can be slow for the browser to evaluate.

    • Diagnosis: The trace shows significant time spent on locator resolution, especially for complex XPath or CSS selectors.

    • Fix: Use Playwright’s robust locators. Prefer getByRole, getByLabel, getByPlaceholder, getByText, and getByAltText which are more resilient to UI changes and often faster.

      # Instead of:
      # page.locator("xpath=//button[@class='submit-btn']").click()
      
      # Use:
      page.get_by_role("button", name="Submit").click()
      

      Role-based locators abstract away the underlying HTML structure, making them less brittle and often more performant as the browser has optimized ways to find elements by role.

  6. Excessive page.reload() or browser.close()/browser.new_page(): Repeatedly closing and reopening browsers or pages within a single test suite or even a single test can be a significant overhead.

    • Diagnosis: Observe the test duration in your CI logs or local runs. If tests start fast but slow down over time, or if individual test setup/teardown takes a long time, this is a suspect.
    • Fix:
      • Reuse Browser/Context: For faster test suite execution, reuse the browser instance and context across tests.

        # conftest.py for pytest
        import pytest
        from playwright.sync_api import sync_playwright
        
        @pytest.fixture(scope="session")
        def browser():
            with sync_playwright() as p:
                browser = p.chromium.launch()
                yield browser
                browser.close()
        
        @pytest.fixture(scope="function")
        def page(browser):
            context = browser.new_context()
            page = context.new_page()
            yield page
            context.close() # Close context after each test to ensure isolation
        

        This fixture pattern reuses the browser across all tests in the session, drastically cutting down startup time. Each test still gets a fresh page and context for isolation.

      • Selective Navigation: If possible, navigate only when necessary. Use page.go_back() or page.go_forward() if the application supports it, or update the URL directly if the app handles it.

By systematically profiling with traces and applying these targeted fixes, you can turn your Playwright test suite from a slow crawl into a swift execution. The next hurdle is often dealing with parallel execution and ensuring test isolation.

Want structured learning?

Take the full Playwright course →