Sync vs Async Playwright — Which to Choose

8 min read

Playwright Python is the only major test framework that ships two complete APIs — one synchronous, one asynchronous — that do exactly the same thing. The TypeScript version doesn't give you a choice; everything is async/await. The Python version does, and the choice matters for how every test in your suite reads. This lesson explains both APIs, when each fits, and why pytest-playwright (and therefore this whole course) defaults to sync.

Two APIs, one engine

Both APIs talk to the same browser binary over the same protocol. The difference is purely how Python code expresses "wait for this to finish."

  • Sync API — every action blocks until the browser confirms it's done. Code reads top-to-bottom, no await, no event loop to manage.
  • Async API — every action returns an awaitable. You write await page.goto(...) exactly like the TypeScript version, and an asyncio event loop drives concurrency.

Pick one per project. Mixing them in the same test triggers RuntimeError: This event loop is already running because the sync API spawns its own event loop under the hood.

The sync API reads like ordinary Python. No async def, no asyncio.run, no await:

from playwright.sync_api import sync_playwright
 
with sync_playwright() as p:
    browser = p.chromium.launch(headless=False)
    page = browser.new_page()
    page.goto("https://myapp.com")
    page.get_by_label("Email").fill("alice@test.com")
    page.get_by_role("button", name="Submit").click()
    assert "dashboard" in page.url
    browser.close()

Read it like a script. Each line waits for the browser before the next runs — page.goto doesn't return until navigation completes; fill doesn't return until the text is in the input. Auto-waiting still happens (Playwright waits for elements to be actionable), it just doesn't surface as await.

This is the API the Playwright with TypeScript course models, minus the async/await ceremony. If you wrote await page.goto("/dashboard") in TS, in Python sync it's page.goto("/dashboard").

Sync API in pytest-playwright — even cleaner

The standalone sync example above shows the raw API. In a real test suite, you'll never write with sync_playwright() as p: yourself — pytest-playwright does it for you and gives you the page fixture:

from playwright.sync_api import Page, expect
 
def test_login(page: Page):
    page.goto("/login")
    page.get_by_label("Email").fill("alice@test.com")
    page.get_by_label("Password").fill("password123")
    page.get_by_role("button", name="Sign in").click()
    expect(page).to_have_url("/dashboard")

The fixture handles Playwright, Browser, BrowserContext, and Page lifecycle automatically — your test only sees the Page. Every Playwright method is sync. This is the form you'll use for 99% of the course.

Async API — when concurrency matters

The async API exists for cases where you genuinely want concurrent operations within a single test or you're integrating with an async framework like FastAPI or aiohttp:

import asyncio
from playwright.async_api import async_playwright
 
async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page()
        await page.goto("https://myapp.com")
        await page.get_by_label("Email").fill("alice@test.com")
        await page.get_by_role("button", name="Submit").click()
        assert "dashboard" in page.url
        await browser.close()
 
asyncio.run(main())

Same test, different shape. Every action has await, the entrypoint is async def, and asyncio.run provides the event loop.

The async API is the right choice when you need things like:

  • Parallel page loads inside one testawait asyncio.gather(page1.goto(...), page2.goto(...)) actually runs concurrently. The sync API would block on the first goto before starting the second.
  • Integrating with an async backend test harness — a FastAPI or aiohttp test server already uses asyncio; the async Playwright API plugs into the same event loop.
  • Async data setup — if your test data layer (e.g., asyncpg for Postgres, httpx.AsyncClient for APIs) is already async, matching the test API avoids two event loops fighting.

If none of those apply — and for almost every QA automation suite, none do — the sync API wins on simplicity.

pytest-playwright is sync — by default

pytest-playwright's page, browser, context, and playwright fixtures are all built on the sync API. Test functions are plain def, not async def:

def test_login(page: Page):
    page.goto("/login")          # no await
    page.get_by_label("Email").fill("alice@test.com")

To use the async API with pytest you'd reach for pytest-playwright-async (a separate, less-maintained plugin) plus pytest-asyncio. The wiring is fiddlier and the gain is real only if your test data setup is genuinely async-bound. For pure browser automation, stick with sync.

Sync vs async — the practical comparison

Two APIs, same engine — when to reach for which

Sync API (recommended)

  • Pattern: page.goto('/x') — no await, no asyncio

  • Reads top-to-bottom like a normal script — easy to teach, easy to read

  • pytest-playwright defaults to this — no extra plugins needed

  • Right tool for: 99% of QA test automation

Async API (advanced)

  • Pattern: await page.goto('/x') — every action awaited

  • Needs async def, asyncio.run, and a sound mental model of the event loop

  • Mirrors the TypeScript Playwright runner exactly

  • Right tool for: real concurrency, async data layers, FastAPI test suites

A side-by-side comparison — the same login test, both ways

Sync — what you'll write in this course:

from playwright.sync_api import Page, expect
 
