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
-
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.gotoaction 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.
-
waitUntilOption: Playwright’sgotohas awaitUntiloption. The default is'load', which waits for theloadevent.'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.
-
- Diagnosis: In the trace viewer, observe the duration of the
-
Overly Broad
waitForSelector: Usingpage.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
waitForSelectoris 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 visibleThis ensures you’re waiting for the meaningful content to appear, not just a placeholder.
-
-
Unnecessary Waits for Network Activity: Tests often include
page.wait_for_timeout(milliseconds)orpage.wait_for_selectoron elements that are already present but not yet interactive due to ongoing client-side JavaScript.-
Diagnosis: Trace shows a static wait or a
waitForSelectorthat resolves quickly, but the subsequent action fails or is slow. -
Fix: Use
page.wait_for_load_state()with specific states orpage.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)networkidleis powerful for ensuring all background AJAX calls have completed, preventing race conditions where an element is rendered but not yet populated with data.
-
-
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 awaitForSelector, 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
attachedorvisiblestates is generally sufficient, but sometimes a specific DOM property orsetTimeoutwithinwaitForFunctionis necessary for stubborn animations.
-
-
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, andgetByAltTextwhich 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.
-
-
Excessive
page.reload()orbrowser.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 isolationThis 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()orpage.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.