Skip to content
TEA docs in plain text: llms-full.txt

Network-First Patterns Explained

Register the response wait before the action that starts the request. Then assert the returned data and the UI state it produces.

// ❌ Traditional: hope 3 seconds is enough
await page.click('button');
await page.waitForTimeout(3000);
await expect(page.locator('.success')).toBeVisible();
// ✅ Network-first: wait exactly as long as the API takes
const responsePromise = page.waitForResponse((resp) => resp.url().includes('/api/submit') && resp.ok());
await page.click('button');
await responsePromise;
await expect(page.locator('.success')).toBeVisible();

A fixed timeout is wrong in both directions at once:

  • Fast network: wastes the difference on every run, multiplied by every test.
  • Slow network, CI, or load: the API takes longer than the guess and the test fails.

The usual repair makes it worse. A test fails at 2000 ms, so it goes to 5000, still fails sometimes, so it goes to 10000 and finally passes. Now every run of that test costs 10 seconds, the suite that took 5 minutes takes 30, and it is still not deterministic. It is slower and equally flaky.

A UI assertion checks the rendered result. Add response assertions when the test also needs to verify the data returned by the backend:

// UI-only assertion
test('should load dashboard data', async ({ page }) => {
await page.goto('/dashboard');
// Playwright retries this assertion until the table is visible or it times out.
// This test does not check which data the API returned.
await expect(page.locator('.data-table')).toBeVisible();
});

Response waits adapt to API timing in local and CI runs. They also give failures a useful boundary: did the request return the expected data, and did the UI render it?

Set up the wait before triggering the action.

const promise = page.waitForResponse(matcher); // 1. Intercept: starts listening immediately
await page.click('button'); // 2. Act: triggers the request
await promise; // 3. Await: resolves on the actual response

Reverse steps 1 and 2 and the response can arrive before the listener exists, at which point the test hangs until timeout.

%%{init: {'theme':'base', 'themeVariables': { 'fontSize':'14px'}}}%%
sequenceDiagram
    participant Test
    participant Playwright
    participant Browser
    participant API

    rect rgb(200, 230, 201)
        Note over Test,Playwright: ✅ CORRECT: Intercept First
        Test->>Playwright: 1. waitForResponse(matcher)
        Note over Playwright: Starts listening for response
        Test->>Browser: 2. click('button')
        Browser->>API: 3. POST /api/submit
        API-->>Browser: 4. 200 OK {success: true}
        Browser-->>Playwright: 5. Response captured
        Test->>Playwright: 6. await promise
        Playwright-->>Test: 7. Returns response
        Note over Test: No race condition!
    end

    rect rgb(255, 205, 210)
        Note over Test,API: ❌ WRONG: Action First
        Test->>Browser: 1. click('button')
        Browser->>API: 2. POST /api/submit
        API-->>Browser: 3. 200 OK (already happened!)
        Test->>Playwright: 4. waitForResponse(matcher)
        Note over Test,Playwright: Too late: response already occurred
        Note over Test: Race condition! Test hangs or fails
    end

Adding response checks to the dashboard test:

// ✅ Vanilla Playwright
test('should load dashboard data', async ({ page }) => {
const dashboardPromise = page.waitForResponse((resp) => resp.url().includes('/api/dashboard') && resp.ok());
await page.goto('/dashboard');
const response = await dashboardPromise;
const { items } = await response.json();
expect(items).toHaveLength(5); // validate the API: catches backend errors
await expect(page.locator('.data-table')).toBeVisible();
await expect(page.locator('.data-table tr')).toHaveCount(items.length); // validate UI against API: catches frontend bugs
});
// ✅ Same test with Playwright Utils
import { test } from '@seontechnologies/playwright-utils/intercept-network-call/fixtures';
import { expect } from '@playwright/test';
test('should load dashboard data', async ({ page, interceptNetworkCall }) => {
const dashboardCall = interceptNetworkCall({
method: 'GET',
url: '**/api/dashboard',
});
await page.goto('/dashboard');
const {
status,
responseJson: { items },
} = await dashboardCall; // already parsed, no resp.ok() check needed
expect(status).toBe(200);
expect(items).toHaveLength(5);
await expect(page.locator('.data-table')).toBeVisible();
await expect(page.locator('.data-table tr')).toHaveCount(items.length);
});

Both forms wait for the matching response, then use retrying assertions to check the UI.

tea_use_playwright_utils defaults to true. When the flag is enabled and the package is installed, TEA generates the utility form and test-review checks for bypasses. An enabled flag with no package produces one setup recommendation. Use page.route directly for analytics, fonts, and third-party scripts. For an application endpoint the utility cannot handle, add // playwright-utils deviation: <reason> on the call and record the reason in the skill summary. The playwright-utils-mandate fragment defines the rule.

Seven things the utility changes:

  1. Automatic JSON parsing. No await response.json() anywhere.
  2. Different result shapes for different utilities. interceptNetworkCall resolves to { status, responseJson, requestJson } because it observes a browser round trip and can see both directions. apiRequest resolves to { status, body } because it issues the request itself.
  3. Glob matching. Use url: '**/api/users'.
  4. One declarative call. Setup and wait are the same expression, and the fixture injects page.
  5. Automatic retry. apiRequest retries 5xx with exponential backoff; 4xx fails immediately. Disable with retryConfig: { maxRetries: 0 } when the error itself is what you are testing.
  6. Schema validation. validateSchema as a parameter, or .validateSchema(Schema) chained. Accepts JSON Schema, Zod, YAML files, and OpenAPI specs, and throws with detailed errors on mismatch.
  7. Managed HAR recording. networkRecorder handles HAR naming and paths, detects CRUD operations for stateful mocking, and switches between record and playback from an environment variable.

