Do these 3 things before closing this tab:
1Repair Windows errors before they cause bigger problems2Scan for outdated or missing drivers - takes under a minute3Clear out junk files and repair common Windows errorsSome links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.
Most pytest-asyncio/Pyppeteer stalls are event-loop ownership bugs, not slow websites. Keep the test coroutine inside the loop created by pytest-asyncio, create and close the browser on that same loop, and never call asyncio.run() or run_until_complete() from an async test. Then check fixture scope, Chromium startup logs, sandbox permissions, and request interception.
The baseline below fixes the common await browser.newPage() hang while leaving enough diagnostics to separate Python loop problems from Chromium or page-load failures.
Start with the safe pytest-asyncio pattern
Use an async fixture from pytest_asyncio, mark the test as asynchronous, and await every Pyppeteer operation. Teardown belongs in finally so a failed assertion does not leave Chromium running.
The Tool Desk
Outbyte Driver Updater FREEScan for outdated or missing drivers - takes under a minuteDriver Scan →Outbyte PC Repair FREEClear out junk files and repair common Windows errorsFree Scan →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()
Do not put the test body inside asyncio.run() or call loop.run_until_complete(). pytest-asyncio already owns a running loop for the test. asyncio event loops are limited to one per thread, so trying to start another one in that thread can produce “cannot run the event loop while another loop is running,” a deadlock-like wait, or an immediate runtime error.
#1 Best Overall
Match loop ownership to fixture lifetime
pytest-asyncio’s event_loop fixture defaults to function scope. That is safe for a function-scoped browser, because each test gets a browser and loop with the same lifetime. Problems appear when a module- or session-scoped browser is used with a function-scoped loop, or when a custom event_loop fixture overlaps the plugin’s own fixture.
| Browser fixture scope | Typical use | Loop requirement | Risk if mismatched |
|---|---|---|---|
| Function | Maximum isolation; simplest debugging | Function-scoped pytest-asyncio loop | Low |
| Module | Reuse one browser for tests in a module | Use a compatible module loop scope | Browser tasks can outlive the function loop |
| Session | Reduce repeated Chromium launches in a large suite | Use a compatible session loop scope | Closed or replaced loops strand browser tasks |
Choose the smallest scope that meets your performance needs. If you widen the browser fixture, widen the pytest-asyncio loop scope deliberately and verify the exact syntax for your installed pytest-asyncio version. Do not keep a second hand-written loop fixture “just in case”; overlapping loop fixtures are a common source of teardown races.
Function-scoped default
Start here when diagnosing a stall. A fresh browser and loop per test makes it clear whether the failure is deterministic and prevents one test’s pending task from affecting another.
Module- or session-scoped browser
Only reuse a browser after the function-scoped version is stable. The fixture must be asynchronous, and its loop must remain alive until the browser’s shutdown coroutine and child tasks finish. A session browser paired with a function loop can appear to work for the first test and hang on newPage() or teardown later.
Rank #2
Remove nested and cross-loop calls
These patterns are unsafe inside an async pytest test:
# Wrong: pytest is already running an event loop
@pytest.mark.asyncio
async def test_wrong():
result = asyncio.run(fetch_page())
# Wrong: also tries to drive a loop that pytest owns
@pytest.mark.asyncio
async def test_also_wrong():
loop = asyncio.get_event_loop()
result = loop.run_until_complete(fetch_page())
Make the helper asynchronous and await it instead:
async def fetch_page(browser):
page = await browser.newPage()
await page.goto('https://example.com', waitUntil='networkidle2')
return await page.title()
@pytest.mark.asyncio
async def test_title(browser):
title = await fetch_page(browser)
assert 'Example' in title
The same rule applies to browser plugins or synchronous wrappers. Do not let one integration start its own loop while Pyppeteer is being awaited by pytest-asyncio. Pick one async integration style for the test process.
When newPage() never returns
A stalled newPage() is not proof that the target URL is slow: page creation happens before navigation. Work through startup diagnostics in this order.
- Enable Pyppeteer logging. Set
pyppeteer.DEBUG = Truebefore launching, or pass a launcher log level oflogging.DEBUG. Preserve Chromium’s stderr in CI output. The last startup message usually distinguishes a loop wait from a process launch failure. - Verify the executable. Pyppeteer’s API does not guarantee arbitrary Chrome versions. Try the bundled Chromium first, then isolate an installation problem with an explicit
executablePathpointing to a known working Chrome or Chromium binary. - Inspect the host sandbox. Restricted Linux containers can prevent Chromium from starting or communicating. A reported Pyppeteer
newPage()hang used system Chrome or--no-sandboxas environment-specific workarounds. Disabling the sandbox weakens isolation, so do not make--no-sandboxyour default fix; first correct container permissions and use it only when the deployment is controlled and the security impact is accepted. - Check process limits and runner timeouts. Memory pressure, permissions, an external process killer, or a test timeout can terminate Chromium while Python waits for a response. The available issue reports do not establish a universal memory threshold; use process and Chromium logs instead of guessing a number.
Request interception can create an intentional-looking hang
If your code enables interception with await page.setRequestInterception(True), every request must be continued, fulfilled, or aborted. One forgotten request leaves navigation waiting indefinitely.
async def allow_requests(page):
await page.setRequestInterception(True)
async def handle(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
page.on('request', handle)
Install the handler before navigation and make sure every branch reaches one of those three outcomes. If you do not need interception, remove it while diagnosing; a plain navigation eliminates an entire class of indefinite waits.
Close the browser before pytest closes the loop
Pyppeteer owns background tasks that communicate with the Chromium process. Closing the event loop first can leave those tasks pending and make the test process hang after all assertions pass. Keep await browser.close() in the fixture’s finally block, and avoid manually closing the loop in a test or fixture. If a test fails during setup, ensure the fixture still reaches its teardown path.
A browser that remains alive after pytest reports success usually indicates missing teardown, a fixture that was never entered, or a task waiting on an intercepted request. It is not fixed by adding arbitrary sleeps; identify and await the shutdown operation.
Use pytest configuration consistently
Every coroutine test must be recognized by pytest-asyncio. The explicit marker is unambiguous:
@pytest.mark.asyncio
async def test_page(browser):
...
If your project enables auto mode, configure that once for the project and do not mix competing async plugins or custom loop fixtures without checking their compatibility. A test collected as a normal synchronous function will not receive the loop and fixture behavior you expect.
A repeatable diagnostic workflow
- Reduce the case to one async test, one browser fixture, and one page operation.
- Use function scope for both browser and loop.
- Remove
asyncio.run(),run_until_complete(), synchronous browser wrappers, and request interception. - Turn on Pyppeteer and Chromium debug logging.
- Confirm the executable path and Chromium version.
- Run the same test in the target container or CI image, then inspect sandbox permissions and process-kill messages.
- Reintroduce interception, wider fixture scope, custom headers, and other options one at a time.
This order tells you whether the stall is caused by Python scheduling, browser startup, or page logic instead of changing several variables at once.
Common symptoms and precise fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| “Cannot run the event loop while another loop is running” | Nested asyncio.run() or run_until_complete(), or a second browser integration |
Await the coroutine on pytest-asyncio’s loop and use one async integration style |
newPage() waits forever |
Chromium startup, executable mismatch, sandbox restriction, or a loop/fixture mismatch | Enable debug logs, verify the executable, inspect sandbox permissions, and align scopes |
| Navigation waits forever after interception is enabled | A request is never continued, fulfilled, or aborted | Handle every request branch or disable interception |
| Tests pass but Chromium remains | No awaited close, teardown skipped, or pending intercepted request | Close in fixture finally and inspect pending-task and request logs |
| Only module/session fixtures fail | Browser lifetime exceeds the loop lifetime | Use a compatible broader loop scope or return to function scope |
Should you use a pytest-specific Pyppeteer fixture plugin?
A plugin such as pytest-pyppeteer can provide fixture integration instead of maintaining your own browser fixture. Treat it as another dependency: check that its maintenance status and versions match your pytest, pytest-asyncio, and Pyppeteer versions. It does not remove the underlying requirements for single-loop ownership, compatible fixture scope, Chromium compatibility, sandbox permissions, or complete request interception.
Outdated Drivers Are Slowing You Down
One free scan finds every outdated or missing driver and matches the right update for your exact hardware.Free scan · exact hardware matchWindows Errors? Fix Them Before They Spread
Repair common Windows errors and clear accumulated junk for a smoother, more stable PC - no reinstall needed.Free scan · no reinstallPerformance and reliability choices
Reuse versus isolation
Launching Chromium for every function is slower but isolates state and makes cleanup predictable. Module or session reuse can reduce launch overhead, yet it requires matching loop scope and careful page cleanup. Stabilize correctness first, then widen scope based on measured suite time.
Best Value
Navigation waits
Use a navigation condition that matches the page under test. A page that keeps long-lived connections can make a network-idle condition wait much longer than expected. If the stall begins only at goto(), compare a minimal navigation with and without interception and inspect network logs.
CI reproducibility
Pin the browser executable used by CI or log its path and version on every run. Keep the same container permissions between local and CI environments. Save Chromium stderr when a timeout occurs; without it, a sandbox or process-kill failure can look like an asyncio deadlock.
Or skip the browser setup
If your goal is simply a clean image or PDF of a URL rather than interactive Pyppeteer control, ScreenshotNeo provides a single HTTP request. Its capture service accepts consent banners before taking the shot and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each step can be disabled. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and the response identifies the page verdict and billing status in headers. An MCP server exposes take_screenshot, get_page_info, and capture_pdf to Claude, Cursor, and other MCP clients.
See the complete parameter list in the ScreenshotNeo API documentation. The following calls use the documented endpoint and a replaceable API key:
cURL
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(`HTTP ${res.status}`);
const data = Buffer.from(await res.arrayBuffer());
require('fs').writeFileSync('shot.webp', data);
ScreenshotNeo includes full-page capture with lazy images loaded, CSS-selector element capture, dark mode, device presets, arbitrary viewports, retina scale, PDF paper and page-range controls, custom CSS and JavaScript, clicks before capture, selector waits, delays, network-idle waits, request and resource blocking, custom headers, cookies, user agents and authorization, timezone and geolocation, transparent backgrounds, resizing, chosen cache TTLs, signed image links, asynchronous jobs with signed webhooks, bulk capture for up to 100 URLs per call, a usage API, and an OpenAPI specification. Parameter names used by other screenshot APIs also work, which can simplify migration.
The Free plan includes 1,000 screenshots each month with no card. Paid plans start at $5 for 3,000 screenshots; Growth is $15 for 15,000, Pro $39 for 60,000, Scale $99 for 250,000, and Business $249 for 1,000,000. Yearly billing gives two months free, and every feature is available on every plan. Create a free ScreenshotNeo account to get the 1,000 monthly screenshots without adding a card.
Frequently Asked Questions
Is there a universal memory limit that explains every Chromium stall?
No. The available Pyppeteer issue material does not establish a universal resource threshold. Check Chromium stderr, container events, and the test runner’s termination messages for the failing environment instead of applying a guessed limit.
Quick wins for a faster PC:
Clear out junk files and repair common Windows errorsFree Scan →Scan for outdated or missing drivers - takes under a minuteDriver Scan →Repair Windows errors before they cause bigger problemsFix Now →Do the dates of Pyppeteer and pytest-asyncio issue discussions show how common these hangs are?
No. The cited hang report and maintainer discussion have publication dates, but they are not success-rate or prevalence statistics.
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.

