Playwright architecture, selector reliability, and advanced interaction patterns.

Recording and Replaying HAR Files

Hand-writing a stub for every endpoint a page touches stops scaling the moment a screen makes forty requests across six services. An HTTP Archive (HAR) file solves the volume problem: you drive the flow once against a real backend, Playwright writes every request and response to disk, and from then on the suite serves that traffic locally with no network involved. Playwright ships both halves of the loop natively — the recordHar context option captures, and page.routeFromHAR() or browserContext.routeFromHAR() replays. This page covers the capture options that decide what lands on disk, the matching rules that decide whether replay succeeds, and the failure modes that produce net::ERR_FAILED on a request you can plainly see inside the archive.

The record-then-replay loop for a Playwright HAR archive A recording pass writes live backend traffic into a HAR archive on close, and a later replay pass matches requests against that archive and fulfils them from disk. Record pass (runs once, against staging) Test run recordHar: { path } Live backend real responses context.close() flushes the archive checkout.har plus attached response bodies Replay pass (every CI run, offline) Test run routeFromHAR() match method and full URL fulfilled on disk no socket opened
The archive is the hinge: one recording pass produces it, and every later run reads from it instead of reaching a backend.

Root cause: a HAR is a URL-keyed lookup table, not a recording of your session

Playwright's HAR router matches an outgoing request to an archive entry on method and full URL only — the request body, headers, and ordering play no part. A recording made against https://staging.example.com therefore misses every request your CI run sends to http://localhost:3000, and a request carrying a cache-busting ?_=1721822400000 query parameter misses the entry recorded a millisecond earlier. When no entry matches and notFound is left at its default of 'abort', Playwright kills the request outright, which surfaces in the browser as net::ERR_FAILED rather than as anything mentioning HAR. Understanding that one matching rule explains almost every replay failure you will hit, and it is the same request-matching model that underpins the rest of Network Interception Basics inside Advanced Interactions & Test Assertions.

Minimal reproducible example

The test below flips between recording and replay on one environment variable, which is the shape you want in a repository: developers refresh the archive deliberately, and CI never touches the network.

import { test, expect } from '@playwright/test';

// RECORD=1 npx playwright test  -> capture live traffic into the archive
// npx playwright test           -> serve the archive from disk
const recording = Boolean(process.env.RECORD);

test('dashboard renders entirely from a recorded archive', async ({ page }) => {
  // routeFromHAR installs a route handler for every request matching `url`.
  await page.routeFromHAR('har/dashboard.har', {
    url: '**/api/**',      // only API calls are archived; JS and CSS load normally
    update: recording,     // true = rewrite the archive from live traffic
    updateMode: 'minimal', // drop timings/cookies/pages when rewriting
    updateContent: 'attach', // bodies become sidecar files, not base64 blobs
    notFound: 'abort',     // an unmatched request fails loudly instead of leaking out
  });

  await page.goto('/dashboard');

  // Both assertions depend on JSON that now comes from har/dashboard.har.
  await expect(page.getByRole('heading', { name: 'Monthly revenue' })).toBeVisible();
  await expect(page.getByTestId('revenue-total')).toHaveText('$48,120');
});

Run it with RECORD=1 once, commit har/dashboard.har and its sidecar directory, then run it again without the variable. If the widget's fetch appends a timestamp for cache busting, the second run fails with a message like page.goto: net::ERR_FAILED at http://localhost:3000/dashboard, or the heading assertion times out while the panel sits in its loading state. Nothing in the error names the archive — you have to know the matching rule to read it.

What actually lands on disk

Two capture options control the file you commit, and getting them wrong is the difference between a 40 KB archive that diffs cleanly in review and a 12 MB base64 blob that nobody can inspect. mode chooses how much HAR metadata is kept: 'minimal' (the default for updateMode) stores only what the router needs, dropping the timings, pages, and cookies blocks, while 'full' keeps the complete HAR schema so the file also opens usefully in browser DevTools or a waterfall analyser. content chooses where response bodies go: 'attach' writes each body as a separate sha1-named file beside the archive, 'embed' inlines it as base64 inside the JSON, and 'omit' discards bodies entirely.

Playwright HAR capture options compared A matrix comparing the mode and content settings of recordHar against what each writes to disk and when to choose it. Setting What lands on disk Choose it when mode: 'minimal' routing fields only default; smallest file mode: 'full' timings, cookies, sizes DevTools waterfalls content: 'attach' bodies as sidecar files reviewable diffs content: 'embed' base64 inside the .har one portable file content: 'omit' headers, no bodies shape checks only
Pick mode for how much HAR metadata survives and content for where response bodies go; the pair decides whether the committed archive stays reviewable.

