ScreenshotNeo

BlogHow-to

How to Capture Background Requests and Responses in Playwright

Learn to observe, wait for, and intercept Playwright network traffic, handle Service Workers, debug failures, and choose the right API.

By the ScreenshotNeo team29 September 20269 min read

How to Capture Background Requests and Responses in Playwright

To capture background requests and responses in Playwright, attach page.on('request') and page.on('response') listeners before navigation or before the action that triggers traffic. Use page.waitForResponse() when you need one response caused by a specific action, and use page.route() or browserContext.route() when you need to intercept, mock, modify or abort requests.

The timing of listener registration matters. A listener added after page.goto() or after a click can miss an early request. The examples below use TypeScript, but the same Playwright APIs work in JavaScript.

Choose the right Playwright network API

Goal API Scope What you receive
Log all traffic from one page page.on('request'), page.on('response') One page Request metadata, then response status and headers
Log traffic from every page and popup browserContext.on('request'), browserContext.on('response') Entire browser context Events from all pages, including relevant Service Worker traffic
Wait for one API response page.waitForResponse() One page and one awaited operation A matching Response object
Wait for one outgoing request page.waitForRequest() One page and one awaited operation A matching Request object
Mock or change traffic page.route(), browserContext.route() Page or context A route that must be continued, fulfilled or aborted

Playwright documents these event and routing APIs in its network guide, Page API and BrowserContext API.

Log every request and response on one page

Register listeners before goto(). This captures document requests, API calls, images, scripts, stylesheets and other resources initiated by that page.

A Playwright listener observes the request and response around a user action.
A Playwright listener observes the request and response around a user action.
import { chromium } from '@playwright/test';

const browser = await chromium.launch();
const page = await browser.newPage();

page.on('request', request => {
  console.log('>>', request.method(), request.resourceType(), request.url());
});

page.on('response', response => {
  console.log('<<', response.status(), response.request().resourceType(), response.url());
});

page.on('requestfailed', request => {
  console.log('FAILED', request.failure()?.errorText, request.url());
});

await page.goto('https://example.com');
await browser.close();

The request event fires when the browser starts the request. The response event fires when status and headers arrive. It does not guarantee that the response body has finished downloading. The requestfinished event marks completion of the body download. An HTTP 404 or 503 is still a completed HTTP response; it is not a transport-level requestfailed event. See the Request API for the lifecycle details.

Read a response body

Calling response.text() or response.json() reads the body after the response event. If you need to know that downloading has completed first, wait for requestfinished or use the response body method, which waits for the body to be available.

page.on('response', async response => {
  if (!response.url().includes('/api/products')) return;

  try {
    const body = await response.text();
    console.log(response.status(), body.slice(0, 500));
  } catch (error) {
    console.error('Could not read response body', response.url(), error);
  }
});

Do not assume that every response is JSON. Check the URL, status and content type before parsing.

Capture requests from every page in a browser context

A context-level listener is useful when the application opens a popup, creates multiple pages, or when you want one collector for the complete browser session.

const context = await browser.newContext();

context.on('request', request => {
  console.log('REQUEST', request.method(), request.url());
});

context.on('response', response => {
  console.log('RESPONSE', response.status(), response.url());
});

const page = await context.newPage();
await page.goto('https://example.com');

const popupPromise = context.waitForEvent('page');
await page.getByRole('link', { name: 'Open report' }).click();
const popup = await popupPromise;
await popup.waitForLoadState();

await context.close();

Use the context when the first request from a popup must be routed. The Page API notes that page.route() does not intercept the first request of a popup; browserContext.route() covers it.

Wait for an API response after a click

For an action-specific assertion, create the wait promise before the action, perform the action, and then await the promise. This ordering prevents a race in which the response arrives before the listener is installed.

const responsePromise = page.waitForResponse(response =>
  response.url().includes('/api/search') &&
  response.request().method() === 'GET' &&
  response.status() === 200
);

await page.getByRole('button', { name: 'Search' }).click();

const response = await responsePromise;
const results = await response.json();
console.log(results);

