Playwright for Python offers an async API next to the sync API you have used so far. To read and write async code confidently you need three ideas: coroutines, await and the event loop. In this lesson you will learn them with small timing experiments, avoid the classic mistakes, and then use async Playwright to load several pages at the same time.
๐ What You’ll Learn: what concurrency means, async def and coroutines, await, asyncio.run, running tasks together with asyncio.gather, the “coroutine was never awaited” mistake, async with and async for, using async_playwright to open pages concurrently, and when async is (and is not) worth it for testing.
โณ Why Async Exists
Most of the time a browser test is waiting: for a page to load, a response to arrive, an element to appear. In normal (synchronous) code, while one operation waits, nothing else happens. Asynchronous code lets a single Python thread start an operation, and while it waits, switch to other work. This is called concurrency.
An everyday comparison: a synchronous cook boils the water, waits, then chops vegetables. An asynchronous cook starts the water, chops the vegetables while it heats, and comes back when it boils. One cook, less total time.
๐งฉ async def, await and asyncio.run
Three building blocks:
async defdefines a coroutine function. Calling it does not run it yet; it returns a coroutine object.awaitruns a coroutine (or another awaitable) and pauses the current coroutine until the result is ready, letting other work run meanwhile. You can only useawaitinside anasync def.asyncio.run(main())creates the event loop (the scheduler that switches between waiting tasks), runsmain()until it finishes, and closes the loop.
โฑ๏ธ Experiment: Sequential vs Concurrent
asyncio.sleep(1) simulates waiting one second for something, such as a page load. Save this as sequential_vs_gather.py:
๐ sequential_vs_gather.py
import asyncio
import time
async def load_page(name: str, seconds: float) -> str:
await asyncio.sleep(seconds) # pretend we are waiting for the network
return f"{name} loaded"
async def one_after_another() -> None:
start = time.perf_counter()
a = await load_page("home", 1)
b = await load_page("login", 1)
c = await load_page("cart", 1)
print("sequential:", [a, b, c], f"{time.perf_counter() - start:.1f}s")
async def all_together() -> None:
start = time.perf_counter()
results = await asyncio.gather(
load_page("home", 1),
load_page("login", 1),
load_page("cart", 1),
)
print("gather: ", results, f"{time.perf_counter() - start:.1f}s")
asyncio.run(one_after_another())
asyncio.run(all_together())
๐ฅ๏ธ Output
sequential: ['home loaded', 'login loaded', 'cart loaded'] 3.0s
gather: ['home loaded', 'login loaded', 'cart loaded'] 1.0s
What happened:
- In
one_after_another, eachawaitwaits for its one-second sleep to finish before the next one starts: 3 seconds in total. asyncio.gather(...)starts all three coroutines at once and waits until all are done. Because the waits overlap, the total is about 1 second.gatherreturns the results in the order you passed the coroutines, not in the order they finished.
๐ Note: Concurrency helps when code is waiting (network, disk, browsers). It does not make CPU-heavy calculations faster, because one thread still does one calculation at a time.
๐ The Classic Mistake: Forgetting await
Calling a coroutine function without await does not run it. Save this as forgot_await.py:
๐ forgot_await.py
import asyncio
async def get_title() -> str:
await asyncio.sleep(0.1)
return "Swag Labs"
async def main() -> None:
wrong = get_title() # missing await: nothing runs
print("Without await:", type(wrong).__name__)
right = await get_title()
print("With await: ", right)
asyncio.run(main())
๐ฅ๏ธ Output
Without await: coroutine
With await: Swag Labs
Python also prints a warning on the error stream when the forgotten coroutine is cleaned up:
๐ฅ๏ธ Output
RuntimeWarning: coroutine 'get_title' was never awaited
In async Playwright the same mistake looks like title = page.title() printing <coroutine object Page.title at 0x...> instead of the title. Whenever you see “coroutine” where you expected a value, look for a missing await.
๐ญ Async Playwright: Load Three Pages Concurrently
The async API has the same methods as the sync API; every call is awaited, and with becomes async with. This script opens three practice sites at the same time in one browser (async_titles.py):
๐ async_titles.py
import asyncio
import time
from playwright.async_api import Browser, async_playwright
URLS = [
"https://sqatools.in/automation-practice-page/",
"https://www.saucedemo.com/",
"https://sqatools.in/login-page/",
]
async def get_title(browser: Browser, url: str) -> str:
context = await browser.new_context() # one isolated context per page
page = await context.new_page()
await page.goto(url)
title = await page.title()
await context.close()
return title
async def main() -> None:
async with async_playwright() as p:
browser = await p.chromium.launch()
start = time.perf_counter()
titles = await asyncio.gather(*(get_title(browser, url) for url in URLS))
elapsed = time.perf_counter() - start
for url, title in zip(URLS, titles):
print(f"{title:40} <- {url}")
print(f"{len(titles)} pages loaded concurrently")
print("Finished in under 10 seconds:", elapsed < 10)
await browser.close()
asyncio.run(main())
๐ฅ๏ธ Output
Automation Practice Page - SQA Tools <- https://sqatools.in/automation-practice-page/
Swag Labs <- https://www.saucedemo.com/
Login Page - SQA Tools <- https://sqatools.in/login-page/
3 pages loaded concurrently
Finished in under 10 seconds: True
Line by line:
from playwright.async_api import ...selects the async API.Browseris imported for the type hint.get_title()is a coroutine: it creates its own context and page, loads the URL, reads the title, and closes the context. Each call is independent, just like separate tests.async with async_playwright() as p:starts and stops Playwright.get_title(browser, url) for url in URLSis a generator expression that creates one coroutine per URL; the*unpacks them intoasyncio.gather(...), which runs them concurrently.zip(URLS, titles)pairs each URL with its title, in the original order.{title:40}pads the title to 40 characters so the arrows line up.
๐ก Tip: Exact timings depend on your network, so this script prints a yes/no check instead of a number. Try changing it to load the URLs one by one with a for loop and compare the times yourself.
๐ async with and async for
Two more async keywords you will meet:
async withuses an *asynchronous* context manager, one whose setup and cleanup are awaited (async with async_playwright() as p:).async forloops over an *asynchronous* iterator, which produces items that must be awaited. Playwright’s main API rarely needs it, but you will see it in async libraries.
๐ซ You Cannot Start an Event Loop Inside Another
asyncio.run() creates a new event loop, and Python allows only one running loop per thread. Calling asyncio.run() from code that is already async fails (nested_run.py):
๐ nested_run.py
import asyncio
async def inner() -> str:
return "done"
async def outer() -> None:
coroutine = inner()
try:
asyncio.run(coroutine) # not allowed inside a running loop
except RuntimeError as error:
print("RuntimeError:", error)
coroutine.close() # tidy up the unused coroutine
print("Correct way:", await inner())
asyncio.run(outer())
๐ฅ๏ธ Output
RuntimeError: asyncio.run() cannot be called from a running event loop
Correct way: done
Inside async code, always await coroutines instead of calling asyncio.run() again. This is also why the sync Playwright API refuses to run inside an event loop (you saw that error in Lesson 1).
๐งช Should Your Tests Be Async?
For most UI test suites, no. The sync API with pytest-playwright is simpler, gives each test its own page automatically, and pytest-xdist (Module 12) already runs tests in parallel processes. Async is worth it when:
- your application or tooling is already built on asyncio;
- one test or script must drive many pages or API calls at the same time (for example, simulating several users);
- you build tools such as crawlers or monitors rather than test suites.
When you do want async tests, the official pytest-playwright-asyncio plugin provides async fixtures. Module 13 covers it in detail.
โ Frequently Asked Questions
๐ค Is async the same as multithreading?
No. Async runs on one thread and switches between tasks only at await points. Threads can switch at any moment and need more care with shared data. For network-bound work like browser automation, async is usually simpler and lighter.
๐ค Can I call a sync function inside an async function?
Yes, but if it blocks (for example time.sleep(5)), it freezes the whole event loop and every other task waits too. Use await asyncio.sleep(5) instead inside async code.
๐ค Does gather stop if one coroutine fails?
By default the first exception is raised from await asyncio.gather(...), but the other coroutines are not cancelled and keep running in the background. Pass return_exceptions=True to get exceptions back as results instead.
๐ค Do I need the async API to run tests in parallel?
No. Parallel test runs come from pytest-xdist with the sync API, which is the most common setup for Playwright Python suites.
๐ Summary
async defcreates coroutines;awaitruns them;asyncio.run()starts the event loop.asyncio.gather()runs waiting tasks concurrently and returns results in order.- A missing
awaitgives you a coroutine object and a “never awaited” warning. - Async Playwright uses the same methods with
awaitandasync with; use it when you truly need concurrency.
That completes Module 2. Test yourself with the Module 2 mock interview, then continue to Module 3, where you master pytest itself.
๐ฌ Watch the Playwright tutorial series on YouTube!
Subscribe to SqaToolsByDY • Practice on the SQA Tools Automation Practice Page