Asserting on API Responses Alongside UI
A test that only inspects the rendered DOM cannot tell you whether the "Place order" button actually persisted an order, and a test that only calls the API cannot tell you whether the customer ever saw a confirmation. Real end-to-end coverage needs both halves in the same test: the payload the browser sent and received, and the interface the user ended up looking at. The difficulty is not fetching the response — it is that Playwright's two assertion families run on different clocks, and combining them naively produces tests that either hang on an event that already fired or read a body that has not arrived. This page shows how to sequence the two so a single test proves both the contract and the pixels.
Root cause: two assertion models in one test
expect(locator).toBeVisible() is a retrying, web-first assertion — Playwright re-evaluates it against a live DOM until it passes or expect.timeout expires. expect(body.status).toBe('confirmed') is an ordinary one-shot assertion over a value that was already decoded; it runs exactly once and cannot wait for anything. Sitting between them, page.waitForResponse() is an event subscription that only observes responses arriving after the subscription is created, so awaiting a click before subscribing means the response you want has already been dispatched and the wait can never resolve. Nearly every failure in a mixed UI/API test traces back to one of those three behaviours being confused for another.
Three distinct tools cover three distinct moments, and picking the wrong one is what produces flaky results. page.waitForResponse() passively observes traffic the page generates on its own, so it must be armed ahead of the action that triggers it. page.request and the standalone request fixture actively issue traffic of their own, so they can be called at any point but never see what the browser did. The retrying matchers documented under Web-First Assertions observe the DOM and nothing else. None of them is a synchronisation primitive for the others, which is also why waitForLoadState('networkidle') is the wrong lever here — the trade-offs are laid out in Waiting for Network Idle vs Element State.
Minimal reproducible example
The test below looks reasonable and fails in two different ways depending on how fast the backend is. It is the shape most teams write first, and both of its defects come directly from the timing mismatch described above.
import { test, expect } from '@playwright/test';
test('checkout writes the order and shows a confirmation', async ({ page }) => {
await page.goto('/cart');
// ANTI-PATTERN 1: the click is fully awaited before the listener exists.
// click() resolves after the event handler runs, so a fast backend can
// deliver the response before waitForResponse() ever subscribes, and the
// test dies with:
// Timeout 30000ms exceeded while waiting for event "response"
await page.getByRole('button', { name: 'Place order' }).click();
const response = await page.waitForResponse('**/api/orders');
// ANTI-PATTERN 2: a bare glob matches ANY /api/orders traffic, including
// the GET that repopulates the list after the POST, so the body asserted
// below may belong to a different request entirely.
const body = await response.json();
// ANTI-PATTERN 3: this is a one-shot assertion. It never retries, and if
// the endpoint returned 500 with an HTML error page, json() has already
// thrown before expect() is reached.
expect(body.status).toBe('confirmed');
await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
});
Step-by-step fix
- Create the response promise before the action, and await both together. Assign
page.waitForResponse(...)to a variable without awaiting it, perform the click, then await the promise (or await both in aPromise.all). The subscription is installed synchronously when the function is called, so any response arriving from that moment on is captured — this is the single change that removes the "waiting for event" timeout. - Match with a predicate, not a bare URL glob. Pass a function that inspects
response.url(),response.request().method()andresponse.status()so the promise resolves on the exact exchange you mean. A glob such as**/api/ordersmatches every verb and every status on that path, which is how tests end up asserting against the wrong payload after a background refetch. - Assert the transport layer before you parse. Call
await expect(response).toBeOK()first. That matcher passes for any status in the 200–299 range and, on failure, prints the status, the URL and a truncated body — a far better diagnostic than a JSON parse error thrown three lines later. Getting this order right converts an obscureSyntaxErrorinto a readable HTTP failure. - Decode the body once into a typed variable.
await response.json()consumes and caches the payload; assign it to aconstwith an explicit interface and assert named fields (expect(order.status).toBe('confirmed'),expect(order.lineItems).toHaveLength(2)). Asserting whole objects withtoEqualcouples the test to every field the backend may add later. - Assert the UI with retrying matchers on the same fact. Follow the payload assertions with
await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible()and, where the payload drives visible text,toHaveText()fed by the decoded value. These matchers auto-wait, so they absorb the render delay between the response landing and the component re-rendering. - Verify persisted state out of band with
page.request. After the UI settles, issue an independentpage.request.get('/api/orders/' + order.id)— theAPIRequestContextexposed on the page shares the browser context's cookies andstorageState, so an authenticated session carries over without a second login. This proves the record survived the round trip rather than merely appearing in an optimistic UI update. - Wrap each half in
test.step()and keep tracing on. Naming the steps "assert API contract" and "assert rendered confirmation" makes the Playwright HTML report and trace show at a glance which side broke, which matters when the same test guards a backend contract and a frontend template.
Applying all seven steps produces a test where each assertion has an unambiguous owner. The response promise is created first, the predicate pins the exact exchange, and the UI matchers run last against state that is already known to be correct on the wire.
import { test, expect, type Response } from '@playwright/test';
interface Order { id: string; status: string; lineItems: Array<{ sku: string }>; }
test('checkout persists the order and renders the confirmation', async ({ page }) => {
await page.goto('/cart');
// Step 1 + 2: subscribe BEFORE acting, and pin the exact exchange by
// path, verb and status so a background GET cannot satisfy the wait.
const orderPosted = page.waitForResponse((res: Response) =>
res.url().includes('/api/orders') &&
res.request().method() === 'POST' &&
res.status() === 201);
await test.step('assert API contract', async () => {
await page.getByRole('button', { name: 'Place order' }).click();
const response = await orderPosted;
// Step 3: fail on the status line, not on a JSON parse error.
await expect(response).toBeOK();
// Step 4: decode once, assert named fields rather than whole objects.
const order: Order = await response.json();
expect(order.status).toBe('confirmed');
expect(order.lineItems).toHaveLength(2);
// Step 6: independent read-back over the context's own cookies.
const readBack = await page.request.get(`/api/orders/${order.id}`);
await expect(readBack).toBeOK();
expect((await readBack.json()).status).toBe('confirmed');
});
// Step 5: retrying matchers absorb the gap between response and render.
await test.step('assert rendered confirmation', async () => {
await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
await expect(page.getByTestId('order-total')).toHaveText('$84.00');
});
});
Troubleshooting variants
Timeout 30000ms exceeded while waiting for event "response"
The subscription was installed after the response arrived, or the predicate never returned true. Check the ordering first — if any await sits between the action and waitForResponse(), the race is already lost. If the ordering is correct, the predicate is the suspect: log every candidate with a temporary page.on('response', r => console.log(r.request().method(), r.status(), r.url())) and compare against your conditions. Redirect hops, absolute versus relative URLs, and a preflight OPTIONS request that your method() === 'POST' check correctly rejects are the usual mismatches. Note that waitForResponse() honours the navigation timeout, not expect.timeout, so raise it per call with the { timeout } option rather than in expect configuration. The trace's network panel, covered in Reading Network and Console Tabs in Traces, lists every exchange the page made and is the quickest way to see what your predicate was offered.
SyntaxError: Unexpected token '<' is not valid JSON
response.json() throws when the body is not JSON, and the most common cause is an error page: a 500 rendering an HTML template, a 401 redirecting to a login form, or a proxy returning a plain-text gateway message. Calling await expect(response).toBeOK() first turns this into a readable status failure. A second cause is a redirect itself — asking for the body of a 301 or 302 raises Response body is unavailable for redirect responses, so either follow the chain and match on the final 200 or filter redirects out of the predicate. When debugging, read await response.text() instead of json() and print the first few hundred characters; that immediately distinguishes an HTML error page from a truncated or empty payload. If the endpoint requires a session your test never established, review Reusing Login State with storageState.
The payload is correct but the UI assertion still times out
This is the healthy failure — the contract held and the frontend did not render it, which is exactly the bug a combined test exists to catch. Before filing it, rule out three test-side causes. First, the component may key off a second request that your test never waited for, so the heading appears only after a follow-up fetch resolves; add a second response promise rather than a fixed delay. Second, the assertion may target the wrong element after an optimistic update is rolled back, which shows in the trace as text appearing and then disappearing. Third, an in-flight route handler from Mocking API Responses with Playwright may be fulfilling the request with a fixture whose shape no longer matches what the component reads. If none apply, the defect is real. For values that converge over a second or two — a total recalculated by a worker, for example — reach for await expect.poll(async () => (await page.request.get('/api/orders/' + id)).status()).toBe(200) instead of relaxing the DOM assertion.
The same endpoint is called several times and the wrong one is captured
Retries, React strict-mode double effects, polling widgets and cache revalidation all produce repeat calls to one path, and a predicate that only checks the URL resolves on whichever arrives first. Tighten the predicate until it identifies a single exchange: add the method, add the expected status, and where the distinguishing detail is in the payload, inspect response.request().postDataJSON() inside the predicate itself, since the callback receives the full Response object and can reach back to its request. If several calls are legitimately identical and you want the last one, collect them with page.on('response', ...) into an array, drive the interaction, then assert on the final entry rather than trying to express "last" as a predicate. A recorded fixture removes the ambiguity entirely for read-only endpoints — see Recording and Replaying HAR Files — because a replayed exchange is deterministic in both count and content.
Verification
Prove the combined test is honest rather than accidentally passing. Start by breaking the backend on purpose: intercept the call with page.route() and fulfil it with { status: 500, body: '{}' } using the technique from Intercepting and Modifying Network Requests, then confirm the test fails at toBeOK() with the status printed — if it instead fails on a missing heading, your API half is not actually running. Next, break the template: fulfil the call with a valid payload but change the field the heading renders, and confirm the failure now lands in the UI step. Two deliberate breaks, two distinct failure locations, means the halves are genuinely independent.
Then check the sequencing under load. Run the spec with npx playwright test --repeat-each=20 --workers=4; response-subscription races are timing-dependent and surface far more reliably under contention than in a single run, a pattern explored in Detecting and Fixing Flaky Playwright Tests. Finally open the trace with npx playwright show-trace and read the network tab: you should see exactly one POST /api/orders matched by your predicate, with the test.step() boundaries showing the API assertions completing before the DOM assertions begin. When the ordering in the trace matches the ordering in your source, the test is measuring what you think it is.
One last check protects the suite over time. Because the payload assertions encode a contract, they should fail loudly when the backend changes shape rather than silently skipping — so point the spec at a build with a renamed field and confirm it reports a value mismatch instead of undefined quietly satisfying a loose comparison. Assertions written as expect(order.status).toBe('confirmed') do this correctly; assertions written as expect(order.status === 'confirmed').toBeTruthy() do not, because a missing field and a wrong value collapse into the same unhelpful "expected true, received false" message. Prefer matchers that print both sides of the comparison, and give any check you repeat across several specs a name of its own using the approach in Writing Custom expect Matchers.
Frequently Asked Questions
Should I assert on the API instead of the UI to make tests faster?
No — replace neither half, and choose deliberately per behaviour. API-only checks are fast and precise but pass happily while the frontend renders nothing; DOM-only checks prove the user experience but leave you guessing whether a green screen came from real data or an optimistic update. Keep the payload assertions for the contract and the retrying matchers for what the user sees, and push exhaustive field-level validation of the endpoint into a dedicated API spec using the request fixture so the browser test stays focused on the integration between the two.
What is the difference between page.request and the request fixture?
page.request is an APIRequestContext bound to that page's browser context, so it inherits the context's cookies, origins and storageState — ideal for reading back a record as the logged-in user immediately after a UI action. The standalone request fixture creates an isolated context with no browser attached; it is faster and better suited to setting up or tearing down fixture data, but it does not share the session unless you configure storageState on it explicitly. Mixing them up is why a read-back that works in the browser returns 401 from the fixture.
Why doesn't expect() on a decoded body retry like a locator assertion?
Retrying requires something Playwright can re-query. A locator is a description of how to find an element, so the matcher can re-run the query against the live page every 100 milliseconds. A decoded JSON object is an immutable snapshot in your test process — re-running the comparison would produce an identical result forever. When you genuinely need a retrying check against a server value, wrap the fetch itself in expect.poll(async () => ...), which re-invokes the callback on the same retry schedule the locator matchers use.
Can I assert on the request payload as well as the response?
Yes, and it is often the more valuable assertion. From a captured response, response.request().postDataJSON() returns the parsed body the browser sent, letting you verify that a form serialised the right fields, that a debounced search sent one query rather than five, or that a tracking call carried the correct identifier. You can also capture the request directly with page.waitForRequest() using the same subscribe-before-acting rule, or inspect it inside a page.route() handler when you are already intercepting the call.