[Examples & benchmarks](https://cdpfleet.com/docs/cases) / #34 · 2026-10-11 · Example · Medium · for [testing](https://cdpfleet.com/cross-browser-testing)

# Record a page once, replay it offline: HAR files with a remote browser

Record every request of a page into a HAR file on your machine, then serve the page from that file — same content in a fraction of the time, nothing sent to the network, and requests the file doesn't have refused.

[Chromium](https://cdpfleet.com/docs/browsers/chromium)

Run on production on 2026-10-11: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go

## The problem

Developing a scraper against a live site is slow, burns proxy traffic, and changes under you while you work. Tests that hit the real site flake. What you want is to capture the site once — through the remote browser and its proxy — and replay exactly that capture as often as you like, with no network at all.

## What we used, and why

| What | Why |
| --- | --- |
| `newContext({ recordHar: { path, mode: "minimal" } })` | Writes every request and response of the context into a HAR file — on your machine, even though the browser is remote. |
| `context.routeFromHAR(path, { notFound: "abort" })` | Answers requests from the HAR; anything the file doesn't contain is refused, so nothing leaks to the network. |
| `requestfinished` / `requestfailed` events | Count what the replay served from the file and what it refused. |
| books.toscrape.com | A real page with images, CSS and scripts. |

## How it works

1.  Launch Chromium; open a context that records a HAR and load the page.
2.  Close the context (the HAR is written) and count its entries.
3.  Open a new context that answers from the HAR, load the same page, and count what was served and refused.
4.  Try a page the HAR doesn't contain.

## The code

The same program in five languages (also [on GitHub](https://github.com/cdpfleet/cdpfleet/tree/main/cases/34-har-record-and-replay), with the raw output). Set these environment variables first:

-   `CDPFLEET_API_KEY` — your API key (dashboard → API keys)
-   `PROXY_URL` — your proxy, e.g. `http://user:pass@proxy.example.com:8000`

 

```node
// npm install playwright@1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
import { chromium } from 'playwright';
import { mkdtempSync, statSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';

const KEY = process.env.CDPFLEET_API_KEY;
const URL = 'https://books.toscrape.com/';

const res = await fetch('https://starter.cdpfleet.com/chromium/session', {
  method: 'POST',
  headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
  body: JSON.stringify({ proxy: process.env.PROXY_URL, headless: 'new' }),
});
if (!res.ok) throw new Error(`launch ${res.status} ${await res.text()}`);
const { wsUrl } = await res.json();
const browser = await chromium.connect(wsUrl, { headers: { 'x-api-key': KEY } });
const dir = mkdtempSync(path.join(tmpdir(), 'har-'));
const har = path.join(dir, 'books.har');

try {
  // 1. Record: every request of the page goes into a HAR file on this machine.
  const rec = await browser.newContext({ recordHar: { path: har, mode: 'minimal' } });
  const p1 = await rec.newPage();
  let t = Date.now();
  await p1.goto(URL, { waitUntil: 'load', timeout: 60000 });
  const liveMs = Date.now() - t;
  const liveBooks = await p1.locator('article.product_pod').count();
  await rec.close(); // writes the HAR
  const entries = JSON.parse(readFileSync(har, 'utf8')).log.entries;

  // 2. Replay: serve every request from the HAR; anything not in it is refused, so the
  //    page can't quietly reach the network.
  const rep = await browser.newContext();
  let fromHar = 0;
  let refused = 0;
  rep.on('requestfinished', () => { fromHar++; });
  rep.on('requestfailed', () => { refused++; });
  await rep.routeFromHAR(har, { notFound: 'abort' });
  const p2 = await rep.newPage();
  t = Date.now();
  await p2.goto(URL, { waitUntil: 'load', timeout: 60000 });
  const replayMs = Date.now() - t;
  const replayBooks = await p2.locator('article.product_pod').count();
  // A page the HAR doesn't have: refused instead of fetched.
  const missing = await p2.goto('https://books.toscrape.com/catalogue/page-2.html').then(() => 'loaded', (e) => (/ERR_FAILED|aborted/i.test(e.message) ? 'refused' : e.message.split('\n')[0]));
  await rep.close();

  console.log(JSON.stringify({
    har_entries: entries.length,
    har_kb: Math.round(statSync(har).size / 1024),
    live: { ms: liveMs, books: liveBooks },
    replay: { ms: replayMs, books: replayBooks, served_from_har: fromHar, refused },
    page_not_in_har: missing,
  }, null, 2));
} finally {
  await browser.close();
  rmSync(dir, { recursive: true, force: true });
}
```

```python
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import json
import os
import re
import shutil
import tempfile
import time
import zipfile

import requests
from playwright.sync_api import sync_playwright

KEY = os.environ["CDPFLEET_API_KEY"]
URL = "https://books.toscrape.com/"

res = requests.post("https://starter.cdpfleet.com/chromium/session",
    headers={"x-api-key": KEY},
    json={"proxy": os.environ["PROXY_URL"], "headless": "new"}, timeout=60)
if not res.ok:
    raise SystemExit(f"launch {res.status_code} {res.text}")

with sync_playwright() as p:
    browser = p.chromium.connect(res.json()["wsUrl"], headers={"x-api-key": KEY})
    tmp = tempfile.mkdtemp(prefix="har-")
    har = os.path.join(tmp, "books.har")
    # Over connect() the Python client only delivers a .har as a .zip archive (a plain .har
    # path is left as an unextracted "books.har.tmp"), so record into a zip and unpack it.
    har_zip = os.path.join(tmp, "books.zip")
    try:
        # 1. Record: every request of the page goes into a HAR file on this machine.
        rec = browser.new_context(record_har_path=har_zip, record_har_mode="minimal",
                                  record_har_content="embed")
        p1 = rec.new_page()
        t = time.time()
        p1.goto(URL, wait_until="load", timeout=60000)
        live_ms = round((time.time() - t) * 1000)
        live_books = p1.locator("article.product_pod").count()
        rec.close()  # writes the HAR
        with zipfile.ZipFile(har_zip) as z, open(har, "wb") as f:
            f.write(z.read(next(n for n in z.namelist() if n.endswith(".har"))))
        with open(har, encoding="utf-8") as f:
            entries = json.load(f)["log"]["entries"]

        # 2. Replay: serve every request from the HAR; anything not in it is refused, so the
        #    page can't quietly reach the network.
        rep = browser.new_context()
        counts = {"from_har": 0, "refused": 0}
        rep.on("requestfinished", lambda _: counts.__setitem__("from_har", counts["from_har"] + 1))
        rep.on("requestfailed", lambda _: counts.__setitem__("refused", counts["refused"] + 1))
        rep.route_from_har(har, not_found="abort")
        p2 = rep.new_page()
        t = time.time()
        p2.goto(URL, wait_until="load", timeout=60000)
        replay_ms = round((time.time() - t) * 1000)
        replay_books = p2.locator("article.product_pod").count()
        # A page the HAR doesn't have: refused instead of fetched.
        try:
            p2.goto("https://books.toscrape.com/catalogue/page-2.html")
            missing = "loaded"
        except Exception as e:
            missing = "refused" if re.search(r"ERR_FAILED|aborted", str(e), re.I) else str(e).split("\n")[0]
        rep.close()

        print(json.dumps({
            "har_entries": len(entries),
            "har_kb": round(os.path.getsize(har) / 1024),
            "live": {"ms": live_ms, "books": live_books},
            "replay": {"ms": replay_ms, "books": replay_books,
                       "served_from_har": counts["from_har"], "refused": counts["refused"]},
            "page_not_in_har": missing,
        }, indent=2))
    finally:
        browser.close()
        shutil.rmtree(tmp, ignore_errors=True)
```

```java
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1. env: CDPFLEET_API_KEY, PROXY_URL
import com.google.gson.*;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.*;
import java.net.URI;
import java.net.http.*;
import java.nio.file.*;
import java.util.*;
import java.util.regex.Pattern;

public class Main {
  static final String KEY = System.getenv("CDPFLEET_API_KEY");
  static final String URL = "https://books.toscrape.com/";
  static final Gson GSON = new GsonBuilder().serializeNulls().create();

  public static void main(String[] args) throws Exception {
    String body = "{\"proxy\": " + GSON.toJson(System.getenv("PROXY_URL")) + ", \"headless\": \"new\"}";
    HttpResponse<String> res = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/chromium/session"))
        .header("x-api-key", KEY).header("content-type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
    if (res.statusCode() != 200) throw new RuntimeException("launch: " + res.statusCode() + " " + res.body());
    String wsUrl = JsonParser.parseString(res.body()).getAsJsonObject().get("wsUrl").getAsString();

    try (Playwright playwright = Playwright.create()) {
      Browser browser = playwright.chromium().connect(wsUrl, new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
      Path dir = Files.createTempDirectory("har-");
      Path har = dir.resolve("books.har");
      try {
        // 1. Record: every request of the page goes into a HAR file on this machine.
        BrowserContext rec = browser.newContext(new Browser.NewContextOptions().setRecordHarPath(har).setRecordHarMode(HarMode.MINIMAL));
        Page p1 = rec.newPage();
        long t = System.currentTimeMillis();
        p1.navigate(URL, new Page.NavigateOptions().setWaitUntil(WaitUntilState.LOAD).setTimeout(60000));
        long liveMs = System.currentTimeMillis() - t;
        int liveBooks = p1.locator("article.product_pod").count();
        rec.close(); // writes the HAR
        JsonArray entries = JsonParser.parseString(Files.readString(har)).getAsJsonObject().getAsJsonObject("log").getAsJsonArray("entries");

        // 2. Replay: serve every request from the HAR; anything not in it is refused, so the
        //    page can't quietly reach the network.
        BrowserContext rep = browser.newContext();
        int[] fromHar = {0};
        int[] refused = {0};
        rep.onRequestFinished(r -> fromHar[0]++);
        rep.onRequestFailed(r -> refused[0]++);
        rep.routeFromHAR(har, new BrowserContext.RouteFromHAROptions().setNotFound(HarNotFound.ABORT));
        Page p2 = rep.newPage();
        t = System.currentTimeMillis();
        p2.navigate(URL, new Page.NavigateOptions().setWaitUntil(WaitUntilState.LOAD).setTimeout(60000));
        long replayMs = System.currentTimeMillis() - t;
        int replayBooks = p2.locator("article.product_pod").count();
        // A page the HAR doesn't have: refused instead of fetched.
        String missing;
        try {
          p2.navigate("https://books.toscrape.com/catalogue/page-2.html");
          missing = "loaded";
        } catch (PlaywrightException e) {
          String msg = String.valueOf(e.getMessage());
          missing = Pattern.compile("ERR_FAILED|aborted", Pattern.CASE_INSENSITIVE).matcher(msg).find() ? "refused" : msg.split("\n")[0];
        }
        rep.close();

        JsonObject live = new JsonObject();
        live.addProperty("ms", liveMs);
        live.addProperty("books", liveBooks);
        JsonObject replay = new JsonObject();
        replay.addProperty("ms", replayMs);
        replay.addProperty("books", replayBooks);
        replay.addProperty("served_from_har", fromHar[0]);
        replay.addProperty("refused", refused[0]);
        JsonObject out = new JsonObject();
        out.addProperty("har_entries", entries.size());
        out.addProperty("har_kb", Math.round(Files.size(har) / 1024.0));
        out.add("live", live);
        out.add("replay", replay);
        out.addProperty("page_not_in_har", missing);

        System.out.println(new GsonBuilder().serializeNulls().setPrettyPrinting().disableHtmlEscaping().create().toJson(out));
      } finally {
        browser.close();
        try (var walk = Files.walk(dir)) {
          walk.sorted(Comparator.reverseOrder()).forEach(f -> f.toFile().delete());
        }
      }
    }
  }
}
```

```csharp
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
using System.Net.Http.Json;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Playwright;

const string Url = "https://books.toscrape.com/";

var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
var res = await http.PostAsJsonAsync("https://starter.cdpfleet.com/chromium/session",
    new { proxy = Environment.GetEnvironmentVariable("PROXY_URL"), headless = "new" });
if (!res.IsSuccessStatusCode) throw new Exception($"launch: {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
var wsUrl = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("wsUrl").GetString()!;

using var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.ConnectAsync(wsUrl, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
var dir = Directory.CreateTempSubdirectory("har-").FullName;
var har = Path.Combine(dir, "books.har");
try
{
    // 1. Record: every request of the page goes into a HAR file on this machine.
    var rec = await browser.NewContextAsync(new() { RecordHarPath = har, RecordHarMode = HarMode.Minimal });
    var p1 = await rec.NewPageAsync();
    var sw = System.Diagnostics.Stopwatch.StartNew();
    await p1.GotoAsync(Url, new() { WaitUntil = WaitUntilState.Load, Timeout = 60000 });
    var liveMs = sw.ElapsedMilliseconds;
    var liveBooks = await p1.Locator("article.product_pod").CountAsync();
    await rec.CloseAsync(); // writes the HAR
    var entries = JsonNode.Parse(await File.ReadAllTextAsync(har))!["log"]!["entries"]!.AsArray();

    // 2. Replay: serve every request from the HAR; anything not in it is refused, so the
    //    page can't quietly reach the network.
    var rep = await browser.NewContextAsync();
    var fromHar = 0;
    var refused = 0;
    rep.RequestFinished += (_, _) => fromHar++;
    rep.RequestFailed += (_, _) => refused++;
    await rep.RouteFromHARAsync(har, new() { NotFound = HarNotFound.Abort });
    var p2 = await rep.NewPageAsync();
    sw.Restart();
    await p2.GotoAsync(Url, new() { WaitUntil = WaitUntilState.Load, Timeout = 60000 });
    var replayMs = sw.ElapsedMilliseconds;
    var replayBooks = await p2.Locator("article.product_pod").CountAsync();
    // A page the HAR doesn't have: refused instead of fetched.
    string missing;
    try
    {
        await p2.GotoAsync("https://books.toscrape.com/catalogue/page-2.html");
        missing = "loaded";
    }
    catch (PlaywrightException e)
    {
        missing = Regex.IsMatch(e.Message, "ERR_FAILED|aborted", RegexOptions.IgnoreCase) ? "refused" : e.Message.Split('\n')[0];
    }
    await rep.CloseAsync();

    var result = new JsonObject
    {
        ["har_entries"] = entries.Count,
        ["har_kb"] = (long)Math.Round(new FileInfo(har).Length / 1024.0, MidpointRounding.AwayFromZero),
        ["live"] = new JsonObject { ["ms"] = liveMs, ["books"] = liveBooks },
        ["replay"] = new JsonObject { ["ms"] = replayMs, ["books"] = replayBooks, ["served_from_har"] = fromHar, ["refused"] = refused },
        ["page_not_in_har"] = missing,
    };
    Console.WriteLine(result.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
}
finally
{
    await browser.CloseAsync();
    Directory.Delete(dir, true);
}
```

```go
// go get github.com/playwright-community/playwright-go@v0.6000.0
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, PROXY_URL
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"math"
	"net/http"
	"os"
	"path/filepath"
	"regexp"
	"strings"
	"sync/atomic"
	"time"

	"github.com/playwright-community/playwright-go"
)

var key = os.Getenv("CDPFLEET_API_KEY")

const url = "https://books.toscrape.com/"

func launch(name string, options map[string]any) (map[string]any, error) {
	body, _ := json.Marshal(options)
	req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+name+"/session", bytes.NewReader(body))
	req.Header.Set("x-api-key", key)
	req.Header.Set("content-type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	if res.StatusCode != http.StatusOK {
		msg, _ := io.ReadAll(res.Body)
		return nil, fmt.Errorf("launch %s: %s %s", name, res.Status, msg)
	}
	var session map[string]any
	return session, json.NewDecoder(res.Body).Decode(&session)
}

type live struct {
	Ms    int64 `json:"ms"`
	Books int   `json:"books"`
}

type replay struct {
	Ms            int64 `json:"ms"`
	Books         int   `json:"books"`
	ServedFromHar int64 `json:"served_from_har"`
	Refused       int64 `json:"refused"`
}

type output struct {
	HarEntries   int    `json:"har_entries"`
	HarKb        int64  `json:"har_kb"`
	Live         live   `json:"live"`
	Replay       replay `json:"replay"`
	PageNotInHar string `json:"page_not_in_har"`
}

func main() {
	if err := run(); err != nil {
		log.Fatal(err)
	}
}

func run() error {
	session, err := launch("chromium", map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": "new"})
	if err != nil {
		return err
	}
	pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
	if err != nil {
		return err
	}
	defer pw.Stop()
	browser, err := pw.Chromium.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
	if err != nil {
		return err
	}
	defer browser.Close()
	dir, err := os.MkdirTemp("", "har-")
	if err != nil {
		return err
	}
	defer os.RemoveAll(dir)
	har := filepath.Join(dir, "books.har")

	// 1. Record: every request of the page goes into a HAR file on this machine.
	rec, err := browser.NewContext(playwright.BrowserNewContextOptions{
		RecordHarPath: playwright.String(har),
		RecordHarMode: playwright.HarModeMinimal,
	})
	if err != nil {
		return err
	}
	p1, err := rec.NewPage()
	if err != nil {
		return err
	}
	t := time.Now()
	if _, err := p1.Goto(url, playwright.PageGotoOptions{WaitUntil: playwright.WaitUntilStateLoad, Timeout: playwright.Float(60000)}); err != nil {
		return err
	}
	liveMs := time.Since(t).Milliseconds()
	liveBooks, err := p1.Locator("article.product_pod").Count()
	if err != nil {
		return err
	}
	if err := rec.Close(); err != nil { // writes the HAR
		return err
	}
	raw, err := os.ReadFile(har)
	if err != nil {
		return err
	}
	var harFile struct {
		Log struct {
			Entries []json.RawMessage `json:"entries"`
		} `json:"log"`
	}
	if err := json.Unmarshal(raw, &harFile); err != nil {
		return err
	}

	// 2. Replay: serve every request from the HAR; anything not in it is refused, so the
	//    page can't quietly reach the network.
	rep, err := browser.NewContext()
	if err != nil {
		return err
	}
	var fromHar, refused atomic.Int64
	rep.OnRequestFinished(func(playwright.Request) { fromHar.Add(1) })
	rep.OnRequestFailed(func(playwright.Request) { refused.Add(1) })
	if err := rep.RouteFromHAR(har, playwright.BrowserContextRouteFromHAROptions{NotFound: playwright.HarNotFoundAbort}); err != nil {
		return err
	}
	p2, err := rep.NewPage()
	if err != nil {
		return err
	}
	t = time.Now()
	if _, err := p2.Goto(url, playwright.PageGotoOptions{WaitUntil: playwright.WaitUntilStateLoad, Timeout: playwright.Float(60000)}); err != nil {
		return err
	}
	replayMs := time.Since(t).Milliseconds()
	replayBooks, err := p2.Locator("article.product_pod").Count()
	if err != nil {
		return err
	}
	// A page the HAR doesn't have: refused instead of fetched.
	missing := "loaded"
	if _, err := p2.Goto("https://books.toscrape.com/catalogue/page-2.html"); err != nil {
		if regexp.MustCompile(`(?i)ERR_FAILED|aborted`).MatchString(err.Error()) {
			missing = "refused"
		} else {
			missing = strings.SplitN(err.Error(), "\n", 2)[0]
		}
	}
	if err := rep.Close(); err != nil {
		return err
	}

	out := output{
		HarEntries:   len(harFile.Log.Entries),
		HarKb:        int64(math.Round(float64(len(raw)) / 1024)),
		Live:         live{Ms: liveMs, Books: liveBooks},
		Replay:       replay{Ms: replayMs, Books: replayBooks, ServedFromHar: fromHar.Load(), Refused: refused.Load()},
		PageNotInHar: missing,
	}
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	enc.SetIndent("", "  ")
	enc.Encode(out)
	fmt.Print(buf.String())
	return nil
}
```

## What we got

| Phase | ms | Books | Detail |
| --- | --- | --- | --- |
| Live (recording) | 8902 | 20 | 31 requests → 868 KB HAR |
| Replay from HAR | 601 | 20 | 30 served, 2 refused |
| Page not in the HAR |  |  | refused |

From the Node.js run on 2026-10-11. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.

Raw output (Node.js)

```
{
  "har_entries": 31,
  "har_kb": 868,
  "live": {
    "ms": 8902,
    "books": 20
  },
  "replay": {
    "ms": 601,
    "books": 20,
    "served_from_har": 30,
    "refused": 2
  },
  "page_not_in_har": "refused"
}
```

## Takeaways

-   **The HAR lands on your machine,** even though the browser runs on the fleet: Playwright streams it back over the session.
-   **Replay is near-instant and offline:** the page came back with the same 20 books in a fraction of the live time — no proxy traffic, no site changes between runs.
-   **`notFound: "abort"` keeps replays honest:** a page that wasn't recorded is refused instead of fetched, so a test can't quietly depend on the live site.
-   **Use it for development and tests,** not for fresh data: a HAR is a snapshot. Record again when the site changes.

---
Source: https://cdpfleet.com/docs/cases/har-record-and-replay · Markdown: https://cdpfleet.com/docs/cases/har-record-and-replay.md · All docs for AI tools: https://cdpfleet.com/llms.txt