waitForResponse() accepts a URL string or glob, a regular expression, or a predicate. A predicate is usually the safest choice because it can check URL, method and status together.

Wait for the outgoing request instead

const requestPromise = page.waitForRequest(request =>
  request.url().includes('/api/search') &&
  request.method() === 'POST'
);

await page.getByRole('button', { name: 'Search' }).click();

const request = await requestPromise;
console.log(request.postDataJSON());

Use waitForRequest() when the payload or headers are what you need to inspect. Use waitForResponse() when the returned status or body is the important result.

Filter noisy background traffic

Modern pages can generate hundreds of events. Filter by URL, method, resource type or status before logging or storing data.

page.on('request', request => {
  const url = request.url();
  const interesting = url.includes('/api/') && request.method() !== 'OPTIONS';
  if (interesting) {
    console.log(request.method(), request.resourceType(), url);
  }
});

page.on('response', response => {
  const request = response.request();
  if (request.resourceType() === 'xhr' || request.resourceType() === 'fetch') {
    console.log(response.status(), response.url());
  }
});

When matching URL globs, * matches characters other than a slash, while ** can cross slashes. Braces express alternatives, such as **/*.{png,jpg}. Regular expressions and predicates are available when glob matching is too broad.

Intercept, mock and modify requests

Listeners observe traffic. Routing changes what the page receives. Every matching route must eventually call route.continue(), route.fulfill() or route.abort(); otherwise the request remains stalled.

Return a fixture instead of the live API

await page.route('**/api/search', async route => {
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({ results: [] }),
  });
});

await page.goto('https://example.com');

Continue a request with changed headers

await page.route('**/api/**', async route => {
  const headers = {
    ...route.request().headers(),
    'x-test-run': 'playwright',
  };

  await route.continue({ headers });
});

Fetch the real response, then modify it

await page.route('**/api/profile', async route => {
  const response = await route.fetch();
  const json = await response.json();
  json.plan = 'demo';

  await route.fulfill({
    response,
    body: JSON.stringify(json),
    headers: { ...response.headers(), 'content-type': 'application/json' },
  });
});

Use browserContext.route() when the rule applies to every page or must cover a popup’s first request. Register routes before navigation or the triggering action.

Understand the request lifecycle

For a normal successful request, Playwright emits events in this order:

  1. request: the browser starts sending the request.
  2. response: status and headers arrive.
  3. requestfinished: the response body has downloaded.

requestfailed means that the client did not receive an HTTP response because of a network error, DNS failure, connection reset or timeout. A server response with status 404, 429 or 503 follows the response and finished path. Redirects finish the original request and create a new request for the redirected URL.

page.on('requestfinished', request => {
  console.log('DOWNLOADED', request.url());
});

page.on('requestfailed', request => {
  console.log('TRANSPORT FAILURE', request.failure(), request.url());
});

Service Workers and missing requests

If a route never sees a request that appears in the browser, check Service Workers before concluding that Playwright failed. Native page and context routing does not intercept requests handled by a Service Worker. Mock Service Worker (MSW) also installs a Service Worker and can make mocked traffic invisible to built-in routing.

When Service Worker behavior is not part of the test, block workers for predictable routing:

const context = await browser.newContext({ serviceWorkers: 'block' });
await context.route('**/api/search', route => route.fulfill({
  status: 200,
  contentType: 'application/json',
  body: JSON.stringify({ results: [] }),
}));

Blocking workers changes the site’s behavior, so keep them enabled when testing the worker itself. If workers matter, listen at the BrowserContext level and inspect request.serviceWorker() where supported. For requests originating in a Service Worker, request.frame() is unavailable. response.fromServiceWorker() identifies a page request handled by a worker. Playwright’s documented Service Worker support is limited to Chromium-based browsers. See the Service Workers guide.

