Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Some 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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
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.

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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.

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  1. Enable Pyppeteer logging. Set pyppeteer.DEBUG = True before launching, or pass a launcher log level of logging.DEBUG. Preserve Chromium’s stderr in CI output. The last startup message usually distinguishes a loop wait from a process launch failure.
  2. 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 executablePath pointing to a known working Chrome or Chromium binary.
  3. Inspect the host sandbox. Restricted Linux containers can prevent Chromium from starting or communicating. A reported Pyppeteer newPage() hang used system Chrome or --no-sandbox as environment-specific workarounds. Disabling the sandbox weakens isolation, so do not make --no-sandbox your default fix; first correct container permissions and use it only when the deployment is controlled and the security impact is accepted.
  4. 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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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

  1. Reduce the case to one async test, one browser fixture, and one page operation.
  2. Use function scope for both browser and loop.
  3. Remove asyncio.run(), run_until_complete(), synchronous browser wrappers, and request interception.
  4. Turn on Pyppeteer and Chromium debug logging.
  5. Confirm the executable path and Chromium version.
  6. Run the same test in the target container or CI image, then inspect sandbox permissions and process-kill messages.
  7. 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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Performance 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.

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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.

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.