pytest-asyncio usually stalls with Pyppeteer because two pieces of code disagree about event-loop ownership. The common triggers are calling asyncio.run() or run_until_complete() inside an async test, creating the browser on one loop and using it on another, tearing down a loop while Chromium tasks are still active, or enabling request interception without completing every request.
Keep the test, fixtures, Pyppeteer browser, pages, and teardown on one pytest-managed loop. Start with the fixture below, then use the diagnostic branches for launch, newPage(), navigation, container, and cleanup failures.
Use one pytest-managed event loop
pytest-asyncio supplies an asyncio loop for async tests and normally tears it down after the test. Asyncio loops are limited to one per thread, so nesting another loop inside that test is not a supported design. An async test must therefore await Pyppeteer directly instead of starting a second loop.
Remove nested loop calls
- Do not call
asyncio.run(coroutine())from a test already running under pytest-asyncio. - Do not call
loop.run_until_complete(coroutine())from that test or its async fixtures. - Do not create a browser in a synchronous fixture and then await it from an async test.
- Do not mix a synchronous browser wrapper or plugin with an async Pyppeteer integration.
Those patterns commonly produce “cannot run the event loop while another loop is running,” a coroutine that never completes, or a browser object whose transport belongs to a loop that has already been closed.
#1 Best Overall
Confirm that pytest treats the test as async
Mark coroutine tests with @pytest.mark.asyncio, or use pytest-asyncio’s auto mode if that is how your project is configured. An unmarked coroutine can be skipped, mis-collected, or run through the wrong fixture path rather than failing at the Pyppeteer call itself.
import pytest
@pytest.mark.asyncio
async def test_smoke():
assert True
A safe baseline fixture and test
Use pytest_asyncio.fixture for an async fixture. The browser is launched, yielded, and closed on the same running loop as the test.
import pytest
import pytest_asyncio
from pyppeteer import launch
@pytest_asyncio.fixture
async def browser():
browser = await launch()
try:
yield browser
finally:
await browser.close()
@pytest.mark.asyncio
async def test_page(browser):
page = await browser.newPage()
await page.goto(
"https://example.com",
waitUntil="networkidle2",
)
assert "Example" in await page.title()
Why this fixture avoids the usual stall
- The fixture does not create a private event loop.
launch(),newPage(), navigation, andclose()are all awaited.- The
finallyblock runs when the assertion fails as well as when it passes. - Chromium is closed before pytest finishes tearing down the loop.
If a test needs several pages, create them from this browser and close each page when it is no longer needed. Do not close the shared browser from an individual test unless that test owns the fixture.
Align fixture lifetime with loop lifetime
The documented event_loop fixture defaults to function scope. A function-scoped browser is therefore the least surprising starting point. A module- or session-scoped browser can reduce repeated launches, but its async fixture lifetime must match a loop that remains alive for that entire scope.
Quick wins for a faster PC:
Fix the driver behind crashes, sound loss and screen glitchesFind Drivers →Repair Windows errors before they cause bigger problemsFix Now →Function scope: the simplest choice
Keep the baseline fixture function-scoped when tests are independent or when reliability matters more than launch overhead. Each test receives a fresh browser and has an unambiguous teardown point.
Rank #2
Module or session scope: change both sides deliberately
If you widen the browser fixture to scope="module" or scope="session", configure pytest-asyncio so the tests and fixture use a compatible loop scope. Do not leave a long-lived browser attached to a function loop. That mismatch can produce “attached to a different loop” errors, hangs during teardown, or a browser that appears alive while its transport is unusable.
Avoid replacing pytest-asyncio’s loop fixture with overlapping custom event_loop fixtures. Two fixtures trying to own the same loop lifecycle make it unclear which one closes the loop first.
When launch() or newPage() never returns
Turn on Chromium and Pyppeteer logging first
Enable debug output before changing flags. This distinguishes a loop deadlock from a failed Chromium process, an executable problem, or a permissions error.
Do these 3 things before closing this tab:
1Clear out junk files and repair common Windows errors2Scan for outdated or missing drivers - takes under a minute3Repair Windows errors before they cause bigger problemsimport logging
import pyppeteer
pyppeteer.DEBUG = True
# Or pass this when launching:
# browser = await pyppeteer.launch(logLevel=logging.DEBUG)
Capture pytest’s output and Chromium’s stderr. Look for an executable-not-found message, an immediate process exit, denied permissions, a failed DevTools connection, or a test-runner timeout. Changing several launch arguments at once can hide the original cause.
Verify the executable and version
Pyppeteer’s API does not guarantee compatibility with arbitrary Chrome versions. A broken bundled download or an incompatible system browser can look like an async stall. Test with an explicit executable path to isolate that variable:
import pytest_asyncio
from pyppeteer import launch
@pytest_asyncio.fixture
async def browser():
browser = await launch(
executablePath="/usr/bin/google-chrome",
# Keep other launch arguments minimal while diagnosing.
)
try:
yield browser
finally:
await browser.close()
Use the actual path on the host; the example path is not universal. If the explicit browser works, investigate the bundled Chromium download and the version selected by Pyppeteer rather than adding more test-loop code.
Check Linux sandbox permissions
Containers and restricted Linux hosts often prevent Chromium from starting normally. A reported newPage() hang was associated with the environment and discussed system Chrome or --no-sandbox as possible workarounds. Disabling the sandbox weakens browser isolation, so do not make --no-sandbox a default fix. First determine whether the container can provide the required user, namespaces, permissions, and shared libraries; use the flag only when the execution environment is controlled and the security trade-off is accepted.
Recommended Free Tools
Check for external process termination
A memory limit, permission policy, CI timeout, or supervisor may kill Chromium while Python is still awaiting a response. The cited material does not establish a universal memory threshold. Compare the timestamp in Chromium stderr with the container, CI, or operating-system logs instead of guessing a numeric limit.
Navigation stalls caused by request interception
If page.setRequestInterception(True) is enabled, every request must be continued, fulfilled, or aborted. One request left unresolved can leave navigation waiting forever; the page can look like a pytest hang even though the event loop is healthy.
Complete every intercepted request
import asyncio
import pytest
import pytest_asyncio
from pyppeteer import launch
@pytest_asyncio.fixture
async def browser():
browser = await launch()
try:
yield browser
finally:
await browser.close()
async def handle_request(request):
try:
if request.resourceType == "image":
await request.abort()
else:
await request.continue_()
except Exception:
# The page may close while a request is being handled.
pass
@pytest.mark.asyncio
async def test_without_interception_stall(browser):
page = await browser.newPage()
await page.setRequestInterception(True)
page.on("request", lambda request: asyncio.ensure_future(handle_request(request)))
await page.goto("https://example.com", waitUntil="networkidle2")
The handler must also deal with requests that arrive while the page is closing. Log exceptions from the handler during diagnosis; silently swallowing them in production can conceal a request that was never completed.
Keep browser integrations consistently asynchronous
If another plugin has already started a loop, or if a synchronous browser API is mixed with Pyppeteer, pytest-asyncio can report that another loop is running. Choose one integration style for the test process. For Pyppeteer, that means async fixtures, async tests, and awaited browser calls throughout. If a project needs a pytest-specific wrapper such as pytest-pyppeteer, check its maintenance status and compatibility with the installed pytest-asyncio and Pyppeteer versions before adopting it; it should replace, not sit beside, a second custom loop owner.
Diagnose by the first operation that stops
| Where it stops | Likely cause | What to check next |
|---|---|---|
| Test collection or before the first await | The coroutine is not marked or auto mode is not enabled; a synchronous fixture is being selected. | Confirm the marker, pytest-asyncio mode, and fixture decorator. |
await launch() |
Chromium cannot start, the executable is incompatible, or the process is killed. | Enable debug logs, inspect stderr, verify executable/version, and inspect host or CI logs. |
await browser.newPage() |
Loop mismatch, DevTools connection failure, or sandbox/permission issue. | Ensure browser and test share one loop; test an explicit executable; inspect sandbox permissions. |
await page.goto() |
Unfinished request interception, a page that never reaches the selected wait condition, or a network failure. | Temporarily disable interception, verify every request is completed, and capture page/browser logs. |
| Fixture teardown | The loop is closing before browser tasks finish, or another fixture already closed the browser. | Close in finally, align fixture and loop scopes, and remove overlapping loop fixtures. |
| Pytest process remains after tests | Chromium was not closed or a background task still owns the transport. | Await browser.close() and ensure no custom task outlives the fixture. |
Reliability and performance practices
Reuse only when the scope is correct
Launching Chromium is heavier than creating a page. Reusing one browser at module or session scope can reduce launch work, but only after the loop-scope arrangement is correct. A fast fixture that occasionally tears down the wrong loop is not a reliability improvement.
Make waits explicit
Use a specific waitUntil condition or wait for a selector that proves the page is ready. A page with long-lived connections may never satisfy a network-idle condition. If the application intentionally keeps connections open, wait for the rendered element your assertion needs rather than waiting for all network activity to stop.
Close what you create
Close pages that are not reused, then close the browser in fixture teardown. Avoid detached tasks that continue issuing Pyppeteer calls after the fixture has yielded control back to pytest.
Diagnose before optimizing
Do not add arbitrary sleeps, disable the sandbox, or increase timeouts until logs identify the failing boundary. Those changes can make a CI run appear less flaky while leaving a loop mismatch, dead request, or crashed browser unresolved.
The Tool Desk
Outbyte PC Repair FREEClear out junk files and repair common Windows errorsFree Scan →Outbyte Driver Updater FREEScan for outdated or missing drivers - takes under a minuteDriver Scan →Best Value
Or skip the browser setup:
If your goal is simply to capture a website image or PDF rather than run browser code inside pytest, ScreenshotNeo provides a single HTTP request. It accepts consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each cleanup step can be disabled. Only clean shots are billed: bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers. Its MCP server gives Claude, Cursor, and other MCP clients take_screenshot, get_page_info, and capture_pdf tools.
The API supports full-page and selector captures, dark mode, device presets, custom viewport and retina scale, PDF paper and margin controls, custom CSS and JavaScript, clicks, selector waits, delays, network-idle waits, request and resource blocking, headers, cookies, user agents, authorization, timezone, geolocation, transparent backgrounds, resizing, chosen cache TTLs, signed image links, asynchronous webhooks, bulk capture of up to 100 URLs per call, usage reporting, and an OpenAPI specification. Existing parameter names used by other screenshot APIs also work.
cURL
See the ScreenshotNeo documentation for authentication and options.
curl -G "https://api.screenshotneo.com/v1/shot"
-d access_key=YOUR_API_KEY
--data-urlencode url=https://stripe.com
-o shot.webp
Python
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
Node.js
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://stripe.com'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));
There is no card requirement for the free allowance of 1,000 screenshots per month. Paid plans start at $5 for 3,000 shots; yearly billing provides two months free, and every feature is included on every plan. Create a free ScreenshotNeo account to try the request.
Free tools Windows power users keep installed
One-click scans. No signup required.
Frequently Asked Questions
Does browser.newPage() load a website by itself?
No. It creates a new page target; navigation happens only when you call a method such as page.goto(). A stall at newPage() points to the browser connection, loop ownership, or host environment rather than the target site’s HTML.
Should I use networkidle2 for every navigation?
No. Pages with persistent connections may never become idle. Wait for a selector or another application-specific readiness condition when the site intentionally keeps network activity open.
Is --no-sandbox a general Pyppeteer fix?
No. It is an environment-specific workaround with security consequences. Diagnose the container’s permissions and sandbox requirements first, and use it only in a controlled environment where the risk is understood.
The Bottom Line
Keep one pytest-managed loop, await every Pyppeteer operation on it, align fixture and loop scopes, close Chromium in finally, and complete every intercepted request. If the stall remains, debug Chromium’s executable, version, sandbox, and process logs before changing timeouts or launch flags.
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

