Why waiting for API responses matters in Playwright tests
When you automate browser interactions, you're often triggering backend API calls that the UI depends on. Clicking a button might fire a network request to save data, fetch results, or update state—but your test can't know when that request completes unless you tell it to wait. Struggling with flaky tests that sometimes pass and sometimes fail? The root cause is usually that you're asserting on the UI before the API response arrives. This guide covers the practical techniques to wait for API response in Playwright: a step-by-step guide, so your tests are reliable and fast.
waitForResponse — Start waiting before the action
The most direct way to synchronize with the network is page.waitForResponse(). This method waits for a response matching your criteria, but here's the key: you must start listening before the action that triggers the request.
The promise is created first, the action happens second, and then you await the result. If you reverse the order—clicking first, then waiting—you'll miss the response.
// ✓ Correct: wait first, act second
const responsePromise = page.waitForResponse(
r => r.url().includes('/api/orders') && r.status() === 200
);
await page.getByRole('button', { name: 'Place order' }).click();
const response = await responsePromise;
// ✗ Wrong: clicking first, then waiting (race condition)
await page.getByRole('button', { name: 'Place order' }).click();
const response = await page.waitForResponse(r => r.url().includes('/api/orders'));
In the e-commerce platform we tested last month, placing an order triggered three parallel API calls: one to charge the card, one to update inventory, and one to send a confirmation email. We filtered for the charge endpoint specifically:
const chargeResponse = page.waitForResponse(
r => r.url().includes('/api/payments/charge') && r.status() === 200
);
await page.getByRole('button', { name: 'Pay now' }).click();
await chargeResponse;
// Now we know the payment went through, safe to assert
await expect(page.getByText('Payment successful')).toBeVisible();
You can filter by URL, status code, method, or any combination. The predicate function receives the response object and should return a boolean. This is far more precise than broad network waits.
Prefer UI assertions — Usually asserting the result on screen is enough
Waiting for the API response is just the first step. In most cases, your test's real goal is to verify that the UI reflects the data correctly. After you've waited for the network call, assert on what the user sees.
This is actually more valuable than inspecting the raw API payload, because it proves the entire flow works: the request was sent, the response was received, and the application rendered the result. If the API returns 200 but the UI doesn't update, your test will catch that.
const listResponse = page.waitForResponse(
r => r.url().includes('/api/customers') && r.status() === 200
);
await page.getByRole('button', { name: 'Load customers' }).click();
await listResponse;
// Assert the UI, not the API
await expect(page.getByRole('heading', { name: 'Customers' })).toBeVisible();
await expect(page.getByText('Alice Johnson')).toBeVisible();
await expect(page.getByText('Bob Smith')).toBeVisible();
In a SaaS dashboard we tested, users could export reports as CSV. The export button triggered an API call that queued a background job, returned a job ID, and then the UI polled for completion. We waited for the initial response, then asserted on the "Export in progress" message that appeared:
const exportResponse = page.waitForResponse(
r => r.url().includes('/api/reports/export') && r.status() === 202
);
await page.getByRole('button', { name: 'Export as CSV' }).click();
const response = await exportResponse;
// The UI should show a loading state or success message
await expect(page.getByText('Preparing your export')).toBeVisible();
This approach also makes your tests more maintainable. If the API contract changes but the UI behavior stays the same, your tests don't break. You're testing the contract that matters: what the user experiences.
Inspect payloads — await response.json() to check data
Sometimes you do need to verify the actual response body. Use await response.json() to parse the payload and make assertions on it. This is especially useful when the response contains IDs or tokens that you need for subsequent requests.
const response = await page.waitForResponse(
r => r.url().includes('/api/auth/login') && r.status() === 200
);
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
const loginResponse = await response;
const body = await loginResponse.json();
expect(body).toHaveProperty('token');
expect(body.token).toMatch(/^eyJ/); // JWT format check
In a ride-hailing app we built, after requesting a ride, the API returned a unique ride ID that we needed to poll for status updates. We extracted it from the response:
const createRideResponse = await page.waitForResponse(
r => r.url().includes('/api/rides/create') && r.status() === 201
);
await page.getByRole('button', { name: 'Request ride' }).click();
const rideData = await createRideResponse.json();
const rideId = rideData.id;
// Use the ID for follow-up assertions or requests
await expect(page.getByText(`Ride #${rideId}`)).toBeVisible();
You can also inspect response headers, which is handy for checking cache headers, rate limits, or custom metadata:
const response = await page.waitForResponse(r => r.url().includes('/api/data'));
const headers = response.headers();
// Check for rate limit headers
console.log('Remaining requests:', headers['x-ratelimit-remaining']);
// Verify CORS or security headers
expect(headers['x-content-type-options']).toBe('nosniff');
Avoid networkidle — It is slow and unreliable for modern apps
waitForNavigation({ waitUntil: 'networkidle' }) is a legacy approach that's still tempting but problematic. Network idle means "no network activity for 500ms," which sounds safe but fails in real-world scenarios. Modern single-page applications (SPAs) often have background polling, analytics requests, or WebSocket activity that never truly goes idle.
We learned this the hard way on a trading platform where background processes continuously synced market data. Waiting for network idle would hang indefinitely or wait much longer than necessary.
// ✗ Avoid: can hang or be extremely slow
await page.goto('https://example.com', { waitUntil: 'networkidle' });
// ✗ Also problematic: waitForNavigation with networkidle
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.waitForNavigation({ waitUntil: 'networkidle' });
// ✓ Better: target the specific API you care about
const dashboardResponse = page.waitForResponse(
r => r.url().includes('/api/dashboard/data')
);
await page.getByRole('link', { name: 'Dashboard' }).click();
await dashboardResponse;
The alternative is to be specific: wait for the exact API calls that matter for your test. If loading a page triggers five API calls, wait for the one that's critical for the next step. This is faster, more predictable, and reveals what your application actually depends on.
For navigation, waitForNavigation({ waitUntil: 'domcontentloaded' }) or 'load' are safer defaults, but even better is to wait for the specific data or UI element you need.
Common mistakes
- Creating the promise after the action. Remember:
waitForResponse()must be called before the click or navigation that triggers the request. Otherwise you get a race condition. - Filtering by URL too broadly. If you filter for
r => r.url().includes('/api'), you might catch an unrelated request and continue prematurely. Use specific endpoint names and status codes. - Not handling network errors. A response with status 500 is still a response. If you expect 200 but get 500, your test should fail loudly, not silently continue.
- Assuming the UI updates instantly. Even after the API response arrives, the browser might need to render. Chain with
expect(...).toBeVisible()to ensure the UI is actually updated before moving on. - Chaining multiple waits without context. If you're waiting for five API calls in sequence, consider breaking the test into smaller, focused scenarios. Complex request chains are hard to debug.
Key takeaways
- Use
page.waitForResponse()with a predicate function to wait for specific API calls. Start listening before the action that triggers the request. - Filter by URL, status code, or method to target the exact endpoint you care about. Avoid broad, vague patterns.
- After waiting for the response, assert on the UI to verify the end-to-end flow works. This is more valuable than checking the raw payload.
- When you do need the response body, use
await response.json()to inspect data, IDs, or tokens for subsequent requests. - Avoid
waitForNavigation({ waitUntil: 'networkidle' })in modern apps. It's slow and unreliable. Target specific API calls instead. - Handle error responses explicitly. Don't assume the status is 200; check for it and let failed assertions provide clear feedback.






Comments
Loading comments…