def test_login_sync(page: Page):
    page.goto("/login")
    page.get_by_label("Email").fill("alice@test.com")
    page.get_by_label("Password").fill("password123")
    page.get_by_role("button", name="Sign in").click()
    expect(page).to_have_url("/dashboard")

Async — what you'd write if your test suite were async-first:

import pytest
from playwright.async_api import Page, expect
 
@pytest.mark.asyncio
async def test_login_async(page: Page):
    await page.goto("/login")
    await page.get_by_label("Email").fill("alice@test.com")
    await page.get_by_label("Password").fill("password123")
    await page.get_by_role("button", name="Sign in").click()
    await expect(page).to_have_url("/dashboard")

Six identical actions. The sync version has zero awaits, no decorator, and reads like a recipe. The async version has six awaits, a @pytest.mark.asyncio decorator, and looks structurally identical to a TypeScript Playwright test. Same engine, same browser commands, same accessibility-first locators.

Coming from Playwright TypeScript?

If you're cross-trained on the TypeScript course, the mapping is direct:

  • TS await page.goto('/x') → Python sync page.goto("/x")
  • TS await expect(locator).toBeVisible() → Python sync expect(locator).to_be_visible()
  • TS test('name', async ({ page }) => { ... }) → Python sync def test_name(page: Page): ...

The async Python API is essentially a 1:1 port of the TS API — same awaits, same shape — but you'd only choose it if you want the async ergonomics. In Python, the language gives you a simpler option, and most QA teams take it.

Performance — they're the same

A common misconception: "async must be faster." For a single sequential test, the sync and async APIs are equally fast. They speak the same wire protocol to the same browser; the wait-for-action time is identical. Async only buys speed when you actually parallelise — asyncio.gather of two independent operations finishes when the slower one finishes, not when their sum does. For a typical "fill form, click submit, assert URL" flow, there's nothing to parallelise inside the test, and sync wins on readability.

⚠️ Common mistakes

  • Mixing sync_playwright and async_playwright imports in the same project. Pick one. Importing both leaves you one careless await away from RuntimeError: There is no current event loop in thread. The fix is removing the unused import — and only keeping the API that matches your fixtures.
  • Reaching for async because the TS Playwright course used await everywhere. The TS course used await because TypeScript's Playwright API is async-only. Python gives you a sync alternative that the JS world doesn't have. Don't carry the async habit over for ceremony's sake — write sync unless you have a concrete reason.
  • Using await inside a def test_(...) function in pytest-playwright. The fixture is sync, the test is sync, await outside an async def raises SyntaxError: 'await' outside async function. Pytest-playwright's page fixture only exists in sync form — change the test signature, not the syntax.

🎯 Practice task

Compare both APIs hands-on. 20-25 minutes.

  1. Continue in your playwright-python-tests/ project. Make sure your virtualenv is active and pytest-playwright is installed.

  2. Create tests/test_sync_login.py:

    from playwright.sync_api import Page, expect
     
    def test_saucedemo_login_sync(page: Page):
        page.goto("/")
        page.get_by_placeholder("Username").fill("standard_user")
        page.get_by_placeholder("Password").fill("secret_sauce")
        page.get_by_role("button", name="Login").click()
        expect(page).to_have_url("/inventory.html")

    Run with pytest tests/test_sync_login.py -v. The test passes — six actions, no await anywhere.

  3. Now create tests/test_async_login.py — the same test, async style. This one runs outside pytest because pytest-playwright is sync-only:

    import asyncio
    from playwright.async_api import async_playwright, expect
     
    async def main():
        async with async_playwright() as p:
            browser = await p.chromium.launch(headless=False)
            page = await browser.new_page()
            await page.goto("https://www.saucedemo.com/")
            await page.get_by_placeholder("Username").fill("standard_user")
            await page.get_by_placeholder("Password").fill("secret_sauce")
            await page.get_by_role("button", name="Login").click()
            await expect(page).to_have_url("https://www.saucedemo.com/inventory.html")
            await browser.close()
     
    asyncio.run(main())

    Run it directly with python tests/test_async_login.py. Same test, six awaits, same green outcome.

  4. Try to break sync with an await. In test_sync_login.py change one line to await page.goto("/"). Re-run pytest. You'll see SyntaxError: 'await' outside async function — Python won't even compile the file. Remove the await; the test runs again.

  5. Stretch: rewrite the async test to log in twice in parallel — open two pages, navigate both at once with asyncio.gather. Confirm both reach /inventory.html. This is the kind of speedup the async API genuinely offers; the sync API would run the two logins serially. For your actual test suite, you almost never need this, but it's worth doing once to see the model.

The rest of this course uses the sync API throughout. The next lesson is your first proper pytest-playwright test — fixtures, type hints, expect, and the project conventions you'll lean on for every chapter that follows.

// tip to track lessons you complete and pick up where you left off across devices.