TL;DR

Start the page.waitForResponse wait before the click that triggers the response, with no await, then await the promise after the click. With nothing left to match, it runs until the 30-second default test timeout fails the test.

  1. Create the promise first, act, then await the promise.
  2. Match the method and pathname (or the GraphQL operation) so the wait catches only the call your action triggers.

The test clicks Place order, then waits for the API call behind it. It passes on every local run. On the CI runner it hangs for 30 seconds, fails on a timeout, and goes green on retry.

The code looks harmless: it awaits the click, then creates the wait with page.waitForResponse on the next line. On a laptop, the wait is usually listening by the time the response lands. Playwright's docs start waiting for the response before the click that triggers it. With nothing left to match, the wait runs until the test's 30-second timeout ends the test. When the retry passes, Playwright reports the test as flaky, not failed.

Playwright's own example shows the fix as one reordered line and a code comment: start waiting for the response before clicking, and note there's no await. That order is necessary. It isn't enough on its own. A glob URL pattern, for one, must match the entire URL, not just part of it.

What you want is a wait that starts before the click, matches only the call you meant, fails with an error from the wait itself, and leaves evidence in the trace when it does fail.

When waitForResponse fits

Most tests that reach for a network wait don't need one. If the result of the action renders on the page, assert on the page. With web-first assertions, Playwright waits until the expected condition is met, and auto-retrying assertions have their own timeout, 5 seconds by default.

page.waitForResponse returns the matched response, so the test can check what the server answered. When the answer doesn't matter and you only need to prove the request went out with the right method or body, use page.waitForRequest, which waits for the matching request and returns it. request.postDataJSON() gives you the parsed body to assert on.

import { test, expect } from '@playwright/test';

