ScreenshotNeo

BlogHow-to

How to Get JSON Responses with Puppeteer and Playwright

Capture JSON from browser requests with Puppeteer or Playwright, parse it safely, and learn when a direct API request is the better fit.

By the ScreenshotNeo team4 October 202610 min read

To read JSON returned by a browser interaction, register a response wait before triggering the click or action, await the matching response, check its HTTP status, and then call await response.json(). This works in both Puppeteer and Playwright. If you need to call an API directly rather than observe a request made by a page, use Playwright’s APIRequestContext.

Choose the response workflow

First decide where the request comes from. That determines which response object to use and how to wait for it.

Goal Use Response object
Read an API response caused by a click, navigation, or page script Puppeteer or Playwright page response wait Puppeteer HTTPResponse or Playwright Response
Call an endpoint directly without relying on page behavior Playwright APIRequestContext APIResponse
Observe many responses as they arrive Page response event Page response object
Change, mock, fulfill, or abort requests Request interception or routing Depends on the library and workflow

For a single interaction, a targeted wait is usually easiest to reason about. Use an event listener when you need ongoing network monitoring. Interception is a separate choice: it changes request handling and is unnecessary for passively reading a response.

Get JSON from a response in Puppeteer

Install Puppeteer in a Node.js project with npm install puppeteer. The following CommonJS example opens a page, waits for the endpoint response before clicking, checks status, and parses the body.

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch({ headless: true });
  try {
    const page = await browser.newPage();
    await page.goto('https://example.com/dashboard', {
      waitUntil: 'domcontentloaded',
    });

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

    await page.click('button#load-items');
    const response = await responsePromise;

    if (!response.ok()) {
      throw new Error(`Items request returned HTTP ${response.status()}`);
    }

    let data;
    try {
      data = await response.json();
    } catch (error) {
      const body = await response.text().catch(() => '[body unavailable]');
      throw new Error(`Items response was not valid JSON: ${body.slice(0, 500)}`, {
        cause: error,
      });
    }

    console.log(data);
  } finally {
    await browser.close();
  }
})().catch(error => {
  console.error(error);
  process.exitCode = 1;
});

page.waitForResponse accepts a URL or a predicate. The predicate above matches the endpoint and method; add a status condition if the page can issue multiple matching requests and you only want a particular status. Puppeteer’s HTTPResponse provides status(), ok(), and json(). The JSON helper throws if the body cannot be parsed as JSON. See the official Puppeteer Page.waitForResponse reference and HTTPResponse reference.

Use a narrow matcher

A broad match such as response => response.url().includes('/api') may catch analytics, background refreshes, or another endpoint. Include a distinctive path and, where useful, the request method and expected status:

const responsePromise = page.waitForResponse(response =>
  new URL(response.url()).pathname === '/api/items' &&
  response.request().method() === 'GET' &&
  response.status() === 200
);

Choose the conditions that describe the response you actually need. If you match only status 200, a legitimate 401 or 500 response will not satisfy the wait; for debugging or error handling, match the endpoint and inspect its status afterward.

Get JSON after an action in Playwright

Install Playwright with npm install playwright. This example uses the Chromium browser bundled with the package. As in Puppeteer, create the wait promise before clicking.

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch({ headless: true });
  try {
    const page = await browser.newPage();
    await page.goto('https://example.com/dashboard', {
      waitUntil: 'domcontentloaded',
    });

    const responsePromise = page.waitForResponse(response =>
      new URL(response.url()).pathname === '/api/items' &&
      response.request().method() === 'GET'
    );

    await page.getByRole('button', { name: 'Load items' }).click();
    const response = await responsePromise;

    if (!response.ok()) {
      throw new Error(`Items request returned HTTP ${response.status()}`);
    }

    let data;
    try {
      data = await response.json();
    } catch (error) {
      const body = await response.text().catch(() => '[body unavailable]');
      throw new Error(`Items response was not valid JSON: ${body.slice(0, 500)}`, {
        cause: error,
      });
    }

    console.log(data);
  } finally {
    await browser.close();
  }
})().catch(error => {
  console.error(error);
  process.exitCode = 1;
});

