Record and replay API calls in Playwright tests, including server-side requests

Record once.
Replay forever.

Records the API responses your app gets during a local Playwright run, WebSockets included, then replays them on CI with your backend off.

npm i -D test-proxy-recorder Star on GitHub

v1.4.0 · MIT · TypeScript · Node ≥ 20 · Next.js & TanStack Start SSR, SPAs, Chrome extensions, WebSockets

REC · MODE=record · local run

saved todos__adds-a-todo.mock.json

REPLAY · MODE=replay · CI

200 OK · 3 ms · from disk

See it record, then replay

One Playwright run records real responses to disk; flip to replay and the same suite passes with the backend off.

Two ways to record

Requests start in two places, so there are two ways to record them, and the proxy process runs in both. Anything sent to the proxy (server-side fetches, browser calls through an API base URL shared with the server, WebSockets) is replayed from the test's .mock.json. Browser calls to other hosts that match your fixture's url pattern are replayed from the test's .har.

HAR

.har

browser → HAR intercept → real API

For SPAs, extensions, and browser-only apps.

Intercepts in the browser itself and saves an HTTP Archive (HAR) file. Records client-side fetch calls, Chrome extension API traffic, analytics and third-party APIs, whichever match the url pattern in your fixture.

Over plain routeFromHAR it adds one HAR per test and redacts auth headers.

What a recording looks like

One file per test
Each test writes its own file, named after the test, so npx playwright test -g "adds a todo" re-records just that one. The proxy records one test at a time, so record with one worker; replay runs in parallel.
The test's id
Playwright sends x-test-rcrd-id with every request a test makes, and registerProxyFetch() copies it onto your server's own fetches. The proxy reads it to answer each test from that test's own file, even when tests replay in parallel.
Secrets
Authorization, Cookie and Set-Cookie headers become [REDACTED] in .mock.json files before they are written, and in .har files when the run ends. Secrets inside response bodies need patterns of your own. Secret redaction →
e2e/recordings/todos__adds-a-todo.mock.json
{
"id": "todos__adds-a-todo",
"recordings": [
{
"request": {
"method": "POST",
"url": "/todos",
"headers": {
"authorization": "[REDACTED]",
"x-test-rcrd-id": "todos__adds-a-todo"
},
"body": "{\"title\":\"Buy groceries\"}"
},
"response": {
"statusCode": 201,
"body": "{\"id\":7,\"title\":\"Buy groceries\",\"done\":false}"
},
"key": "POST_todos.json",
"sequence": 0
}
]
}
Part of a real recording, 2.6 KB in full: the POST from one test, with its Authorization header redacted. The whole file also holds the GET that followed, every header, and a timestamp for each request.

Where it fits

The mocking tools are good at different jobs. The combination below — recording real traffic across SSR, browser, and WebSockets, with no hand-written mocks — is the gap the others leave open.

Feature comparison of test-proxy-recorder against Playwright routeFromHAR, MSW, Polly.js, playwright-network-cache, and Mocky Balboa.
Featuretest-proxy-recorderPlaywright routeFromHARMSWPolly.jsplaywright-network-cacheMocky Balboa
Record real trafficYesYesNoYesYesNo
Server-side (SSR)YesNoYesPartialNoYes
Browser-sideYesYesYesYesYesYes
WebSocketYesNoYesNoNoNo
Playwright-nativeYesYesYesNoYesYes
MaintainedYesYesYesNoYesYes

Polly.js records inside the process it runs in, so for server-side requests it has to run inside your app server. MSW and Mocky Balboa mock server-side requests too, but you write the responses yourself. The full comparison, with sources and when to pick another tool, is in the docs.

The trade-off is a more involved first setup than the browser-only tools: a proxy process runs beside your tests, and for server-side requests your app's API base URL points at it during test runs.

Works with your real auth provider

Log in through Cognito, Auth0, Clerk, or WorkOS — for real, on every run. Only your app's API is recorded; auth stays live, your data goes offline.