test('a rendered result needs only a web-first assertion', async ({ page }) => {
  // Paths assume baseURL is set in playwright.config.ts (use: { baseURL })
  await page.goto('/orders');
  await page.getByRole('button', { name: 'Refresh' }).click();
  // Retries until the row appears; no network wait needed
  await expect(page.getByRole('row', { name: /Order #1042/ })).toBeVisible();
});

test('waitForRequest checks the body that was sent', async ({ page }) => {
  await page.goto('/settings');
  const requestPromise = page.waitForRequest(
    (request) => request.url().endsWith('/api/settings') && request.method() === 'PUT',
  );
  await page.getByLabel('Weekly digest').check();
  const request = await requestPromise;
  expect(request.postDataJSON()).toMatchObject({ weeklyDigest: true });
});

test('waitForResponse confirms a save with no UI change', async ({ page }) => {
  await page.goto('/editor/draft-17');
  const responsePromise = page.waitForResponse(
    (response) =>
      response.url().endsWith('/api/drafts/17') && response.request().method() === 'PATCH',
  );
  await page.getByLabel('Body').fill('Second paragraph');
  const response = await responsePromise;
  expect(response.ok()).toBe(true);
});

Anti-pattern

A sleep instead of a wait

A flaky network wait gets swapped for a pause, and the pause passes on the machine where someone tuned the number. Playwright's docs say never to wait for a timeout in production, because tests that wait for time are inherently flaky. The pause wastes time on a fast machine and fails whenever the API is slower than the guess.

// Anti-pattern: guesses how long the save takes, so it fails whenever the API is slower
import { test, expect } from '@playwright/test';

test('autosave persists the draft', async ({ page }) => {
  await page.goto('/editor/draft-17');
  await page.getByLabel('Body').fill('Second paragraph');
  await page.waitForTimeout(2000);
  await page.reload();
  await expect(page.getByLabel('Body')).toHaveValue('Second paragraph');
});

Fix

Wait for the event the test depends on: the save's response, as in the third test above, or a web-first assertion when the result renders. If the server's timing or data is what makes the test flaky, mock the endpoint with page.route instead of waiting for a real server at all.

The click-then-wait race

The flake comes down to the order of two lines. page.waitForResponse(urlOrPredicate, options) starts a wait and hands you a promise, and the official example creates that promise before the click on purpose. Its comment says it plainly: start waiting for response before clicking, note no await.

Anti-pattern

Click, then wait

This test clicks first and creates the wait second, while the docs' example starts waiting for the response before clicking, without await.

// Anti-pattern: the wait starts only after the click resolves; the docs start waiting before clicking
import { test, expect } from '@playwright/test';

test('placing an order saves it', async ({ page }) => {
  // Assumes baseURL is set in playwright.config.ts
  await page.goto('/checkout');
  await page.getByRole('button', { name: 'Place order' }).click();
  const response = await page.waitForResponse('**/api/orders');
  expect(response.ok()).toBe(true);
});

The await on the click holds the next line until the click resolves, and the wait doesn't exist until that line runs. With nothing else matching, the wait never resolves and the test fails at Playwright Test's 30-second default test timeout.

Whether the response lands inside that gap depends on timing, and timing is exactly what changes between a laptop and a busy CI runner. A click-then-wait test that passes a hundred times on localhost proves only that the response was slow enough on that machine.

Fix

Create the promise first, act, then await the promise.

import { test, expect } from '@playwright/test';

test('placing an order saves it', async ({ page }) => {
  // Assumes baseURL is set in playwright.config.ts
  await page.goto('/checkout');
  // Start waiting before the click; no await yet
  const responsePromise = page.waitForResponse('**/api/orders');
  await page.getByRole('button', { name: 'Place order' }).click();
  const response = await responsePromise;
  expect(response.ok()).toBe(true);
});

The wait now starts before the click, as in the docs' example. The '**/api/orders' matcher is still loose, though, and a loose matcher fails in its own ways.

URL strings, globs and RegExp

A wait that starts on time can still wait for nothing, because its matcher never matches. urlOrPredicate takes a URL string, a regex, or a predicate that receives the Response object. When the context has a baseURL and you pass a path, Playwright merges the two with the new URL() constructor, so tests can use relative URLs without repeating the full URL.

The Network guide uses a glob in its own example, '**/api/fetch_data'. Two glob rules decide whether yours matches. The glob must match the entire URL, not just part of it. A single * matches any characters except /.

Anti-pattern

Globs that match nothing

A glob gets written for the path it was tested against, then the app adds a query string or an ID segment. The wait runs until its timeout, which defaults to 30 seconds.

// Anti-pattern: the filter sends /api/orders?status=open, and the glob must match the whole URL
import { test, expect } from '@playwright/test';

test('filtering to open orders', async ({ page }) => {
  await page.goto('/orders');
  const responsePromise = page.waitForResponse('**/api/orders');
  await page.getByRole('button', { name: 'Open orders' }).click();
  expect((await responsePromise).ok()).toBe(true);
});

'**/api/*' falls into the same trap against /api/orders/42, since a single * matches any characters except /.

Fix

Pick the matcher that fits the URL. With a baseURL set, a path string gets merged with it via the new URL() constructor. A ** glob matches any characters including /, so it matches the endpoint on any host. For more complex matching, such as query strings or dynamic segments, the docs suggest RegExp instead of glob patterns.

import { test, expect } from '@playwright/test';

test.use({ baseURL: process.env.BASE_URL ?? 'http://localhost:3000' });

test('path string merged with baseURL', async ({ page }) => {
  await page.goto('/orders');
  const responsePromise = page.waitForResponse('/api/orders');
  await page.getByRole('button', { name: 'Refresh' }).click();
  expect((await responsePromise).ok()).toBe(true);
});

test('glob that matches any host', async ({ page }) => {
  await page.goto('/orders');
  const responsePromise = page.waitForResponse('**/api/orders');
  await page.getByRole('button', { name: 'Refresh' }).click();
  expect((await responsePromise).ok()).toBe(true);
});

test('RegExp that tolerates a query string', async ({ page }) => {
  await page.goto('/orders');
  const responsePromise = page.waitForResponse(/\/api\/orders(\?.*)?$/);
  await page.getByRole('button', { name: 'Open orders' }).click();
  expect((await responsePromise).ok()).toBe(true);
});

Method and status predicates

A matcher that catches the wrong response is worse than one that catches nothing. The test moves on early and fails somewhere that has nothing to do with the wait. One report had a RegExp matcher resolve on the CORS preflight OPTIONS response in Firefox instead of the real POST request (#36311). The issue was closed as completed and labelled v1.54.

Your own app can produce the same mix-up. page.waitForResponse() takes a URL string, regex or predicate and returns the matched response.

Anti-pattern

A matcher for any API call

A save that answers 201 can still fail this test. The wait can resolve on the order-list poll instead of the POST the click sent, and the assertion then reads the poll's status.

// Anti-pattern: '**/api/**' also matches the order-list poll, so the wait can resolve on the wrong call
import { test, expect } from '@playwright/test';

test('placing an order returns the new order', async ({ page }) => {
  await page.goto('/checkout');
  const responsePromise = page.waitForResponse('**/api/**');
  await page.getByRole('button', { name: 'Place order' }).click();
  const response = await responsePromise;
  expect(response.status()).toBe(201);
});

Fix

Filter on what identifies the call your action triggers. response.request() returns the matching Request. request.method() gives its HTTP method (GET, POST, etc.), so a predicate that checks for POST skips a GET poll to the same path. Compare the exact pathname, and add a query parameter when one tells calls apart.

import { test, expect } from '@playwright/test';

test('placing an order returns the new order', async ({ page }) => {
  await page.goto('/checkout');
  const responsePromise = page.waitForResponse((response) => {
    const url = new URL(response.url());
    return url.pathname === '/api/orders' && response.request().method() === 'POST';
  });
  await page.getByRole('button', { name: 'Place order' }).click();
  const response = await responsePromise;
  expect(response.status()).toBe(201);
});

Where the status check goes

The docs' predicate example checks the URL and a 200 status in the same predicate. That turns the status into a filter: a response with any other status doesn't match, so the wait keeps going until a matching response arrives or its timeout runs out (30 seconds by default). Keep status out of the predicate unless you mean to skip responses you know will come first. Assert on response.status() after the wait: it holds the status code of the response the wait matched.

Matching GraphQL operations

GraphQL defeats the matchers above. If your client sends every operation as a POST to one /graphql endpoint, URL and method look identical for every call on the page.

The difference lives in the request body. request.postDataJSON() returns the parsed request body, for form-urlencoded data and JSON as a fallback. The predicate can read operationName and variables from what you sent.

Anti-pattern

Matching only the endpoint

Any query the page fires can satisfy a wait on the GraphQL URL alone, so the test can pass on an operation it never meant to check.

// Anti-pattern: every operation hits /graphql, so this resolves on whichever matching query comes back
import { test, expect } from '@playwright/test';

test('saving the profile', async ({ page }) => {
  await page.goto('/profile');
  const responsePromise = page.waitForResponse('**/graphql');
  await page.getByRole('button', { name: 'Save profile' }).click();
  expect((await responsePromise).ok()).toBe(true);
});

Fix

Check the method, then the operation name and the variable that identifies this call.

import { test, expect } from '@playwright/test';

test('saving the profile', async ({ page }) => {
  await page.goto('/profile');
  const responsePromise = page.waitForResponse((response) => {
    const request = response.request();
    if (request.method() !== 'POST' || !response.url().endsWith('/graphql')) return false;
    const body = request.postDataJSON();
    return body?.operationName === 'UpdateProfile' && body?.variables?.userId === 'user-7';
  });
  await page.getByLabel('Display name').fill('Ada');
  await page.getByRole('button', { name: 'Save profile' }).click();
  const response = await responsePromise;
  expect(response.ok()).toBe(true);
});

Matching the request body you sent picks out the operation without reading the response body inside the predicate. The response body stays for the assertions, where a failure belongs.

Asserting status and payload

A response you log is a response nobody checks.

Anti-pattern

Logging instead of asserting

Nothing in the test asserts on the status. The status and body go to the console for a human to read later, and nothing asserts on them.

// Anti-pattern: a 403 with a JSON body prints to the console and the test still passes
import { test } from '@playwright/test';

test('placing an order', async ({ page }) => {
  await page.goto('/checkout');
  const responsePromise = page.waitForResponse('**/api/orders');
  await page.getByRole('button', { name: 'Place order' }).click();
  console.log((await responsePromise).status());
  console.log(await (await responsePromise).json());
});

Fix

Await the promise once into a variable and assert with expect. response.ok() is true only for a status in the 200-299 range, and response.status() gives the exact code, so put the code in the assertion message. response.json() throws when the body can't be parsed with JSON.parse, so check the status before parsing an endpoint that can answer with an empty body or an HTML error page.

import { test, expect } from '@playwright/test';

test('placing an order returns the new order', async ({ page }) => {
  await page.goto('/checkout');
  const responsePromise = page.waitForResponse(
    (response) =>
      new URL(response.url()).pathname === '/api/orders' &&
      response.request().method() === 'POST',
  );
  await page.getByRole('button', { name: 'Place order' }).click();
  const response = await responsePromise;

  // Status first: a failure reports the code instead of a JSON parse error
  expect(response.ok(), `POST /api/orders answered ${response.status()}`).toBe(true);

  const order = await response.json();
  expect(order).toMatchObject({ status: 'pending', items: [{ sku: 'TSHIRT-M' }] });
});

Timeouts on slow runners

When the wait does miss, the timeouts decide how long you sit there and which error you read. The waitForResponse timeout is in milliseconds and defaults to 30 seconds. Passing 0 disables it, and browserContext.setDefaultTimeout() or page.setDefaultTimeout() change the default.

Playwright Test also gives each test 30 seconds by default, and that budget includes fixture setup and beforeEach hooks. In Playwright Test, actionTimeout defaults to 0 (no timeout) and sets the same default as page.setDefaultTimeout(), so with defaults a missed response waits until the 30-second test timeout fails the test. Give the wait an explicit timeout that runs out before the time left in the test budget, and a missed response fails in the wait itself. If the error you get is a different timeout altogether, our checklist for debugging Playwright timeouts works through the other kinds.

A feature request (#31169) asked for a timeout specific to beforeAll/beforeEach hooks and waitForResponse in the Playwright config. It was closed for limited engagement. Besides the per-call timeout and setDefaultTimeout, the config's actionTimeout sets a default timeout for all Playwright actions, the same as page.setDefaultTimeout().

Anti-pattern

Minutes-long test timeouts

The test budget goes up to five minutes so slow CI stops failing. The race is still there. Any wait you also stretch, with page.setDefaultTimeout() or a timeout of 0, now hangs for minutes before the run reports anything.

// Anti-pattern: a five-minute test budget lets any stretched wait hang for minutes before failing
import { defineConfig } from '@playwright/test';

export default defineConfig({
  timeout: 300_000,
});

Fix

Set an explicit timeout on the wait, and set a page default with page.setDefaultTimeout() only for an environment that is slow across the board.

import { test, expect } from '@playwright/test';

test.beforeEach(async ({ page }) => {
  // A known-slow CI environment: sets a 20-second default for every method that takes a timeout
  if (process.env.CI) page.setDefaultTimeout(20_000);
});

test('placing an order returns the new order', async ({ page }) => {
  await page.goto('/checkout');
  const responsePromise = page.waitForResponse(
    (response) =>
      new URL(response.url()).pathname === '/api/orders' &&
      response.request().method() === 'POST',
    // Fails in the wait only if it starts with more than 15 seconds of the test budget left
    { timeout: 15_000 },
  );
  await page.getByRole('button', { name: 'Place order' }).click();
  const response = await responsePromise;
  expect(response.status()).toBe(201);
});

page.setDefaultTimeout changes the default maximum time for every method that accepts a timeout option. Don't use it to rescue one slow endpoint; give that endpoint its own per-call timeout.

Cancelling a wait

The signal option takes an AbortSignal. If the signal is aborted, the wait stops and the call throws an error. That is how you give up on a response once another branch of the test has made it irrelevant.

Debugging with Trace Viewer

A timeout tells you the wait missed. The trace tells you whether it lost the race or never had a match.

On CI, the Trace Viewer docs recommend recording traces on the first retry of a failed test with trace: 'on-first-retry', which produces a trace.zip for each test that was retried.

import { defineConfig } from '@playwright/test';

export default defineConfig({
  // on-first-retry records nothing without at least one retry
  retries: process.env.CI ? 2 : 0,
  use: {
    trace: 'on-first-retry',
  },
});

The Network tab

The Network tab lists every network request the test made, and you can sort by type, status code, method, request, content type, duration and size. Sort by method to find the call, then read its status and duration. Our trace viewer guide shows how to narrow the Network tab to the failing action when the run made a lot of calls.

The wait's span

A maintainer replied in #34373 that actions such as the response wait are displayed in the trace viewer and the HTML report at the point where they are initiated, not where they are awaited. The issue was closed as not planned. The maintainer explained that this placement lets the timeline show when each action started and ended. That makes the start of the span a clock for the race:

  • If the response finished before the span begins, the wait started late. Move it above the click.
  • If the response arrived after the span began and the wait still timed out, the matcher is wrong. Compare the request's URL and method with your glob or predicate.
  • If the request isn't in the Network tab at all, the click never sent it. If you use page.route() or browserContext.route() and network events appear to be missing, Playwright's Network guide says to disable Service Workers by setting serviceWorkers to 'block' (see the gotchas below).

A reusable waitForApi helper

Once every wait needs a method, a pathname and a timeout, copy-pasted predicates drift apart. Put the matcher in one function and keep the order in the caller.

Anti-pattern

A helper that awaits

The helper waits, parses and returns the body, so it reads like a one-liner. Every call site then awaits it on the line after the action, which brings back the click-then-wait race in every test that uses it. It also parses before anyone checks the status.

// Anti-pattern: the helper is meant to be awaited, so every call site awaits it after the click
import { test, expect, type Page } from '@playwright/test';

async function waitForApi(page: Page, path: string) {
  const response = await page.waitForResponse((r) => new URL(r.url()).pathname === path);
  return response.json();
}

test('placing an order', async ({ page }) => {
  await page.goto('/checkout');
  await page.getByRole('button', { name: 'Place order' }).click();
  const order = await waitForApi(page, '/api/orders');
  expect(order.status).toBe('pending');
});

Fix

Return the unawaited promise. Comparing only the URL's pathname means a query string can't break the match, because query parameters are not part of the path.

// tests/helpers/wait-for-api.ts
import type { Page, Response } from '@playwright/test';

type ApiMatch = {
  method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
  path: string;
  timeout?: number;
};

// Returns the pending promise: create it before the action, await it after
export function waitForApi(
  page: Page,
  { method, path, timeout = 15_000 }: ApiMatch,
): Promise<Response> {
  return page.waitForResponse(
    (response) =>
      response.request().method() === method && new URL(response.url()).pathname === path,
    { timeout },
  );
}

The caller keeps the promise-first order: create, act, await, assert.

// tests/orders.spec.ts
import { test, expect } from '@playwright/test';
import { waitForApi } from './helpers/wait-for-api';

test('placing an order returns the new order', async ({ page }) => {
  await page.goto('/checkout');
  const orderResponse = waitForApi(page, { method: 'POST', path: '/api/orders' });
  await page.getByRole('button', { name: 'Place order' }).click();
  const response = await orderResponse;

  expect(response.ok(), `POST /api/orders answered ${response.status()}`).toBe(true);
  expect(await response.json()).toMatchObject({ status: 'pending' });
});

The helper leaves status out of its predicate on purpose. A failing response still resolves the wait and reaches the assertion, which reports the code.

Gotchas after the fix

A promise-first wait with a tight matcher can still miss for reasons outside the test.

Service workers

The symptom the Network guide describes is that network events appear to be missing while you use browserContext.route() or page.route(). If your app registers a service worker and network events go missing while you use page.route() or browserContext.route(), set serviceWorkers to 'block', the fix the Network guide gives for missing events. Try it before you rewrite a matcher that already looks right.

import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    // Block the app's service worker while chasing missing network events
    serviceWorkers: 'block',
  },
});

Flakes across many runs

One trace explains one failure. Playwright reports a test that fails on the first run but passes when retried as flaky. The next run may not fail at all. Seeing the pattern takes results from many runs, the same data you'd use to detect flaky tests from CI retries.

Currents marks a test that has retries enabled and fails its first attempt as flaky, with a dedicated badge, so a race that passes on retry stays visible. In Currents' Error Explorer, you can search for failures whose Action is waitForResponse and see which endpoints frequently break across your CI runs.

For a single test, the rule fits in one line: create the promise before the action, match the method and pathname (or the GraphQL operation), assert the status after the wait, and keep the wait's timeout under the test's. When the same endpoint keeps showing up anyway, stop tightening the wait. Mock the endpoint with page.route if the server's timing is the problem. The decision matrix for what to mock helps you pick which endpoints to fake. Assert on what the UI renders if the response was never the point.

Frequently asked questions

How do I wait for a response after a click in Playwright?

Create the promise first, act, then await the promise. Playwright's official example creates the page.waitForResponse promise before the click on purpose, and its comment says to start waiting for response before clicking, note no await. The click-then-wait race shows the broken order next to the fixed one.

Why does waitForResponse pass locally but time out in CI?

The await on the click holds the next line until the click resolves, and the wait doesn't exist until that line runs. Whether the response lands inside that gap depends on timing, and timing is exactly what changes between a laptop and a busy CI runner. Playwright's own example starts waiting for the response before clicking, so create the promise first, act, then await the promise, as the click, then wait anti-pattern shows.

Why does my waitForResponse glob pattern never match?

The glob must match the entire URL, not just part of it, so a glob written for the bare path misses once the app adds a query string.

How do I wait for a POST response in Playwright?

Pass a predicate that checks the exact pathname and the request's method. response.request() returns the matching Request, and request.method() gives its HTTP method, so a predicate that checks for POST skips a GET poll to the same path. Method and status predicates has the full example.

How do I wait for a specific GraphQL operation with waitForResponse?

Read the operation name and variables from the request body inside the predicate.

What is the default timeout for waitForResponse and how do I change it?

The waitForResponse timeout is in milliseconds and defaults to 30 seconds. Passing 0 disables it, and browserContext.setDefaultTimeout() or page.setDefaultTimeout() change the default. Give the wait an explicit timeout that runs out before the time left in the test budget, and a missed response fails in the wait itself, as timeouts on slow runners explains.

What is the difference between waitForRequest and waitForResponse?

page.waitForResponse returns the matched response, so the test can check what the server answered. When the answer doesn't matter and you only need to prove the request went out with the right method or body, use page.waitForRequest, which waits for the matching request and returns it. When waitForResponse fits shows both, plus the case where a web-first assertion is enough.

How do I debug a waitForResponse timeout in Trace Viewer?

Compare the response in the Network tab with the start of the wait's span. Actions such as the response wait are displayed in the trace viewer at the point where they are initiated, not where they are awaited. If the response finished before the span begins, the wait started late, and if it arrived after the span began and the wait still timed out, the matcher is wrong, as the wait's span explains.