Network-First Patterns Explained
Network-First Patterns Explained
Section titled âNetwork-First Patterns ExplainedâNetwork-first patterns are TEAâs answer to flakiness. The UI changes because an API responded, so wait for the API response rather than guessing at a timeout.
// â 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.
Navigation has the same problem in a sharper form:
// â Navigate-then-assert race conditiontest('should load dashboard data', async ({ page }) => { await page.goto('/dashboard'); // navigation starts // Page loads HTML, JavaScript requests /api/dashboard, and this assertion // runs before the response arrives. It fails intermittently. await expect(page.locator('.data-table')).toBeVisible();});The counter-argument that tests are fast enough locally does not survive contact with a different environment, an API under load, network variability, or a suite growing from 100 tests to 1000. Network-first prevents all four before they appear, and the investment is roughly thirty minutes to learn against the hundreds of hours a flaky suite costs in debugging and lost trust.
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 endApplied to the racing dashboard test above:
// â
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 exactly as long as needed, whether that is 100 ms or 5 seconds, and behave the same locally, in CI, and against staging.
What Playwright Utils Adds
Section titled âWhat Playwright Utils Addsâ@seontechnologies/playwright-utils is optional as a choice, and binding once chosen. tea_use_playwright_utils defaults to true, and while it is true and the package is installed the second form above is what TEA generates and what test-review expects. Both halves are required: a flag with no install generates the vanilla form and produces one setup recommendation rather than findings. page.route on an application endpoint becomes a finding unless the code says why. It stays correct for what it is genuinely for: blocking analytics, fonts, and third-party scripts, and that needs no justification. Where the utility genuinely does not cover a case, the vanilla call ships with a // playwright-utils deviation: <reason> comment on the line and an entry in the workflowâs summary, which is what separates a decision from an oversight. The full rule is the playwright-utils-mandate knowledge fragment.
Seven things the utility changes:
- Automatic JSON parsing. No
await response.json()anywhere. - Different result shapes for different utilities, and the distinction matters.
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.
url: '**/api/users'instead of aresp.url().includes(...)predicate or a regex. - One declarative call. Setup and wait are the same expression, and the fixture injects
page, so you never pass it. - 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 { test } from '@seontechnologies/playwright-utils/intercept-network-call/fixtures';
// Any responseconst anyCall = interceptNetworkCall({ url: '**' });
// Specific endpointconst userCall = interceptNetworkCall({ url: '**/api/users/123' });
// Method plus endpoint; assert the status you expect rather than filtering on itconst createCall = interceptNetworkCall({ method: 'POST', url: '**/api/users' });const { status, responseJson } = await createCall;expect(status).toBe(201);
// 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:
// 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' }), }); });
await page.goto('/users');
const response = await page.waitForResponse('**/api/users'); 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';
// Record modeprocess.env.PW_NET_MODE = 'record';
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});# 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ââ// Still a guess, just a differently shaped oneawait page.click('button');await page.waitForSelector('.success', { timeout: 5000 });It waits on the DOM, which is the effect, and gives up after an arbitrary ceiling. Wait on the cause first, then check the effect:
await page.waitForResponse(matcher);await page.waitForSelector('.success');How TEA Applies This
Section titled âHow TEA Applies Thisâatdd and automate generate network-first tests by default, in whichever form the project is configured for. test-review flags every waitForTimeout as a Critical determinism violation with the network-first replacement attached:
## Critical Issue: Hard Wait Detected
**File:** tests/e2e/submit.spec.ts:45**Issue:** Using `page.waitForTimeout(3000)`**Severity:** Critical (causes flakiness)**Fix:** Replace with `page.waitForResponse(matcher)` set up before the actionRelated
Section titled âRelatedâ- Test Quality Standards - the scoring rubric this rule is worth 15 points in
- 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