Article · Network & API Mocking for Reliable Tests

Recording and Replaying HAR Files in Playwright

A HAR archive turns one run against a real API into a fixture the suite can replay forever, offline and in milliseconds. The mechanics take three lines; the reliability comes from the decisions around them — what to capture, how requests are matched on the way back, and what happens when a request arrives that the archive has never seen. This guide is the Playwright-specific half of Record & Replay HTTP Traffic.

12 sections URL: /network-api-mocking-for-reliable-tests/record-and-replay-http-traffic/recording-and-replaying-har-files-in-playwright/
Record mode versus replay mode With update true the browser talks to the real API and writes the archive; with it off, requests are served from the archive and never leave the machine. record — update: true test real staging API invoices.har written replay — update omitted test invoices.har network never reached the same test code runs in both modes — only the flag changes, which is what makes it easy to leave on by accident
One flag separates a deterministic offline suite from a suite quietly rewriting its fixtures from live traffic.

Root cause #

Tests that call a real API inherit its availability, its latency distribution and its data. A staging outage turns the whole suite red, a slow afternoon turns marginal waits into timeouts, and a colleague editing a record changes what your assertions see. Replaying a recorded archive removes all three at once, because the responses come from disk.

What makes replay subtle is matching. On the way back, Playwright has to decide which recorded entry answers an incoming request. Matching is by method and URL, so two requests that differ only in a header — an Accept-Language, a pagination cursor carried in a header, an idempotency key — look identical to the matcher and the first recorded entry answers both. That is fine when the two responses are the same and quietly wrong when they are not, which is why paginated and filtered endpoints are where replay most often goes astray.

The second subtlety is coverage. An archive answers what it recorded. A test that later exercises a new path — a different filter, an error case, a request the feature added last week — produces a request with no match, and the notFound policy decides whether that aborts loudly or falls through to the network. Falling through is convenient during development and is the main way a “fully mocked” suite ends up depending on staging without anyone realising.

Step-by-step fix #

1. Record once, with a filter #

Capture only your own API. Fonts, analytics beacons and image requests make the archive large, the diff unreviewable, and the recording slower to replay.

// tests/invoices.spec.ts
// Trade-off: a tight url filter keeps archives reviewable but means anything
// outside the filter is unhandled at replay time — decide what happens to it.
test.beforeEach(async ({ context }) => {
  await context.routeFromHAR('fixtures/invoices.har', {
    url: '**/api/**',
    update: process.env.RECORD === '1',   // never default this to true
    updateMode: 'minimal',                // store only what replay needs
    notFound: 'abort',                    // unmatched requests fail loudly
  });
});
# Record against staging, then commit the scrubbed archive.
RECORD=1 BASE_URL=https://staging.example.com npx playwright test invoices
node scripts/scrub-har.js fixtures/invoices.har

Gating on an environment variable is what stops record mode from reaching CI. A hard-coded update: true that survives review will rewrite fixtures from whatever the pipeline happened to see.

2. Make matching unambiguous #

Where an endpoint returns different bodies for different query parameters, the recording must contain each variant and the requests must differ in the URL. If your client carries the distinguishing value in a header, replay cannot tell the variants apart — move it into the query string for testability, or handle those routes with an explicit page.route instead.

// Trade-off: an explicit route for the ambiguous endpoint is more code and it
// is the only way to disambiguate when the difference is not in the URL.
await page.route('**/api/invoices*', async (route) => {
  const cursor = new URL(route.request().url()).searchParams.get('cursor');
  const file = cursor ? `fixtures/invoices-page-${cursor}.json` : 'fixtures/invoices-page-1.json';
  await route.fulfill({ path: file });
});
notFound policy decides what an uncovered request does Abort fails the test loudly; fallback lets the request reach the network and reintroduces non-determinism. request withno recorded match notFound: 'abort'test fails, gap visible notFound: 'fallback'hits the real network record the missing path green todayred when staging is down
Aborting on an uncovered request is what keeps the archive honest about what it actually covers.

3. Scope archives per feature, not per suite #

One archive per feature keeps each diff readable and stops an unrelated recording change from touching every test. It also makes a failure attributable: when invoices.har changes and invoice tests fail, the cause is one commit away.

// Trade-off: more files to maintain, in exchange for diffs a reviewer can
// actually read and failures that point at one feature.
const HARS = {
  invoices: 'fixtures/invoices.har',
  billing: 'fixtures/billing.har',
};
test.beforeEach(async ({ context }, testInfo) => {
  const har = HARS[testInfo.titlePath[0]] ?? 'fixtures/default.har';
  await context.routeFromHAR(har, { url: '**/api/**', notFound: 'abort' });
});

4. Normalise volatile values at scrub time #

Recorded payloads contain timestamps, generated ids and expiry values that were true on the day of capture. Assertions against them break as soon as the recording ages, and the reflex fix — loosening the assertion — throws away the coverage.