One extra rule is worth memorising: if the path you pass ends in .zip, Playwright bundles the archive and all attached bodies into a single zip file, and routeFromHAR() accepts that zip directly. That is the tidiest option for a repository, because one binary file replaces a JSON document plus a directory of hash-named siblings.

Step-by-step fix

  1. Record against a pinned environment. Open a context with recordHar and drive the flow exactly as the test will, using the same origin the test will later use. Recording against staging and replaying against localhost guarantees zero matches, because the archive keys include the scheme and host.
  2. Narrow the capture with urlFilter. Pass a glob such as '**/api/**' so only the traffic you intend to control is archived. Capturing bundles, fonts, and analytics beacons inflates the file and couples it to your build hashes; if speed rather than determinism is the goal, blocking images and fonts is the better tool.
  3. Close the context to flush the archive. Playwright writes the HAR when browserContext.close() resolves. A script that calls process.exit(), or a run killed with Ctrl-C, leaves nothing on disk — this is the single most common reason the file is missing.
  4. Scrub the archive before committing it. mode: 'minimal' drops the cookies blocks, but Authorization headers, Set-Cookie response headers, and tokens echoed inside JSON bodies still land in the file. Run a sanitising script over it and treat the result as source code under review.
  5. Replay with routeFromHAR() scoped by url. Register it on the page for a single spec, or on the context inside a fixture when a whole project shares one archive. Context-level registration composes cleanly with the isolation model described in Browser Contexts & Isolation.
  6. Choose notFound deliberately. 'abort' (the default) fails an unmatched request so gaps surface immediately; 'fallback' lets it reach the real network, which is convenient while you are building the archive and dangerous in CI, where it silently reintroduces the flakiness you were removing.
  7. Layer page.route() overrides on the dynamic endpoints. Register the HAR router first, then a narrower handler for anything carrying a nonce, cursor, or timestamp. Handlers are consulted in reverse registration order, so the later, more specific one wins and can still call route.fallback() to hand back to the archive.
  8. Refresh with update: true on a schedule. Re-record on a nightly job against staging and let the diff review surface backend contract changes, rather than letting the archive drift until a real regression hides behind stale fixtures.
How Playwright resolves a request during HAR replay A decision tree showing that a later-registered route handler wins first, then the archive is matched on method and URL, and otherwise the notFound option decides between aborting and hitting the network. Browser issues request Newer route handler matches? yes that handler wins no HAR entry for method + URL? yes served from disk no notFound option abort: net::ERR_FAILED fallback: hits real network
Every replay failure traces back to one of these branches — a shadowing handler, a URL that never matched, or a notFound setting that hid the gap.

The recording half is best kept out of the test file entirely, as a script you invoke on demand:

import { chromium } from '@playwright/test';

async function record(): Promise<void> {
  const browser = await chromium.launch();
  const context = await browser.newContext({
    recordHar: {
      path: 'har/checkout.har.zip', // .zip bundles archive + bodies into one file
      mode: 'minimal',              // omit timings, pages and cookies
      content: 'attach',            // bodies stored separately, then zipped
      urlFilter: '**/api/**',       // ignore bundles, fonts and analytics
    },
  });
  const page = await context.newPage();

  await page.goto('https://staging.example.com/checkout');
  await page.getByRole('button', { name: 'Continue to payment' }).click();
  await page.getByRole('heading', { name: 'Payment details' }).waitFor();

  await context.close(); // WRITES the archive — omit this and the file never appears
  await browser.close();
}

void record();

For a quick capture without writing any script at all, npx playwright open --save-har=har/checkout.har.zip --save-har-glob="**/api/**" https://staging.example.com opens a browser, records what you click through, and writes the archive on exit. The same two flags work with npx playwright codegen, so you can generate the spec and its fixture data in one session.

Layering an override on top of the archive is the pattern that makes HAR replay survive real applications, where one or two endpoints refuse to be static:

import { test, expect } from '@playwright/test';

test('applying a coupon updates the cart total', async ({ page }) => {
  // Registered first: the broad archive handler for all API traffic.
  await page.routeFromHAR('har/checkout.har.zip', { url: '**/api/**', notFound: 'abort' });

  // Registered second: consulted FIRST, so it shadows the archive for this URL.
  await page.route('**/api/coupon', async (route) => {
    const body = route.request().postDataJSON() as { code: string };
    if (body.code !== 'SAVE10') {
      await route.fallback(); // unknown codes fall through to the archived response
      return;
    }
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ code: 'SAVE10', discountCents: 1000 }),
    });
  });

  await page.goto('/checkout');
  await page.getByLabel('Coupon code').fill('SAVE10');
  await page.getByRole('button', { name: 'Apply' }).click();
  await expect(page.getByTestId('cart-total')).toHaveText('$89.00');
});