// e2e/auth.setup.ts — log in for real, once. Never recorded.
import { test as setup } from '@playwright/test';
import { setProxyMode } from 'test-proxy-recorder';
setup('authenticate', async ({ page }) => {
await setProxyMode('transparent'); // login bypasses the recorder
await page.goto('/login');
await page.getByTestId('email').fill(process.env.TEST_EMAIL!);
await page.getByTestId('password').fill(process.env.TEST_PASSWORD!);
await page.getByTestId('signinButton').click();
await page.waitForURL('/dashboard');
// Reused by every test — they start already signed in.
await page.context().storageState({ path: 'e2e/.auth/state.json' });
});

Quick start

Scaffold everything with one command, point your API at the proxy, then record and commit. Browser-only app? init skips the SSR step for you.

What changes in your repo

  • In your test suite: a Playwright fixture, the proxy process (it runs only during tests), and recordings committed to git.
  • In your app, only for server-side requests: point the API base URL at the proxy during test runs, and call registerProxyFetch() once, in app/layout.tsx on Next.js or src/router.tsx on TanStack Start. It copies the test's id onto each server-side fetch and does nothing in production unless TEST_PROXY_RECORDER_ENABLED is set.
  • With the agent prompt: @tanstack/intent adds guidance on loading this library's skills to your agent's config file, such as CLAUDE.md.
  • Testing a deployed environment? HAR records its browser calls with no change to the app, but the pages themselves still load from that server. To replay server-side calls as well, run the app in CI next to the proxy.

Paste this into Claude Code, Cursor or another coding agent. It first runs @tanstack/intent, which adds guidance to your agent's config file, such as CLAUDE.md, so the agent loads this library's setup skills. Then it installs the package, finds your backend URL in the app's config, runs init and finishes the wiring from its output.

Set up test-proxy-recorder for end-to-end tests in this project, then follow the instructions that `init` prints. Run these commands:
npx @tanstack/intent@latest install
npm install --save-dev test-proxy-recorder
Then run init, passing this project's backend API base URL as the target — find it yourself from the app's env/config (the URL the app calls in dev); don't assume the default:
npx test-proxy-recorder init <your-backend-api-url> --port 8100 --dir ./e2e/recordings
Then complete the app-specific steps init prints: point the app's API base URL at the proxy in dev/test only, tag server-side fetches (Next.js), add a smoke test, and verify record → replay.

Full setup guide →

Questions about replay

How are requests matched?
The proxy matches a server-side call on its method, path and exact query string, not its body, and serves repeated calls in the order they were recorded. Browser calls follow Playwright's HAR rules, which also compare POST bodies. A query string that changes every run, such as a timestamp, never matches, and no option ignores a parameter, so keep such values fixed during tests. How replay matches requests →
What if a recording is missing?
The proxy answers 404 and names the request and the test. A call made more times than it was recorded gets the last response again, with a warning in the proxy log. In the browser, Playwright aborts a call that matches the url pattern your fixture passes to before() but is not in the HAR; other calls reach the network.
When does replay reach the real API?
Replay reaches the real API only after the proxy leaves replay mode, and the proxy keeps one mode for all running tests. It leaves replay mode 120 seconds after the last test started, or when a per-test hook calls teardown(). With no backend running in CI, such a call fails instead of passing. Session timeout and teardown →
What happens when the API changes?
With a typed API client, updating the client for a changed field makes the replayed tests that use it fail, because the recordings still hold the old field. That points you at the change, and recording again fixes the tests with less work than editing hand-written mocks.
How do I test errors and edge cases?
In the browser, a page.route() registered after before() overrides the recording. Server-side responses are edited in the .mock.json file. Forcing a response →

Stop hand‑writing mocks

Your API already gives the right answers. Record them.

npm i -D test-proxy-recorder Star on GitHub

If it saved you an afternoon, a star takes one second — it's how the next person finds it, and it tells a solo maintainer to keep building. Hit a snag or have an idea? Open an issue or join Discord.