// scripts/normalise-har.js
// Trade-off: normalising makes recordings stable and slightly less faithful;
// keep the shape and the types, change only the volatile values.
for (const entry of har.log.entries) {
  if (!entry.response.content.text) continue;
  entry.response.content.text = entry.response.content.text
    .replace(/"createdAt":"[^"]+"/g, '"createdAt":"2026-08-02T12:00:00.000Z"')
    .replace(/"requestId":"[^"]+"/g, '"requestId":"req_fixed_0001"');
}

Pair the normalised timestamps with a frozen clock in the test so relative rendering stays deterministic too.

5. Combine replay with targeted overrides #

Replay covers the realistic path; error cases are easier to express as explicit routes on top. Register the specific route after the HAR so it takes precedence.

// Trade-off: mixing sources means two places to look when a response is
// surprising; the alternative is recording error states, which is harder.
await context.routeFromHAR('fixtures/invoices.har', { url: '**/api/**', notFound: 'abort' });
await page.route('**/api/invoices/INV-1/pay', (route) =>
  route.fulfill({ status: 402, json: { error: 'card_declined' } })
);

6. Verify replay never touches the network #

The guarantee is worth asserting. A single test that fails if any request escapes the archive turns an assumption into a check.

// Trade-off: one extra listener per test run, and it is what proves the suite
// is genuinely offline rather than merely believed to be.
const escaped = [];
page.on('request', (r) => {
  if (!r.url().startsWith('http://localhost') && !r.url().includes('/api/')) escaped.push(r.url());
});
// …run the flow…
expect(escaped, `unexpected live requests: ${escaped.join(', ')}`).toHaveLength(0);

Pitfalls #

  • Leaving update: true in the committed config. CI overwrites fixtures from live traffic and determinism is gone. Mitigation: gate it behind an environment variable that CI never sets.
  • notFound: 'fallback' in CI. Uncovered requests reach the network silently. Mitigation: abort, and record the missing path deliberately.
  • Recording third-party traffic. Archives balloon and contain vendor tokens. Mitigation: filter by URL at capture time.
  • Ambiguous matching on paginated endpoints. The first recorded entry answers every page. Mitigation: keep distinguishing values in the URL, or use explicit routes.
  • Asserting on recorded timestamps. The test breaks as the recording ages. Mitigation: normalise volatile fields and freeze the clock.
  • One archive shared by the whole suite. Every test depends on every recording and diffs are unreadable. Mitigation: one archive per feature.
  • Never re-recording. Green tests against an API version that no longer exists. Mitigation: a scheduled re-record with a structural diff.
Layering explicit routes on top of an archive The archive answers the realistic path while explicit routes registered afterwards cover error cases. explicit page.route — registered last, matched first (error and edge cases) routeFromHAR — realistic recorded payloads for everything else anything neither layer handles aborts, so coverage gaps surface as failures rather than live calls
Two layers with an abort underneath gives realism, intent and a guarantee at the same time.

Reliability targets #

Metric Target Notes
Live requests during a replay run 0 Asserted by the escape check
Archives per feature 1 Reviewable diffs, attributable failures
Archive size < 300 KB Achieved with updateMode: 'minimal' and a URL filter
Recording age < 30 days Maintained by the scheduled re-record
Unmatched requests 0 notFound: 'abort' makes this enforceable
HAR replay scorecard Targets for live requests, archive size, recording age and unmatched requests. 0live requests < 300 KBper archive < 30 drecording age 0unmatched
Zero live requests is the property that makes the suite immune to a staging outage.

Frequently Asked Questions #

Q: Why does replay return the wrong page of results? A: Matching found an earlier recorded entry for the same method and URL. If your pagination cursor travels in a header rather than the query string, every page looks identical to the matcher. Move the cursor into the URL, or handle that endpoint with an explicit route that reads the request and picks a fixture.

Q: Can I record while the tests run in CI and commit the result automatically? A: You can, and it undermines the point. An automatically updated fixture means the expected values change whenever the API changes, so the suite can never detect a contract break — it simply adopts it. Re-record on a schedule and require a human to accept the diff.

Q: What should happen when a new feature adds a request the archive lacks? A: The test should fail with an abort, which is the signal to re-record that feature’s archive. That is the workflow functioning correctly: coverage gaps surface immediately rather than as a live call that works on someone’s machine.

Q: How do I record traffic for a flow that requires signing in? A: Record the authenticated flow with a test account on staging, then scrub the credentials from the archive before committing. On replay the login exchange is served from the recording like any other request, so the suite needs no real credentials at all — which is usually a security improvement as well as a reliability one. Keep the session-seeding approach consistent with Clearing Browser Storage Between Tests so the replayed session does not leak between tests.

Q: Is a HAR archive the right place for error-case coverage? A: Rarely. Producing a 500 or a rate-limit response from a real staging API on demand is awkward, and the recorded result is opaque to a reader. Express error cases as explicit routes with a small literal body — the intent is visible in the test, which is the property that matters most for an error path.