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.

Use Playwright’s page.expect_response() as a context manager around the click or other action that starts the request. Register the expectation first, match the intended URL (and, when useful, method and status), then wait for the page element that proves the response has been rendered before calling page.screenshot(). This avoids fragile fixed sleeps and prevents screenshots of an unfinished interface.

The reliable pattern: wait, trigger, verify, capture

A network response and a finished visual state are different events. The response may arrive while JavaScript is still parsing data, updating the DOM, loading images, or animating a component. The robust sequence is:

  1. Open a response expectation before the action that causes the request.
  2. Match the response narrowly, preferably by URL plus HTTP method and status.
  3. Perform the click, form submission, or other trigger.
  4. Read the response and check that it succeeded.
  5. Wait for a meaningful UI condition, such as a result becoming visible.
  6. Capture the screenshot.

Playwright documents synchronous and asynchronous Python APIs. Pick one style for the surrounding application rather than mixing them.

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

Synchronous Playwright example

Install Playwright and its browser binaries in the environment where the script runs:

python -m pip install playwright
playwright install chromium

The following script waits for a successful GET response whose URL contains /api/data, then waits for the text that the application displays after rendering.

from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError

TARGET = "https://example.com"

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto(TARGET, wait_until="domcontentloaded")

    try:
        # Register this before the click that starts the request.
        with page.expect_response(
            lambda response: (
                "/api/data" in response.url
                and response.request.method.lower() == "get"
                and response.status == 200
            ),
            timeout=30_000,
        ) as response_info:
            page.get_by_role("button", name="Load data").click()

        response = response_info.value
        # The response arrived; now prove that the UI has painted it.
        page.get_by_text("Data loaded").wait_for(state="visible")
        page.screenshot(path="page.png", full_page=True)
    except PlaywrightTimeoutError:
        # Treat a missing response or missing UI state as a failed capture.
        raise RuntimeError("The expected response or rendered result did not arrive in time")
    finally:
        browser.close()

example.com, /api/data, the button name, and Data loaded are placeholders. Replace them with identifiers from your application. A role-based locator is preferable to a brittle CSS path when the control has an accessible name.

Asynchronous Playwright example

In an asyncio application, use async with and await every operation:

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
import asyncio
from playwright.async_api import async_playwright, TimeoutError as PlaywrightTimeoutError

async def capture_after_data():
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page()
        await page.goto("https://example.com", wait_until="domcontentloaded")

        try:
            async with page.expect_response(
                "**/api/data",
                timeout=30_000,
            ) as response_info:
                await page.get_by_role("button", name="Load data").click()

            response = await response_info.value
            if not response.ok:
                raise RuntimeError(f"Data request returned HTTP {response.status}")

            await page.get_by_text("Data loaded").wait_for(state="visible")
            await page.screenshot(path="page.png", full_page=True)
        except PlaywrightTimeoutError as exc:
            raise RuntimeError("Expected network or UI state timed out") from exc
        finally:
            await browser.close()

asyncio.run(capture_after_data())

A glob such as **/api/data is convenient, but a predicate is safer when several requests share a path. You can combine URL, method, status, query parameters, or other response properties in that predicate.

Choose the event that matches what you need

Playwright wait What it proves Use it when
expect_request The browser issued a matching request. You need to verify that an action started the call, before any response exists.
expect_response Matching response status and headers were received. You need the server’s HTTP result before proceeding with the capture.
expect_request_finished The request lifecycle reached completion, including body download. You need the transfer to finish, for example before processing a downloaded response.

These events are not interchangeable. A request can be issued and then fail at the network layer, producing a failure event rather than a normal response. Conversely, HTTP 404 or 503 responses still complete as requests; check response.status or response.ok if only successful responses should trigger a screenshot.

How to match the correct response

Prefer a specific URL predicate

with page.expect_response(
    lambda r: (
        r.url.startswith("https://app.example.test/api/items")
        and r.request.method.upper() == "POST"
        and r.status == 201
    )
) as info:
    page.get_by_role("button", name="Create item").click()

A broad pattern such as **/* can resolve on analytics, fonts, or unrelated API traffic. Include a distinctive path and, when relevant, the method and expected status. If query strings vary, inspect r.url in a temporary diagnostic predicate or match only the stable path.

Capture the response body when it helps debugging

with page.expect_response("**/api/data") as info:
    page.get_by_role("button", name="Load data").click()
response = info.value
if not response.ok:
    raise RuntimeError(f"HTTP {response.status}: {response.status_text}")
data = response.json()

Do not assume that a valid JSON response means the screenshot is ready. Keep the separate UI wait for the state the reader should see.

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