Setup: Integrate Playwright Utils.

interceptNetworkCall narrows by any combination of method, URL glob, and observed status.

import { expect } from '@playwright/test';
import { test } from '@seontechnologies/playwright-utils/intercept-network-call/fixtures';
// `url: '**'` matches any response; `url: '**/api/users/123'` matches one endpoint.
// Both use the call shape below: register first, act, then await.
test('method plus endpoint, asserting the status', async ({ page, interceptNetworkCall }) => {
const createCall = interceptNetworkCall({ method: 'POST', url: '**/api/users' });
await page.goto('/users/new');
await page.getByRole('button', { name: 'Create user' }).click();
const { status, responseJson } = await createCall;
expect(status).toBe(201);
expect(responseJson).toHaveProperty('id');
});
// Multiple calls from one navigation: intercept both, then navigate
test('multiple responses', async ({ page, interceptNetworkCall }) => {
const usersCall = interceptNetworkCall({ url: '**/api/users' });
const postsCall = interceptNetworkCall({ url: '**/api/posts' });
await page.goto('/dashboard'); // triggers both
const [{ responseJson: users }, { responseJson: posts }] = await Promise.all([usersCall, postsCall]);
expect(users).toBeInstanceOf(Array);
expect(posts).toBeInstanceOf(Array);
});

The vanilla equivalents, for projects not using the utilities, are separate alternatives; register each promise before the action that triggers the request:

// Any successful response
const promise = page.waitForResponse((resp) => resp.ok());
// Specific endpoint
const promise = page.waitForResponse((resp) => resp.url().includes('/api/users/123'));
// POST returning 201
const promise = page.waitForResponse(
(resp) => resp.url().includes('/api/users') && resp.request().method() === 'POST' && resp.status() === 201,
);
// Multiple calls: the navigation goes inside Promise.all, so the listeners exist first
const [usersResp, postsResp] = await Promise.all([
page.waitForResponse((resp) => resp.url().includes('/api/users')),
page.waitForResponse((resp) => resp.url().includes('/api/posts')),
page.goto('/dashboard'),
]);
const users = await usersResp.json();
const posts = await postsResp.json();

Validating the response before asserting on the UI is the point of all of these. It separates “the backend returned the wrong thing” from “the frontend rendered the right thing wrongly”, which a UI-only assertion cannot do:

test('validate response data', async ({ page, interceptNetworkCall }) => {
const checkoutCall = interceptNetworkCall({ method: 'POST', url: '**/api/checkout' });
await page.click('button:has-text("Complete Order")');
const { status, responseJson: order } = await checkoutCall;
expect(status).toBe(200);
expect(order.status).toBe('confirmed');
expect(order.total).toBeGreaterThan(0);
await expect(page.locator('.order-confirmation')).toContainText(order.id);
});

Set the stub up before navigation, same ordering rule.

import { test } from '@seontechnologies/playwright-utils/intercept-network-call/fixtures';
test('should handle API error', async ({ page, interceptNetworkCall }) => {
const usersCall = interceptNetworkCall({
method: 'GET',
url: '**/api/users',
fulfillResponse: {
status: 500,
body: { error: 'Internal server error' },
},
});
await page.goto('/users');
const { status, responseJson } = await usersCall; // stub and wait are one call
expect(status).toBe(500);
expect(responseJson.error).toContain('Internal server');
await expect(page.locator('.error-message')).toContainText('Server error');
});
// Vanilla: route setup and response wait are two separate steps
test('should handle API error', async ({ page }) => {
await page.route('**/api/users', (route) => {
route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Internal server error' }),
});
});
const responsePromise = page.waitForResponse('**/api/users');
await page.goto('/users');
const response = await responsePromise;
const error = await response.json();
expect(error.error).toContain('Internal server');
await expect(page.locator('.error-message')).toContainText('Server error');
});
import { test } from '@seontechnologies/playwright-utils/network-recorder/fixtures';
test('should work offline', async ({ page, context, networkRecorder }) => {
await networkRecorder.setup(context); // HAR naming and paths handled for you
await page.goto('/dashboard');
await page.click('#add-item'); // CRUD operations detected and replayed statefully
});
Terminal window
# Capture responses with the backend running
PW_NET_MODE=record npx playwright test
# Play the recording back with no backend running
PW_NET_MODE=playback npx playwright test
// Vanilla: name the HAR file and flip `update` by hand, per test
test('offline testing - RECORD', async ({ page, context }) => {
await context.routeFromHAR('./hars/dashboard.har', { url: '**/api/**', update: true });
await page.goto('/dashboard');
});
test('offline testing - PLAYBACK', async ({ page, context }) => {
await context.routeFromHAR('./hars/dashboard.har', { url: '**/api/**', update: false });
await page.goto('/dashboard'); // uses recorded responses, no backend needed
});
// Wait for the success element
await page.click('button');
await page.waitForSelector('.success', { timeout: 5000 });

A DOM wait is valid when visibility is the behavior under test. When the test needs to verify the network response, register that wait before the click:

const responsePromise = page.waitForResponse(matcher);
await page.click('button');
await responsePromise;
await expect(page.locator('.success')).toBeVisible();

Automate red and expand modes generate network-first tests by default, in whichever form the project is configured for. test-review flags hard waits under registry row H1 at HIGH severity:

## High Issue: Hard Wait Detected
**File:** tests/e2e/submit.spec.ts:45
**Issue:** Using `page.waitForTimeout(3000)`
**Severity:** HIGH (registry row H1)
**Fix:** Replace with `page.waitForResponse(matcher)` set up before the action