Reliable patterns for tests and collectors

  • Install listeners and routes before goto() or the user action.
  • Use a specific URL predicate and method instead of matching a broad domain.
  • Give waits an explicit timeout appropriate for the application; avoid indefinite promises.
  • Prefer UI assertions for readiness. Playwright marks networkidle as discouraged for tests because analytics, polling and long-lived connections can keep a page busy.
  • Remove listeners or close the context after each test to prevent duplicate logs and memory growth.
  • Do not store full response bodies for every asset. Keep metadata by default and save bodies only for matching API responses.
  • Redact authorization headers, cookies and personal data before writing network logs.

Common errors and fixes

Symptom Likely cause Fix
waitForResponse times out The promise was created after the click, or the predicate does not match the real URL. Create it before the action; log requests and match the actual method, URL and status.
A 404 appears as a failure HTTP errors are being confused with transport failures. Inspect response.status(). Use requestfailed for network-level failures.
Route handler hangs The handler never completed the route. Call exactly one of continue, fulfill or abort.
Popup’s first request is not mocked The route is attached to the opener page. Attach it to the BrowserContext before opening the popup.
Requests visible in DevTools are absent from routing A Service Worker or MSW handled them. Block workers when appropriate, or observe at context level and inspect worker ownership.
JSON parsing throws The response is HTML, empty, compressed unexpectedly, or an error page. Check status and content type, then use response.text() for diagnosis.
Hosted capture can remove common overlays before producing the image.
Hosted capture can remove common overlays before producing the image.

Performance, reliability and cost considerations

Event handlers run while the browser is processing traffic. Keep them small: push selected metadata into an in-memory array and write it after the action, rather than performing slow file or database operations for every event. Filtering early reduces CPU, memory and log volume.

Reading every response body is expensive on pages with large images, videos or bundles. Restrict body capture to API URLs or a known resource type. For long-running collectors, cap the number of retained events and close contexts promptly.

Routes are deterministic for matching requests, but they can change application behavior. Keep fixtures versioned and make route predicates narrow. For production monitoring, distinguish server HTTP errors from transport failures so alerts identify the correct class of problem.

Or skip the browser setup

If your goal is a clean screenshot rather than network inspection, ScreenshotNeo provides a one-call website capture API. It accepts a URL and returns PNG, JPEG, WebP or PDF. Before capture it accepts cookie and consent banners and removes more than 60 known consent platforms, newsletter popups and chat widgets; each cleanup step can be turned off.

Only clean shots are billed. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads and cache hits cost nothing, and the response identifies the result with X-Page-Verdict and X-Billed headers.

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(`Screenshot failed: ${res.status}`);
const file = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', file));

See the complete ScreenshotNeo API documentation for the 63 available options: full-page capture with lazy images loaded, CSS element capture, dark mode, device presets, custom viewports, retina scale, PDF paper and page-range controls, custom CSS and JavaScript, clicks, selector waits, delays, network-idle waits, request blocking, headers, cookies, user agents, authorization, timezone, geolocation, transparent backgrounds, resizing, configurable caching, signed links, asynchronous jobs, webhooks, bulk capture and usage reporting. An MCP server exposes take_screenshot, get_page_info and capture_pdf to Claude, Cursor and other MCP clients.

The Free plan includes 1,000 screenshots each month with no card. Paid plans start at $5 for 3,000 shots; every feature is available on every plan. Create a free ScreenshotNeo account.

FAQ

Can I capture requests made by an iframe?

Page and context listeners observe requests from the browser session, including frame traffic. Use the request’s frame information when available, and remember that Service Worker-originated requests do not have a frame.

Should I use response or requestfinished?

Use response when status and headers are enough. Use requestfinished when you need to know that the response body download completed.

Can I route a request and still inspect its real response?

Yes. Call route.fetch(), inspect or modify the returned response, and finish with route.fulfill().

Why does a request appear twice?

Check redirects, retries, preflight OPTIONS requests and application polling. Log method, URL and resource type to separate those cases.

Is network traffic capture the same as taking a screenshot?

No. Playwright network APIs observe or change browser traffic. ScreenshotNeo is a hosted capture API for producing clean images or PDFs without maintaining a browser setup.