Timeouts and failure handling

expect_response has a documented default timeout of 30,000 milliseconds. Set a per-wait timeout when this operation has a different service-level requirement; setting it to 0 disables the timeout, which is rarely appropriate for unattended jobs. Page or browser-context timeout settings can provide a common policy.

  • Timeout: the request may not have been triggered, the matcher may be wrong, the server may be slow, or the page may have navigated away. Save a diagnostic screenshot, console log, or trace, then fail the job rather than capturing an arbitrary state.
  • HTTP error: a 4xx or 5xx response can still satisfy a URL-only matcher. Check status or ok before proceeding.
  • Network failure: DNS, TLS, connection, or browser policy failures can emit a failed-request event without a response. Report that separately from an HTTP error.
  • Race condition: if the listener is created after the click, a fast response can be missed. Always enter the expectation context first.

Wait for visual readiness after the response

Use a condition tied to the actual interface: a result row becomes visible, a loading indicator disappears, a status changes, or a specific element reaches the expected text. Examples:

# A result is inserted into the DOM
page.locator("[data-testid='results']").wait_for(state="visible")

# A spinner is removed
page.locator("[data-testid='spinner']").wait_for(state="hidden")

# A status label contains the expected text
page.get_by_text("Saved").wait_for(state="visible")

Fixed waits such as page.wait_for_timeout(5000) are discouraged for production synchronization: five seconds may be too short on a busy run and wasteful on a fast one. The Page API also discourages using networkidle as a generic readiness signal. Prefer an assertion or an application-specific selector that represents completion.

Common problems and fixes

The wait times out immediately

  • Confirm the action really triggers the request; a disabled button or validation error may prevent it.
  • Log the actual request URLs and compare query strings, trailing slashes, and HTTP method.
  • Ensure the expectation is entered before the action.

The wrong response satisfies the matcher

Narrow the predicate by path, method, status, and—if necessary—a query parameter or request header. Avoid matching a common substring such as api.

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

The screenshot still shows a spinner

The response arrived before rendering completed. Add a wait for the final element or for the spinner to become hidden. If rendering depends on several calls, wait for the last meaningful UI condition rather than guessing which request is last.

The script sees 404 or 503 but continues

URL matching alone accepts error responses. Reject non-success statuses explicitly with if not response.ok or a predicate requiring the expected status.

Navigation hides the element you need

If the action navigates, include the navigation and response expectations around the same action, then locate the readiness element on the destination page. Use stable locators rather than handles to elements from the old document.

Performance, reliability, and repeatable captures

  • Reuse a browser process for multiple pages, but create an isolated context when cookies, locale, or authentication must not leak between jobs.
  • Set explicit navigation and response timeouts appropriate to your service, and record elapsed time and the matched URL for diagnosis.
  • Keep screenshots deterministic: fix viewport, device scale factor, timezone, locale, and test data when visual diffs matter.
  • Use full_page=True only when a full document is needed; viewport captures are smaller and faster.
  • Close pages, contexts, and the browser in finally blocks so failures do not exhaust resources.
  • For retries, repeat the complete action-and-wait transaction. Do not reuse a response object from a previous attempt.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

If you only need a finished website image rather than browser-level control, ScreenshotNeo provides a single HTTP request. Its capture flow accepts cookie and consent banners and removes more than 60 known consent platforms, newsletter popups, and chat widgets before the shot; each step can be disabled. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and the response reports the result in X-Page-Verdict and X-Billed headers. An MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.

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

Python (see the ScreenshotNeo API documentation):

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)

Equivalent cURL:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

Equivalent 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}`);

Every plan includes the features; the Free plan provides 1,000 screenshots per month with no card, and paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account to get an API key.

When to use Playwright instead

Keep Playwright when the screenshot depends on a precise interaction sequence, custom JavaScript assertions, authenticated browser state, or a response whose body you must inspect. Use a screenshot API when you want a repeatable URL-to-image or PDF request without maintaining browser binaries and synchronization code.

Frequently Asked Questions

Can I wait for a request and a response in the same Playwright block?

Yes. Nest or place separate expectations around the same triggering action, but make each matcher specific so an unrelated request cannot satisfy either wait.

What if the page makes several identical requests?

Match additional properties such as method, query parameters, request payload, or response status, then wait for the UI state that identifies the particular result you need.

Free tools Windows power users keep installed

One-click scans. No signup required.

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

Does a successful response guarantee that the screenshot contains the new data?

No. The response only establishes a network event. Wait for the rendered element, text, or loading-state transition that proves the page has updated.

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.