How to fix Playwright race conditions when `waitForResponse` misses requests?

Playwright

waitForResponse misses requests for exactly the same reason waitForEvent('download') and waitForEvent('popup') do — the wait is registered after the action that triggers the request. For fast endpoints that respond in under 50ms, the response can arrive before the waitForResponse listener is set up, and the resulting promise hangs until timeout. This is a consistent source of flakiness on fast CI machines or locally against mocked APIs.

Common mistake

test('search returns results', async ({ page }) => {
  await page.goto('https://app.example.com/search');
  await page.getByRole('searchbox').fill('playwright');

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

  // Race condition: response may arrive before this line executes
  const response = await page.waitForResponse('**/api/search**');
  expect((await response.json()).results.length).toBeGreaterThan(0);
});

The fix

Create the waitForResponse promise before triggering the action:

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

test('search returns product results', async ({ page }) => {
  await page.goto('https://app.example.com/search');
  await page.getByRole('searchbox').fill('wireless headphones');

  // Register response wait BEFORE clicking
  const searchResponsePromise = page.waitForResponse(
    (response) =>
      response.url().includes('/api/search') &&
      response.request().method() === 'GET' &&
      response.status() === 200
  );

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

  // Now safely await the already-registered promise
  const searchResponse = await searchResponsePromise;
  const data = await searchResponse.json();

  expect(data.results).toHaveLength(5);
  expect(data.results[0].name).toContain('Headphones');

  // Also confirm UI reflects the data
  await expect(page.getByRole('listitem')).toHaveCount(5);
});

For scenarios where multiple API calls fire and you need a specific one:

// Use a predicate to match the exact request you care about
const profileResponse = page.waitForResponse(
  (res) => res.url().includes('/api/profile') && res.request().method() === 'PUT'
);

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

const response = await profileResponse;
expect(response.status()).toBe(200);

For testing error states:

const errorResponse = page.waitForResponse(
  (res) => res.url().includes('/api/payment') && res.status() >= 400
);

await page.route('**/api/payment', (route) =>
  route.fulfill({ status: 422, body: JSON.stringify({ error: 'Card declined' }) })
);

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

await errorResponse;
await expect(page.getByRole('alert')).toContainText('Card declined');

Why it works

page.waitForResponse() attaches a listener to Playwright's internal network event stream before the action fires. When the browser receives the HTTP response, Playwright emits a response event and the already-registered promise resolves with the matching Response object. Using a predicate function instead of a URL glob gives you method, status, and body matching in addition to URL matching, which prevents false matches from unrelated concurrent requests such as analytics pings or health checks.

Tips

  • Prefer predicate functions over URL glob patterns for waitForResponse when the endpoint could match multiple different requests (e.g., a generic /api/** pattern that fires for several parallel calls).
  • Use response.ok() instead of response.status() === 200 when you only care that the request succeeded — it returns true for 200–299.
  • page.waitForRequest() is the complement for cases where you want to inspect the request body (e.g., verifying the correct payload was sent) rather than the response.
  • For tests that need to verify multiple sequential API calls, chain multiple waitForResponse promises and await them in order after the triggering action.