That route.fallback() call is what distinguishes this from replacing the archive wholesale: the handler only claims the requests it recognises. Broader override techniques live in Intercepting and Modifying Network Requests, and the equivalent for a single-endpoint API where operation names rather than URLs vary is covered in Stubbing GraphQL Requests in Playwright.

Troubleshooting variants

net::ERR_FAILED on a request that is visibly in the archive

Open the archive and compare log.entries[].request.url against the URL in the error character for character. The usual culprits are a cache-busting parameter (?_=1721822400000), a session identifier in the path, or a content-hashed asset name that changed on the next build. Fix it by narrowing urlFilter so the volatile request is never archived, then adding a page.route() handler that fulfils it directly; or, when only the query string varies, register the HAR router with a url glob that excludes it. Temporarily switching notFound to 'fallback' confirms the diagnosis in one run — if the test passes with fallback and fails with abort, the archive is missing an entry rather than serving a wrong one.

The .har file is empty, truncated, or never created

Playwright serialises the archive during context teardown, so anything that skips await context.close() loses it: a thrown error before the close call, process.exit() in a finally block, a watch-mode restart, or a CI job that times out. Wrap the recording script in try/finally with await context.close() in the finally, and check the file's byte size in the same script before declaring the capture successful. With update: true on routeFromHAR(), the rewrite lands when the test runner disposes the context at the end of the test, so a crashed test also produces no update.

Replay passes locally but fails in CI

Compare the origins first. If the local run uses baseURL: 'http://localhost:3000' and CI serves the app on http://127.0.0.1:3000 or a container hostname, every archived URL misses. Pin baseURL identically in both places, or record and replay against a relative-path service so the host never changes. The second common cause is the sidecar bodies never reaching CI — a .gitignore rule for hash-named files, or Git LFS pointers checked out without the objects, leaves the JSON present and every body missing. Committing the .zip form removes both hazards at once, and the wider set of pipeline gotchas is collected under CI/CD Integration.

Verification

Prove the replay is genuinely offline rather than accidentally passing through. Set notFound: 'abort' and add a context-level catch-all — await context.route('**/*', (route) => route.abort()) registered before the HAR router — so anything the archive does not cover is killed. A suite that still passes has no hidden network dependency. Next, run the spec with the machine's network disabled, or with offline: true on the context: a HAR-backed test is unaffected, while a test still reaching a backend fails immediately.

Then confirm the traffic came from disk rather than the wire. Record a trace with --trace on and inspect the network tab, where each fulfilled request appears with its archived response body; the technique is detailed in Reading Network and Console Tabs in Traces. Finally, measure stability: run npx playwright test --repeat-each=20 against the archive. Replayed responses are byte-identical every time, so any residual variation is coming from the application or from your waits rather than from the backend — exactly the separation of concerns that makes detecting and fixing flaky tests tractable, and it pairs well with waiting on element state rather than network idle once the network is deterministic.

Frequently Asked Questions

When should I reach for a HAR archive instead of hand-written route stubs?

Use an archive when the page depends on many endpoints whose exact payloads you do not want to author by hand — dashboards, checkout flows, and any screen backed by a third-party widget. Use hand-written stubs when a test hinges on one specific response shape, especially an error case such as a 500 or a validation rejection that the real backend will not produce on demand. Most mature suites run both: an archive supplies the ambient traffic, and a narrow handler stubs the endpoint under test, as described in Mocking API Responses with Playwright.

Can routeFromHAR tell two POSTs to the same URL apart?

No. The router keys on method and URL, so two POST /api/orders calls with different payloads are indistinguishable to it and you cannot rely on the archive to branch on the body. When a test needs payload-dependent responses, register a page.route() handler after the HAR router, read route.request().postDataJSON(), fulfil the case you care about, and call route.fallback() for everything else so the archive still serves the rest.

Does HAR replay cover WebSockets and server-sent events?

The HAR format models HTTP request and response pairs, so a persistent WebSocket connection is not captured in a form routeFromHAR() can serve back. Long-lived streaming endpoints need a different approach — Playwright's WebSocket routing API for socket traffic, or a fulfilled response with a crafted event-stream body for server-sent events. Treat the archive as coverage for conventional XHR and fetch traffic only.

How do I keep credentials out of a committed archive?

Record with mode: 'minimal' to drop the cookie blocks, then run a sanitiser over the archive before it reaches version control: walk log.entries, replace the value of any authorization, set-cookie, or x-api-key header with a placeholder, and redact known token fields inside JSON bodies. Record against a seeded test account rather than a real one, and add an archive check to the same secret-scanning step that guards the rest of the repository. Verifying the sanitised archive still replays is a single run of the suite.

Back to overview