page.route lets you mock individual requests by hand. request lets you call APIs directly. There's a third pattern that sits between them: HAR file record-and-replay. Run a flow once against a real backend, save every request and response to a file, then replay that file in subsequent test runs without ever touching the network. The result is a test suite that runs offline, runs deterministically, runs in milliseconds — and that you can re-record on demand when the API evolves. This lesson is about Playwright's HAR APIs, when they earn their keep over hand-rolled mocks, and the gotchas around dynamic data.
What HAR is
HAR — HTTP Archive — is a JSON file format that records every request and response a browser made during a session. URL, method, headers, body, timing, status code, response headers, response body. Open one in Chrome DevTools (Network tab → right-click → "Save all as HAR with content") and you can replay an entire user session.
Playwright's APIs come in two halves:
recordHar— capture HAR while a test runs. Save to disk on context close.page.routeFromHAR(...)— replay HAR into a test. Every matching request is served from the file, not the network.
Combine the two and you get the workflow: record once against the real backend, replay forever in CI.
Recording — recordHar
You set recordHar on the BrowserContext when it's created. Playwright watches every request from that point on; when the context closes, it writes the HAR file to disk.
import { test } from "@playwright/test";
test("record HAR for the products page", async ({ browser }) => {
const context = await browser.newContext({
recordHar: { path: "tests/har/products.har" }
});
const page = await context.newPage();
await page.goto("https://shop.example.com/products");
await page.getByRole("button", { name: "Filter: Electronics" }).click();
await page.getByRole("link", { name: "Wireless Headphones" }).click();
// Closing the context triggers the HAR write
await context.close();
});After the test runs, tests/har/products.har contains every request and response the page made — the products list, the filter API call, the product detail call, every image, every analytics ping. You can inspect it as JSON, version it in git, share it with teammates.
The recordHar options give you finer control:
recordHar: {
path: "tests/har/products.har",
mode: "minimal", // minimal | full (default full)
content: "embed", // omit | embed | attach (default embed)
urlFilter: /\/api\// // record only API calls, skip images/CSS
}mode: "minimal" skips response bodies for non-text content (faster, smaller). urlFilter lets you record only the requests you'll need — usually /api/ is enough; you don't replay images.
CLI recording — no test code needed
For a quick one-off recording, the CLI is faster than writing a test:
npx playwright open --save-har=tests/har/checkout.har https://shop.example.comThis opens a regular browser; click through whatever flow you want. When you close the browser, Playwright writes the HAR file. Useful for capturing a flow the dev team described over Slack — record once, replay in tests forever.
Replaying — routeFromHAR
The flip side: instead of letting requests reach the network, serve them from a HAR file:
test("replay HAR — products page runs offline", async ({ page }) => {
await page.routeFromHAR("tests/har/products.har", {
url: "**/api/**",
update: false // false = replay only; true = update HAR with real responses
});
await page.goto("https://shop.example.com/products");
// Every API request matches the HAR; no real network call goes out
await expect(page.getByText("Wireless Headphones")).toBeVisible();
});The url filter scopes which requests come from the HAR — typically **/api/** so you replay API calls but leave images, CSS, and analytics to the real network (or ignored). Anything not matched in the HAR falls through to the real network unless you also set up a route to abort.
update: true is the magic flag for re-recording. When set, Playwright runs the requests against the real network and updates the HAR file with the latest responses. Run a test once with update: true to refresh the HAR; commit the new file; run normally afterwards.
A complete record-then-replay workflow
A typical setup uses two test files — one to record, one (or many) to replay:
// tests/record-har.spec.ts — run manually when you need to refresh the HAR
import { test } from "@playwright/test";
test("record products HAR", async ({ browser }) => {
test.skip(!process.env.RECORD_HAR, "Set RECORD_HAR=1 to actually record");
const context = await browser.newContext({
recordHar: { path: "tests/har/products.har", urlFilter: /\/api\// }
});
const page = await context.newPage();
await page.goto("https://shop.example.com/products");
await page.getByRole("link", { name: "Wireless Headphones" }).click();
await page.waitForLoadState("networkidle");
await context.close();
});// tests/products-replay.spec.ts — runs in every CI build, no network needed
import { test, expect } from "@playwright/test";
test.describe("Products page (HAR replay)", () => {
test.beforeEach(async ({ page }) => {
await page.routeFromHAR("tests/har/products.har", {
url: "**/api/**",
update: false
});
});
test("renders the product list", async ({ page }) => {
await page.goto("/products");
await expect(page.getByTestId("product-card")).toHaveCount(50);
});
test("renders the wireless headphones detail page", async ({ page }) => {
await page.goto("/products/wireless-headphones");
await expect(page.getByRole("heading")).toContainText("Wireless Headphones");
await expect(page.getByText(/£\d+/)).toBeVisible();
});
});The replay tests run identically against the recorded HAR every time — same data, same status codes, same response timing. CI doesn't need backend credentials; the test passes the same offline as it does online.
When HAR earns its keep over hand-rolled mocks
The trade-off:
- Hand-rolled
route.fulfill— explicit, scoped, easy to read. Every mock is in the test file, you can see exactly what's being faked. Brittle when the API shape evolves. - HAR replay — automatic, comprehensive, captures structural truth from a real backend. Easier to keep current (re-record). Less self-explanatory in the test (the JSON lives in a file, not in the code).
Reach for HAR when:
- The flow has many API calls and writing each
route.fulfillby hand would be tedious. - The data shape is complex and you want the recording to match production exactly.
- The backend is unstable in CI and you want tests to run fully offline.
Reach for hand-rolled mocks when:
- You only need to mock one or two endpoints — quicker to write inline.
- You're testing edge cases (errors, empty states) that the real backend won't easily produce.
- The data needs to vary per test — different responses for different scenarios.
Many real test suites combine both: HAR for the bulk read-only API surface, hand-rolled mocks for the specific edge cases each test cares about.
The HAR workflow
Limits and gotchas
HAR is powerful, but it has rough edges to plan around:
- Dynamic data drifts. Timestamps, auth tokens, signed URLs — these are unique to each session. Re-record periodically or substitute them in the HAR with a script.
- Session-bound responses. A response that contains a
Set-Cookiefor a specific session ID won't replay correctly for a different session. For auth flows, lean onstorageState(chapter 6) instead. - Large HAR files. A flow with hundreds of requests can produce a multi-megabyte HAR file. Use
urlFilterto record only/api/traffic, andmode: 'minimal'to skip non-text bodies. - Schema evolution. When the backend adds or renames a field, your replayed HAR is now stale. Re-record. The
update: trueflag is designed for exactly this — run the suite once with it set, the HAR refreshes against the real backend. - HAR is not a contract test. It captures one response shape; it doesn't verify the API still produces that shape on subsequent runs. Pair HAR replay with a small set of pure-API contract tests (chapter 4 lesson 3) that hit the real backend periodically — that's how you catch schema drift before the HAR-replay tests start lying.
Inspecting HAR files
Three ways to look inside:
- Chrome DevTools. Open Chrome → Network tab → right-click → "Import HAR file." You get the same panel you'd see during a live session: request list, response previews, timing.
- VS Code. HAR is JSON, so any editor opens it. Search for a specific URL or status code with regular text search.
page.routeFromHARwith logging. Add a small wrapper that logs every served-from-HAR request:await page.route('**/*', async (route) => { console.log('served from HAR:', route.request().url()); route.continue(); }).
Coming from Cypress?
Cypress doesn't have a built-in HAR feature. The closest equivalents are:
cy.intercept('/api/x', { fixture: 'x.json' })— manually-written fixtures, like Playwright's hand-rolled mocks.- Third-party plugins (
cypress-network-idle,cypress-har-generator) that approximate the recording side.
If your Cypress suite relies heavily on fixture files (cy.fixture(...) everywhere), HAR is the natural Playwright equivalent — and it captures the responses automatically rather than requiring hand-maintained JSON.
⚠️ Common mistakes
- Recording dynamic flows that include auth tokens, then replaying for years. The first replay works. Six months later the recorded
Authorization: Bearer eyJ...token has expired, and any test logic that re-uses cookies fails. For auth specifically, usestorageState(chapter 6) — keep auth flows out of HAR. - Forgetting to set
urlFilterand recording megabytes of images. A typical product page has 50+ image requests. WithouturlFilter: /\/api\//, your HAR is a 10MB file dominated by JPGs you don't care about. Always scope to API traffic. - Treating HAR as a substitute for API contract tests. HAR captures one snapshot of the API. If the backend silently changes a field name, the HAR-replay tests still pass (they're using the cached old shape) but production is broken. Combine HAR replay with a periodic contract test (lesson 3) that hits the real backend.
🎯 Practice task
Record a HAR file and replay it in a deterministic test. 25-30 minutes.
-
Pick a public API-driven page — e.g., JSONPlaceholder's
/postsendpoint via a simple HTML viewer, or your own dev environment. -
Create
tests/record-har.spec.ts:import { test } from "@playwright/test"; test("record posts HAR", async ({ browser }) => { test.skip(!process.env.RECORD_HAR, "Set RECORD_HAR=1 to record"); const context = await browser.newContext({ recordHar: { path: "tests/har/posts.har", urlFilter: /jsonplaceholder/ } }); const page = await context.newPage(); await page.goto("https://jsonplaceholder.typicode.com/posts"); await page.waitForLoadState("networkidle"); await context.close(); }); -
Record by running:
RECORD_HAR=1 npx playwright test tests/record-har.spec.ts --project=chromium. After the run,tests/har/posts.harexists. -
Inspect the HAR file in VS Code or DevTools. Verify it contains the
/postsresponse with all 100 entries. -
Create
tests/replay-har.spec.ts:import { test, expect } from "@playwright/test"; test.describe("Posts (HAR replay)", () => { test.beforeEach(async ({ page }) => { await page.routeFromHAR("tests/har/posts.har", { url: "**/jsonplaceholder/**", update: false }); }); test("loads posts from HAR — no network needed", async ({ page }) => { const responsePromise = page.waitForResponse(/posts/); await page.goto("https://jsonplaceholder.typicode.com/posts"); const response = await responsePromise; expect(response.status()).toBe(200); const text = await page.locator("body").textContent(); expect(text?.length).toBeGreaterThan(1000); }); }); -
Run with no internet (or after blocking the JSONPlaceholder domain in your hosts file). The test still passes — every response comes from the HAR.
-
Demonstrate
update: true. Run once withupdate: truewhile online — the HAR refreshes with the latest responses. Diff the file withgit diffto see what changed. This is the "API drifted; refresh the recordings" workflow. -
Stretch: add a third test that combines HAR replay (for the read-only listing) with hand-rolled
route.fulfill(for a simulated POST that creates a new post). The HAR handles the bulk; the route handles the one specific edge case the test cares about. This composition is the canonical mature-team pattern.
That closes Chapter 4 — network mocking and API testing. You now have four distinct techniques for controlling the network: full mocking (route.fulfill), modification (route.fetch + fulfill), API testing (request), and record/replay (HAR). Combined, they cover every "how do I decouple my tests from a flaky backend?" question you'll ever face. The next chapter shifts to fixtures, hooks, and data management — the patterns that turn dozens of one-off tests into a reusable, well-structured suite.