HAR Recording and Replay in Playwright with routeFromHAR
Flaky end-to-end tests are almost always a network problem in disguise: a slow third-party API, a staging environment that returns yesterday’s data, or a checkout service that randomly times out. The fix is to stop hitting the real network during tests and replay a recorded snapshot instead. In this guide you’ll learn Playwright HAR record replay end to end — how to capture a HAR file from a live run, replay it with routeFromHAR, scope it to specific URLs, update it safely, and decide when a HAR beats hand-written page.route mocks.
🎠Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.
Contents
What is a HAR file and why use it
A HAR (HTTP Archive) file is a JSON document that records every request and response a page made during a session — URLs, methods, headers, status codes, timings, and response bodies. Browsers have exported HAR from their DevTools Network panel for years, and Playwright can both produce and consume the same format. That means you can record once against a real backend and then replay those exact responses forever, with zero network calls.
Compared with writing every mock by hand, a HAR captures the real shape of your API responses, including pagination metadata, nested objects, and edge-case fields you might forget to stub. It gives you three concrete wins:
- Speed: replayed responses are served from disk in milliseconds, with no round trips to a slow API.
- Determinism: the same bytes come back every run, so tests stop failing because a backend changed or a feed updated.
- Offline capability: CI runners and laptops on a plane can run the suite without reaching any external service.
Recording a HAR file with Playwright
There are two ways to record. The simplest is to ask routeFromHAR itself to record by setting update: true — when the HAR doesn’t exist yet (or you want to refresh it), Playwright performs the real requests, lets them through, and writes the archive to disk. This is the pattern most teams standardize on because the same line both records and replays.
import { test, expect } from '@playwright/test';
// Run once with update:true to RECORD the HAR, then flip it back to replay.
test('record live API traffic into a HAR file', async ({ page }) => {
await page.routeFromHAR('hars/dashboard.har', {
url: '**/api/**', // only archive calls under /api/
update: true, // hit the real network and write the HAR
});
await page.goto('https://app.example.com/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
// After this run, hars/dashboard.har contains every matched response.
});
If you prefer to capture all traffic for a context (not just route-matched URLs), record at the context level via browser.newContext. This is closer to a DevTools HAR export and is handy when you don’t yet know which endpoints you need.
import { chromium } from '@playwright/test';
async function captureHar() {
const browser = await chromium.launch();
const context = await browser.newContext({
recordHar: {
path: 'hars/full-session.har',
mode: 'minimal', // 'minimal' keeps only what replay needs
content: 'embed', // inline response bodies into the HAR
urlFilter: '**/api/**', // optional: restrict what gets recorded
},
});
const page = await context.newPage();
await page.goto('https://app.example.com/dashboard');
await page.getByRole('button', { name: 'Load more' }).click();
await context.close(); // closing flushes the HAR to disk
await browser.close();
}
captureHar();
Two options are worth understanding. The content setting controls where bodies live: 'embed' writes them inside the JSON (one portable file), while 'attach' stores them as separate files next to the HAR (better for large binary payloads and cleaner diffs). The mode setting controls fidelity: 'minimal' drops timing, cookies, and other fields that replay ignores, producing a far smaller, review-friendly file than the 'full' export you’d get from a browser.
Replaying a HAR with routeFromHAR
Once the HAR exists, replay is a single line. With the default options, routeFromHAR intercepts matching requests and serves the recorded response straight from the file — no real network involved. The core of Playwright HAR record replay is choosing what happens when a request doesn’t match anything in the archive, which is governed by the notFound option.
import { test, expect } from '@playwright/test';
test('replay recorded API responses offline', async ({ page }) => {
await page.routeFromHAR('hars/dashboard.har', {
url: '**/api/**',
update: false, // replay only; never touch the network
notFound: 'abort', // fail loudly if a call wasn't recorded
});
await page.goto('https://app.example.com/dashboard');
// These assertions run against the recorded payloads, deterministically.
await expect(page.getByTestId('user-count')).toHaveText('1,284');
await expect(page.getByRole('row')).toHaveCount(11); // header + 10 rows
});
The notFound option takes two values, and the right choice depends on how strict you want the test to be:
| notFound value | Behavior on an unmatched request | When to use it |
|---|---|---|
'abort' (default) | The request is aborted with a network error | Strict, fully offline tests — surfaces any call you forgot to record |
'fallback' | The request continues to the real server (or the next route handler) | Partial replay — mock /api from HAR but let assets, analytics, or new endpoints hit the network |
A common production pattern is to mock the data layer from a HAR while letting static assets through. You do that by narrowing the url glob so only API calls are intercepted, and setting notFound: 'fallback' so anything outside that glob (fonts, images, scripts) loads normally.
import { test, expect } from '@playwright/test';
test('mock the API but stream real assets', async ({ page }) => {
await page.routeFromHAR('hars/products.har', {
url: '**/api/products**', // intercept ONLY the product API
notFound: 'fallback', // everything else continues normally
});
await page.goto('https://shop.example.com/catalog');
// Product data comes from the HAR; CSS, fonts and images load for real.
await expect(page.getByRole('listitem')).toHaveCount(24);
await expect(page.getByRole('img').first()).toBeVisible();
});
Scoping HAR replay across the whole suite
Calling routeFromHAR in every test gets repetitive. Lift it into a Playwright fixture so the archive is wired up automatically for any test that opts in, keeping individual specs focused on assertions rather than network plumbing.
import { test as base, expect } from '@playwright/test';
// A fixture that replays the dashboard HAR for any test using `harPage`.
export const test = base.extend<{ harPage: void }>({
harPage: [async ({ page }, use) => {
await page.routeFromHAR('hars/dashboard.har', {
url: '**/api/**',
notFound: 'abort',
});
await use();
}, { auto: false }],
});
test('renders KPIs from recorded data', async ({ page, harPage }) => {
await page.goto('https://app.example.com/dashboard');
await expect(page.getByTestId('revenue')).toContainText('$');
});
export { expect };
You can also reach for HAR replay at the browserContext level with context.routeFromHAR when every page in a context should share the same archive — for example, an authenticated context created from a saved storageState. Page-level routes win over context-level ones for the same URL, which lets a single test override one endpoint while inheriting the rest of the suite’s recorded responses.
Updating and maintaining HAR files
APIs change, and a stale HAR will happily serve outdated responses while your real backend has moved on. Treat refreshing the archive as a deliberate, reviewable action. The cleanest way is an environment toggle that flips update on demand, so a single command re-records every HAR in the suite against live services.
import { test, expect } from '@playwright/test';
const UPDATE_HARS = process.env.UPDATE_HARS === '1';
test('checkout flow uses a maintainable HAR', async ({ page }) => {
await page.routeFromHAR('hars/checkout.har', {
url: '**/api/**',
update: UPDATE_HARS, // re-record when the flag is set
updateContent: 'embed', // inline bodies on update
updateMode: 'minimal', // keep the diff small
});
await page.goto('https://shop.example.com/cart');
await page.getByRole('button', { name: 'Checkout' }).click();
await expect(page.getByText('Order confirmed')).toBeVisible();
});
Run UPDATE_HARS=1 npx playwright test to regenerate the archives, then inspect the Git diff before committing. Because updateMode: 'minimal' strips volatile fields like timestamps, your diffs show only meaningful payload changes — a new field, a renamed key, a changed total — which makes code review of recorded data actually feasible.
A few maintenance habits keep HAR-based suites healthy over time:
- Scope tightly. Use a precise
urlglob so you don’t archive analytics beacons, telemetry, or ad calls you never assert on. - Scrub secrets. HARs can capture auth tokens and cookies in headers. Record against test accounts and review files before committing, or post-process them to redact sensitive values.
- One HAR per flow. Keep a focused archive per feature (dashboard, checkout, search) rather than one giant file, so updates stay localized and diffs stay readable.
- Pin to a known backend state. Re-record against a seeded test environment so counts and IDs in your assertions remain stable.
🚀 Level Up Your Playwright
From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.
HAR replay vs page.route mocks
HAR replay isn’t a replacement for page.route with route.fulfill — the two solve different problems. HAR shines when you want a faithful snapshot of many real endpoints captured cheaply. Hand-written page.route handlers shine when you need to fabricate a specific scenario (an empty list, a 500 error, a race condition) that may never occur naturally. Many strong suites use both: a HAR for the happy path, and targeted route.fulfill overrides layered on top for edge cases.
| Aspect | routeFromHAR (HAR replay) | page.route + route.fulfill |
|---|---|---|
| Setup effort | Record once, replay forever | Write each response body by hand |
| Fidelity to real API | High — captures actual payloads | Only as accurate as your stub |
| Forcing error states | Awkward — needs an override | Trivial — fulfill with status 500 |
| Maintenance on API change | Re-record with update: true | Manually edit each handler |
| Best for | Realistic happy-path data | Specific edge cases and failures |
You can combine them in one test: register the HAR first, then add a page.route that overrides a single endpoint. Because the later, more specific route handler runs first, you get realistic baseline data from the HAR plus a forced failure exactly where you want it.
import { test, expect } from '@playwright/test';
test('happy-path HAR plus a forced 500 on one endpoint', async ({ page }) => {
// Baseline: realistic data for everything under /api.
await page.routeFromHAR('hars/dashboard.har', { url: '**/api/**' });
// Override just the notifications endpoint to simulate an outage.
await page.route('**/api/notifications', async (route) => {
await route.fulfill({ status: 500, body: 'Service Unavailable' });
});
await page.goto('https://app.example.com/dashboard');
await expect(page.getByRole('alert')).toHaveText(/notifications.*unavailable/i);
});
Conclusion
Mastering Playwright HAR record replay turns your slowest, flakiest integration tests into fast, deterministic, offline-capable specs. Record real traffic with update: true or a recordHar context, replay it with routeFromHAR and a deliberate notFound policy, scope it through fixtures, and keep archives fresh with an UPDATE_HARS toggle. Reach for HAR when you want faithful happy-path data, and layer route.fulfill overrides on top for the error paths a recording can’t capture. Start by recording one critical flow today — you’ll feel the difference in your next CI run.
FAQ
Does routeFromHAR replace a real backend during tests?
Yes, for the URLs it matches. With update: false (the default) and notFound: 'abort', routeFromHAR serves every matched request straight from the HAR file with no network access, so the matched portion of your backend is effectively replaced. Requests that fall outside the url glob, or that have notFound: 'fallback', still reach the real server.
How do I update a HAR file when the API changes?
Set update: true on the routeFromHAR call (ideally behind an environment flag like UPDATE_HARS) and run the test against the live backend. Playwright performs the real requests and rewrites the archive. Use updateMode: 'minimal' to keep the file small and the Git diff readable, then review the changes before committing.
Can I use HAR replay and page.route mocks together?
Absolutely. Register routeFromHAR for your baseline data, then add more specific page.route handlers to override individual endpoints — for example, forcing a 500 error or an empty response. The later, more specific route runs first, so you get realistic HAR-backed data everywhere except the one endpoint you deliberately stub.
🎓 Master Playwright End to End
Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.