Playwright’s matcher can be a URL string, regular expression, or predicate. The predicate receives the page Response; from it, you can inspect the URL, status, and associated request method. The official Page.waitForResponse documentation shows the wait-before-click pattern.

Wait with a regular expression or URL

For a stable endpoint, a regular expression can make the match concise:

const responsePromise = page.waitForResponse(/\/api\/items(?:\?|$)/);
await page.getByRole('button', { name: 'Load items' }).click();
const response = await responsePromise;
const data = await response.json();

Prefer a predicate when the method, status, or parsed URL path matters. A plain URL string is useful when the complete URL is stable. Query strings, hostnames, and API version paths can vary, so avoid matching more loosely than needed.

Call an API directly with Playwright

If there is no need to reproduce the page’s browser behavior, make a direct call with Playwright’s APIRequestContext. This avoids launching a browser page just to fetch a JSON endpoint. A standalone example can create and dispose its request context like this:

const { request } = require('playwright');

(async () => {
  const api = await request.newContext({
    baseURL: 'https://example.com',
    extraHTTPHeaders: {
      Accept: 'application/json',
    },
  });

  try {
    const response = await api.get('/api/items');
    if (!response.ok()) {
      throw new Error(`HTTP ${response.status()}`);
    }

    const data = await response.json();
    console.log(data);
  } finally {
    await api.dispose();
  }
})().catch(error => {
  console.error(error);
  process.exitCode = 1;
});

In a Playwright test, you can instead use the provided request fixture. The API response is an APIResponse, a different type from the page’s Response. It supports json(), body(), text(), status(), and ok(). Its body remains in memory until the request context closes; in a long-running workflow, dispose of responses when you no longer need them. See the official APIResponse reference and APIRequestContext reference.

Handle status, parsing, and timeouts separately

Reliable response handling has three distinct failure points: the wait may time out because no matching response arrived; an HTTP response may arrive with an error status; or the body may not be valid JSON. Handle each at the point where it becomes known.

Check What it tells you What it does not tell you
The wait resolves A response matched your condition That its HTTP status is successful
response.ok() or status Whether the HTTP status indicates success That the body is valid JSON
await response.json() Whether the body can be parsed as JSON That the returned data has the shape your code expects

A 404 or 503 is still an HTTP response, so a wait that matches only the URL can resolve for it. In Playwright, a request that cannot get an HTTP response due to a network failure is reported through request failure handling rather than as an HTTP response. Keep transport errors, HTTP errors, and parse errors distinguishable in logs.

Set a timeout and report useful context

Both libraries let you configure the response wait timeout. Use a bounded timeout appropriate for the application, and include the endpoint or action in the error so the failure is actionable. For example, in Playwright:

const responsePromise = page.waitForResponse(
  response => response.url().includes('/api/items'),
  { timeout: 15_000 }
);

await page.getByRole('button', { name: 'Load items' }).click();
const response = await responsePromise;

A timeout means the predicate did not match before the deadline; it does not necessarily mean the server never replied. The matcher could be wrong, the action could have failed to run, or the page may have made a different request. Check the page’s observed requests before increasing the timeout.

Validate the data shape

Valid JSON can still be an error object or a payload with missing fields. Validate the shape your application needs before using it:

const data = await response.json();
if (!data || !Array.isArray(data.items)) {
  throw new Error('Expected JSON with an items array');
}

For production code, use the schema validation approach already used in your project. Do not treat successful JSON parsing as proof that the payload is semantically correct.

Observe responses with events

When you need to inspect every response or collect traffic over a period, listen for the page response event. Playwright example:

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

  console.log(response.status(), response.url());
  if (response.headers()['content-type']?.includes('application/json')) {
    try {
      console.log(await response.json());
    } catch (error) {
      console.error('Could not parse response JSON:', error.message);
    }
  }
});

Keep event handlers bounded: pages can produce many responses, and reading every body can add memory and processing overhead. Filter early by host, path, method, or content type, and avoid logging tokens or personal data. A targeted waitForResponse is clearer when one action should produce one response for a test or script.

