Cases / #34 · 2026-10-11 · Medium
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.
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
- Launch Chromium; open a context that records a HAR and load the page.
- Close the context (the HAR is written) and count its entries.
- Open a new context that answers from the HAR, load the same page, and count what was served and refused.
- Try a page the HAR doesn't contain.
The code
The same program in five languages (also on GitHub, 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:[email protected]:8000
// npm install [email protected]
// 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 });
}
# 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)
// 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());
}
}
}
}
}
// 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 get github.com/playwright-community/[email protected]
// 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.