Network-First Patterns Explained
Network-First Patterns Explained
Section titled “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 enoughawait page.click('button');await page.waitForTimeout(3000);await expect(page.locator('.success')).toBeVisible();
// ✅ Network-first: wait exactly as long as the API takesconst responsePromise = page.waitForResponse((resp) => resp.url().includes('/api/submit') && resp.ok());await page.click('button');await responsePromise;await expect(page.locator('.success')).toBeVisible();Why Hard Waits Fail
Section titled “Why Hard Waits Fail”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 assertiontest('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?
Intercept, Act, Await
Section titled “Intercept, Act, Await”Set up the wait before triggering the action.
const promise = page.waitForResponse(matcher); // 1. Intercept: starts listening immediatelyawait page.click('button'); // 2. Act: triggers the requestawait promise; // 3. Await: resolves on the actual responseReverse 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 Playwrighttest('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 Utilsimport { 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.
What Playwright Utils Adds
Section titled “What Playwright Utils Adds”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:
- Automatic JSON parsing. No
await response.json()anywhere. - Different result shapes for different utilities.
interceptNetworkCallresolves to{ status, responseJson, requestJson }because it observes a browser round trip and can see both directions.apiRequestresolves to{ status, body }because it issues the request itself. - Glob matching. Use
url: '**/api/users'. - One declarative call. Setup and wait are the same expression, and the fixture injects
page. - Automatic retry.
apiRequestretries 5xx with exponential backoff; 4xx fails immediately. Disable withretryConfig: { maxRetries: 0 }when the error itself is what you are testing. - Schema validation.
validateSchemaas a parameter, or.validateSchema(Schema)chained. Accepts JSON Schema, Zod, YAML files, and OpenAPI specs, and throws with detailed errors on mismatch. - Managed HAR recording.
networkRecorderhandles HAR naming and paths, detects CRUD operations for stateful mocking, and switches between record and playback from an environment variable.
Setup: Integrate Playwright Utils.
Matcher Variations
Section titled “Matcher Variations”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 navigatetest('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 responseconst promise = page.waitForResponse((resp) => resp.ok());
// Specific endpointconst promise = page.waitForResponse((resp) => resp.url().includes('/api/users/123'));
// POST returning 201const 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 firstconst [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);});Stubbing Responses
Section titled “Stubbing Responses”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 stepstest('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');});HAR Recording for Offline Testing
Section titled “HAR Recording for Offline Testing”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});# Capture responses with the backend runningPW_NET_MODE=record npx playwright test
# Play the recording back with no backend runningPW_NET_MODE=playback npx playwright test// Vanilla: name the HAR file and flip `update` by hand, per testtest('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});”I Already Use waitForSelector”
Section titled “”I Already Use waitForSelector””// Wait for the success elementawait 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();How TEA Applies This
Section titled “How TEA Applies This”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 actionRelated
Section titled “Related”- Test Quality Standards: the current criteria and deduction ledger
- Fixture Architecture: how network utilities become fixtures
- Integrate Playwright Utils: installation and configuration
- How to Run Test Review: finding hard waits in an existing suite
- Knowledge Base Index: the network-first and intercept-network-call fragments