When request interception is appropriate

Use interception when you need to modify, abort, or fulfill a request, or to stub an endpoint in a test. Do not enable it only to read a response body. The Puppeteer project’s official Request Interception guide warns that once interception is enabled, each request stalls until it is continued, fulfilled, or aborted. Every intercepted request therefore needs a reliable handler, including requests you do not intend to change. An incomplete handler can make navigation or application loading appear to hang.

Common problems and fixes

Symptom Likely cause Fix
The response wait times out The wait began after the click, the click did not trigger the request, or the matcher does not describe the actual URL/method Create the wait promise first; inspect the request URL and method; narrow or correct the predicate; confirm the control is available and the action completed
The wait resolves on the wrong response The predicate matches a broad path used by multiple requests Match the exact pathname and add the request method, host, or status when appropriate
response.json() throws The body is empty, HTML, malformed JSON, or another format Check status and content type; inspect response.text() for a short diagnostic excerpt; fix the server expectation or handle the non-JSON response
JSON parses but the script fails later The payload is valid JSON but has an unexpected schema or error object Validate required fields and handle API-level errors explicitly
A 401, 404, or 500 is treated as a missing response The matcher includes a 2xx status condition, so the error response cannot match Match the endpoint first, then inspect status and report the HTTP error
Requests hang after enabling interception An intercepted request was never continued, fulfilled, or aborted Ensure every request reaches a resolving handler, or remove interception if passive observation is all you need
Direct API call differs from the page result The browser request may depend on page cookies, authentication, headers, or client-side state Use the page-response workflow when you need the request the page actually made; otherwise provide the necessary request context to the direct call

Performance, reliability, and cost

  • Prefer direct API requests when suitable. A direct request avoids page startup and UI interaction. Keep browser-driven capture when authentication, client state, or the exact page-generated request is part of the behavior being tested.
  • Wait only for the event you need. A precise response predicate avoids unrelated waits and reduces ambiguity when the page makes background requests.
  • Do not over-wait for page load. Choose a navigation condition that fits the page. A page can continue making network requests after DOM content is ready; the response wait should target the endpoint rather than assuming all network activity has stopped.
  • Close resources predictably. Close browser instances and dispose direct API contexts in finally blocks. For long-running direct API workflows, release response bodies when they are no longer needed.
  • Keep captured bodies modest. Avoid retaining many large response bodies or parsing every response from a busy page. Filter, process, and discard data as early as the workflow allows.
  • Cost depends on your environment. Browser launch and execution consume compute and time; direct requests are generally lighter when browser behavior is unnecessary. Actual infrastructure cost depends on where and how often the script runs, so measure it in your deployment rather than assuming a fixed price.

Or skip the browser setup

If the goal is a clean screenshot of a page rather than the JSON body behind its network activity, ScreenshotNeo is a website screenshot API and MCP server for developers. One GET request returns a PNG, JPEG, WebP, or PDF. Its screenshot API is documented at ScreenshotNeo docs.

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 request failed: HTTP ${res.status}`);
await require('node:fs/promises').writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

ScreenshotNeo accepts cookie and consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and responses report the page verdict and billing status in headers. Its MCP server gives AI agents tools to take screenshots, get page information, and capture PDFs. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 screenshots.

Sign up free for 1,000 screenshots a month, with no card required.

FAQ

How do I get the JSON response after clicking a button in Playwright?

Create page.waitForResponse(...) before calling the button’s click(), await the matching response, check ok() or status, then call await response.json().

How do I read the response body as JSON in Puppeteer?

Await the matching HTTPResponse from page.waitForResponse, then call await response.json(). Check response.ok() separately.

Why does response.json() fail?

The response body may not be valid JSON, even if an HTTP response arrived. Inspect its status, content type, and text body to find out whether the server returned HTML, an empty body, or an error payload in another format.

Should I use page responses or Playwright APIRequestContext?

Use page responses to inspect traffic initiated by the rendered site. Use APIRequestContext when you want to make a direct API call without driving the page.