Examples & benchmarks / #1 · 2026-09-30 · Benchmark · Medium · for web scraping, testing
What your browser says before it says anything: TLS and HTTP/2 fingerprints
Five engines, one endpoint (tls.peet.ws), and the fingerprints anti-bot systems read before your page even loads.
Google Chrome Microsoft Edge Firefox Camoufox WebKit
Run on production on 2026-10-22: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
A benchmark: the results below are what this setup measured on the dates shown, on our own test runs. Sites, networks and browsers change, so rerun the code before you rely on the numbers.
The problem
Anti-bot systems don't wait for JavaScript. The first thing a server sees is the TLS ClientHello (cipher suites, extensions, their order) and, on HTTP/2, the SETTINGS and WINDOW_UPDATE frames. Those are condensed into fingerprints — JA3/JA4 for TLS, the Akamai HTTP/2 fingerprint for h2 — and compared with what the user agent claims to be. A Chrome user agent over a Python-requests TLS stack is flagged instantly. So: what does each cdpfleet engine actually send?
What we used, and why
| What | Why |
|---|---|
chrome, edge, firefox, camoufox, webkit | One of each engine family, to compare Chromium, Gecko and WebKit network stacks. |
proxy | Every session needs one; fingerprints are made by the browser, not the proxy, so any proxy works. |
headless: true | TLS and HTTP/2 don't change between headless and headful — the cheapest mode (1 thread) is enough. |
page.goto(tls.peet.ws/api/all) | The browser itself makes the request. page.request / APIRequest would go out through Playwright's own HTTP client, with a different fingerprint. |
How it works
- Launch each browser with a proxy.
- Navigate to
https://tls.peet.ws/api/alland read the JSON the page returns. - Keep the user agent, HTTP version, JA4, JA3 hash, peetprint and Akamai HTTP/2 fingerprint.
- Close the browser — that ends the session and the billing.
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 (http://user:pass@host:port)
import { chromium, firefox, webkit } from 'playwright';
const KEY = process.env.CDPFLEET_API_KEY;
const PROXY = process.env.PROXY_URL;
// One browser per engine family. `family` is the Playwright client that speaks to it.
const BROWSERS = [
{ name: 'chrome', family: chromium },
{ name: 'edge', family: chromium },
{ name: 'firefox', family: firefox },
{ name: 'camoufox', family: firefox },
{ name: 'webkit', family: webkit },
];
async function launch(name, options) {
const res = await fetch(`https://starter.cdpfleet.com/${name}/session`, {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify(options),
});
if (!res.ok) throw new Error(`launch ${name}: ${res.status} ${await res.text()}`);
return res.json();
}
// Navigate the browser itself to tls.peet.ws: the JSON it returns describes the TLS
// ClientHello and HTTP/2 frames this very browser sent. (page.request would go out from
// Playwright's own HTTP client instead, with a different fingerprint.)
// A residential exit occasionally times out: one retry, in a fresh tab.
async function fingerprint(browser) {
for (let attempt = 1; ; attempt++) {
try {
const page = await browser.newPage();
return await (await page.goto('https://tls.peet.ws/api/all', { timeout: 30000 })).json();
} catch (err) {
if (attempt === 2) throw err;
}
}
}
const rows = [];
for (const b of BROWSERS) {
const session = await launch(b.name, { proxy: PROXY, headless: true });
const browser = await b.family.connect(session.wsUrl, { headers: { 'x-api-key': KEY } });
try {
const fp = await fingerprint(browser);
rows.push({
browser: b.name,
version: browser.version(),
user_agent: fp.user_agent,
http_version: fp.http_version,
ja4: fp.tls.ja4,
ja3_hash: fp.tls.ja3_hash,
peetprint_hash: fp.tls.peetprint_hash,
akamai_h2: fp.http2?.akamai_fingerprint ?? null,
akamai_h2_hash: fp.http2?.akamai_fingerprint_hash ?? null,
cipher_suites: fp.tls.ciphers.length,
extensions: fp.tls.extensions.length,
});
} finally {
await browser.close(); // ends the session and stops billing
}
}
console.log(JSON.stringify(rows, null, 2));
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL (http://user:pass@host:port)
import json
import os
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
PROXY = os.environ["PROXY_URL"]
# One browser per engine family, and the Playwright client that speaks to it.
BROWSERS = [("chrome", "chromium"), ("edge", "chromium"), ("firefox", "firefox"),
("camoufox", "firefox"), ("webkit", "webkit")]
def launch(name, options):
res = requests.post(f"https://starter.cdpfleet.com/{name}/session",
headers={"x-api-key": KEY}, json=options, timeout=60)
res.raise_for_status()
return res.json()
def fingerprint(browser):
# Navigate the browser itself: the JSON describes the TLS ClientHello and HTTP/2
# frames this very browser sent (page.request would use Playwright's own client).
# A residential exit occasionally times out: one retry, in a fresh tab.
for attempt in (1, 2):
try:
return browser.new_page().goto("https://tls.peet.ws/api/all", timeout=30000).json()
except Exception:
if attempt == 2:
raise
rows = []
with sync_playwright() as p:
for name, family in BROWSERS:
session = launch(name, {"proxy": PROXY, "headless": True})
browser = getattr(p, family).connect(session["wsUrl"], headers={"x-api-key": KEY})
try:
fp = fingerprint(browser)
http2 = fp.get("http2") or {}
rows.append({
"browser": name,
"version": browser.version,
"user_agent": fp["user_agent"],
"http_version": fp["http_version"],
"ja4": fp["tls"]["ja4"],
"ja3_hash": fp["tls"]["ja3_hash"],
"peetprint_hash": fp["tls"]["peetprint_hash"],
"akamai_h2": http2.get("akamai_fingerprint"),
"akamai_h2_hash": http2.get("akamai_fingerprint_hash"),
"cipher_suites": len(fp["tls"]["ciphers"]),
"extensions": len(fp["tls"]["extensions"]),
})
finally:
browser.close() # ends the session and stops billing
print(json.dumps(rows, indent=2))
// 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 java.net.URI;
import java.net.http.*;
import java.util.Map;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final HttpClient HTTP = HttpClient.newHttpClient();
static JsonObject launch(String name, JsonObject options) throws Exception {
HttpResponse<String> res = HTTP.send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/" + name + "/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(options.toString())).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new RuntimeException("launch " + name + ": " + res.statusCode() + " " + res.body());
return JsonParser.parseString(res.body()).getAsJsonObject();
}
// A residential exit occasionally times out: one retry, in a fresh tab.
static JsonObject fingerprint(Browser browser) {
for (int attempt = 1; ; attempt++) {
try {
Response res = browser.newPage().navigate("https://tls.peet.ws/api/all", new Page.NavigateOptions().setTimeout(30000));
return JsonParser.parseString(res.text()).getAsJsonObject();
} catch (PlaywrightException err) {
if (attempt == 2) throw err;
}
}
}
public static void main(String[] args) throws Exception {
// One browser per engine family, and the Playwright client that speaks to it.
String[][] browsers = {{"chrome", "chromium"}, {"edge", "chromium"}, {"firefox", "firefox"}, {"camoufox", "firefox"}, {"webkit", "webkit"}};
JsonArray rows = new JsonArray();
try (Playwright playwright = Playwright.create()) {
for (String[] b : browsers) {
JsonObject options = new JsonObject();
options.addProperty("proxy", System.getenv("PROXY_URL"));
options.addProperty("headless", true);
JsonObject session = launch(b[0], options);
BrowserType family = switch (b[1]) { case "firefox" -> playwright.firefox(); case "webkit" -> playwright.webkit(); default -> playwright.chromium(); };
Browser browser = family.connect(session.get("wsUrl").getAsString(), new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
try {
// Navigate the browser itself: the JSON describes the TLS ClientHello and HTTP/2
// frames this very browser sent (APIRequest would use Playwright's own client).
JsonObject fp = fingerprint(browser);
JsonObject tls = fp.getAsJsonObject("tls");
JsonObject h2 = fp.has("http2") && fp.get("http2").isJsonObject() ? fp.getAsJsonObject("http2") : new JsonObject();
JsonObject row = new JsonObject();
row.addProperty("browser", b[0]);
row.addProperty("version", browser.version());
row.add("user_agent", fp.get("user_agent"));
row.add("http_version", fp.get("http_version"));
row.add("ja4", tls.get("ja4"));
row.add("ja3_hash", tls.get("ja3_hash"));
row.add("peetprint_hash", tls.get("peetprint_hash"));
row.add("akamai_h2", h2.has("akamai_fingerprint") ? h2.get("akamai_fingerprint") : JsonNull.INSTANCE);
row.add("akamai_h2_hash", h2.has("akamai_fingerprint_hash") ? h2.get("akamai_fingerprint_hash") : JsonNull.INSTANCE);
row.addProperty("cipher_suites", tls.getAsJsonArray("ciphers").size());
row.addProperty("extensions", tls.getAsJsonArray("extensions").size());
rows.add(row);
} finally {
browser.close(); // ends the session and stops billing
}
}
}
System.out.println(new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create().toJson(rows));
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL (http://user:pass@host:port)
using System.Net.Http.Json;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
var proxy = Environment.GetEnvironmentVariable("PROXY_URL")!;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();
// One browser per engine family, and the Playwright client that speaks to it.
var browsers = new (string Name, IBrowserType Family)[]
{
("chrome", playwright.Chromium), ("edge", playwright.Chromium), ("firefox", playwright.Firefox),
("camoufox", playwright.Firefox), ("webkit", playwright.Webkit),
};
// A residential exit occasionally times out: one retry, in a fresh tab.
async Task<JsonNode> Fingerprint(IBrowser browser)
{
for (var attempt = 1; ; attempt++)
{
try
{
var page = await browser.NewPageAsync();
return JsonNode.Parse(await (await page.GotoAsync("https://tls.peet.ws/api/all", new() { Timeout = 30000 }))!.TextAsync())!;
}
catch (Exception err) when ((err is PlaywrightException or TimeoutException) && attempt < 2) { }
}
}
var rows = new JsonArray();
foreach (var (name, family) in browsers)
{
var res = await http.PostAsJsonAsync($"https://starter.cdpfleet.com/{name}/session", new { proxy, headless = true });
if (!res.IsSuccessStatusCode) throw new Exception($"launch {name}: {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
var wsUrl = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("wsUrl").GetString()!;
var browser = await family.ConnectAsync(wsUrl, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
try
{
// Navigate the browser itself: the JSON describes the TLS ClientHello and HTTP/2
// frames this very browser sent (APIRequest would use Playwright's own client).
var fp = await Fingerprint(browser);
var tls = fp["tls"]!;
var h2 = fp["http2"] as JsonObject;
rows.Add(new JsonObject
{
["browser"] = name,
["version"] = browser.Version,
["user_agent"] = fp["user_agent"]?.DeepClone(),
["http_version"] = fp["http_version"]?.DeepClone(),
["ja4"] = tls["ja4"]?.DeepClone(),
["ja3_hash"] = tls["ja3_hash"]?.DeepClone(),
["peetprint_hash"] = tls["peetprint_hash"]?.DeepClone(),
["akamai_h2"] = h2?["akamai_fingerprint"]?.DeepClone(),
["akamai_h2_hash"] = h2?["akamai_fingerprint_hash"]?.DeepClone(),
["cipher_suites"] = tls["ciphers"]!.AsArray().Count,
["extensions"] = tls["extensions"]!.AsArray().Count,
});
}
finally
{
await browser.CloseAsync(); // ends the session and stops billing
}
}
Console.WriteLine(rows.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
// 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 (http://user:pass@host:port)
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
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 row struct {
Browser string `json:"browser"`
Version string `json:"version"`
UserAgent string `json:"user_agent"`
HTTPVersion string `json:"http_version"`
JA4 string `json:"ja4"`
JA3Hash string `json:"ja3_hash"`
PeetprintHash string `json:"peetprint_hash"`
AkamaiH2 *string `json:"akamai_h2"`
AkamaiH2Hash *string `json:"akamai_h2_hash"`
CipherSuites int `json:"cipher_suites"`
Extensions int `json:"extensions"`
}
// The part of tls.peet.ws/api/all we use.
type peet struct {
UserAgent string `json:"user_agent"`
HTTPVersion string `json:"http_version"`
TLS struct {
JA4 string `json:"ja4"`
JA3Hash string `json:"ja3_hash"`
PeetprintHash string `json:"peetprint_hash"`
Ciphers []any `json:"ciphers"`
Extensions []any `json:"extensions"`
} `json:"tls"`
HTTP2 *struct {
AkamaiFingerprint string `json:"akamai_fingerprint"`
AkamaiFingerprintHash string `json:"akamai_fingerprint_hash"`
} `json:"http2"`
}
// Navigate the browser itself: the JSON describes the TLS ClientHello and HTTP/2
// frames this very browser sent (APIRequest would use Playwright's own client).
// A residential exit occasionally times out: one retry, in a fresh tab.
func fingerprint(browser playwright.Browser) (fp peet, err error) {
for attempt := 1; attempt <= 2; attempt++ {
page, _ := browser.NewPage()
var res playwright.Response
if res, err = page.Goto("https://tls.peet.ws/api/all", playwright.PageGotoOptions{Timeout: playwright.Float(30000)}); err == nil {
return fp, res.JSON(&fp)
}
}
return fp, err
}
func main() {
pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
log.Fatal(err)
}
defer pw.Stop()
// One browser per engine family, and the Playwright client that speaks to it.
browsers := []struct {
name string
family playwright.BrowserType
}{{"chrome", pw.Chromium}, {"edge", pw.Chromium}, {"firefox", pw.Firefox}, {"camoufox", pw.Firefox}, {"webkit", pw.WebKit}}
rows := []row{}
for _, b := range browsers {
session, err := launch(b.name, map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": true})
if err != nil {
log.Fatal(err)
}
browser, err := b.family.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
if err != nil {
log.Fatal(err)
}
fp, err := fingerprint(browser)
if err != nil {
log.Fatal(err)
}
r := row{Browser: b.name, Version: browser.Version(), UserAgent: fp.UserAgent, HTTPVersion: fp.HTTPVersion,
JA4: fp.TLS.JA4, JA3Hash: fp.TLS.JA3Hash, PeetprintHash: fp.TLS.PeetprintHash,
CipherSuites: len(fp.TLS.Ciphers), Extensions: len(fp.TLS.Extensions)}
if fp.HTTP2 != nil {
r.AkamaiH2, r.AkamaiH2Hash = &fp.HTTP2.AkamaiFingerprint, &fp.HTTP2.AkamaiFingerprintHash
}
rows = append(rows, r)
browser.Close() // ends the session and stops billing
}
out, _ := json.MarshalIndent(rows, "", " ")
fmt.Println(string(out))
}
What we got
| Browser | Version | HTTP | JA4 | Akamai h2 hash | Ciphers | Extensions |
|---|---|---|---|---|---|---|
| chrome | 156.0.8078.47 | h2 | t13d1517h2_8daaf6152771_cb7bf5808d99 | 52d84b11737d980aef856699f885ca86 | 16 | 19 |
| edge | 155.0.4283.62 | h2 | t13d1516h2_8daaf6152771_806a8c22fdea | 52d84b11737d980aef856699f885ca86 | 16 | 18 |
| firefox | 150.0.2 | h2 | t13d1617h2_86a278354501_3cbfd9057e0d | 6ea73faa8fc5aac76bded7bd238f6433 | 16 | 17 |
| camoufox | 156.0.1-beta.36 | h2 | t13d1517h2_8daaf6152771_3cbfd9057e0d | 6ea73faa8fc5aac76bded7bd238f6433 | 15 | 17 |
| webkit | 26.4 | HTTP/1.1 | t13d2913h1_723694b0fccc_5671b5df5029 | — | 29 | 13 |
From the Node.js run on 2026-10-22. 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)
[
{
"browser": "chrome",
"version": "156.0.8078.47",
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.0.0 Safari/537.36",
"http_version": "h2",
"ja4": "t13d1517h2_8daaf6152771_cb7bf5808d99",
"ja3_hash": "2b08ceee34bbfcc192bbe4cea3957eb8",
"peetprint_hash": "fc97c1cdfb1409c9a9326c1b726d1dee",
"akamai_h2": "1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
"akamai_h2_hash": "52d84b11737d980aef856699f885ca86",
"cipher_suites": 16,
"extensions": 19
},
{
"browser": "edge",
"version": "155.0.4283.62",
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/155.0.0.0 Safari/537.36 Edg/155.0.0.0",
"http_version": "h2",
"ja4": "t13d1516h2_8daaf6152771_806a8c22fdea",
"ja3_hash": "472c42c2ef6930b04e3c6ed9cd413b26",
"peetprint_hash": "8f568b2a409b82d0ec24cb84bf3bc3fc",
"akamai_h2": "1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
"akamai_h2_hash": "52d84b11737d980aef856699f885ca86",
"cipher_suites": 16,
"extensions": 18
},
{
"browser": "firefox",
"version": "150.0.2",
"user_agent": "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0",
"http_version": "h2",
"ja4": "t13d1617h2_86a278354501_3cbfd9057e0d",
"ja3_hash": "6447ab086255d194909d4013b1a89e87",
"peetprint_hash": "fd4547eeb41f073156b7bc8125a79a3c",
"akamai_h2": "1:65536;2:0;4:131072;5:16384|12517377|0|m,p,a,s",
"akamai_h2_hash": "6ea73faa8fc5aac76bded7bd238f6433",
"cipher_suites": 16,
"extensions": 17
},
{
"browser": "camoufox",
"version": "156.0.1-beta.36",
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:156.0) Gecko/20100101 Firefox/156.0",
"http_version": "h2",
"ja4": "t13d1517h2_8daaf6152771_3cbfd9057e0d",
"ja3_hash": "9d42e90b0225e779f03141ddcd699df2",
"peetprint_hash": "a4bbbc7e3adf3db97d276fd0adb1efc4",
"akamai_h2": "1:65536;2:0;4:131072;5:16384|12517377|0|m,p,a,s",
"akamai_h2_hash": "6ea73faa8fc5aac76bded7bd238f6433",
"cipher_suites": 15,
"extensions": 17
},
{
"browser": "webkit",
"version": "26.4",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Safari/605.1.15",
"http_version": "HTTP/1.1",
"ja4": "t13d2913h1_723694b0fccc_5671b5df5029",
"ja3_hash": "2cbcec37dfff29e4c6a34dd35ebe1d70",
"peetprint_hash": "e2a4f6ec1c75767498bc87d934bfccec",
"akamai_h2": null,
"akamai_h2_hash": null,
"cipher_suites": 29,
"extensions": 13
}
]Takeaways
- Each engine sends its own real fingerprint. Chrome's JA4 and HTTP/2 settings are Chrome's; Firefox's are Firefox's. Nothing to patch.
- Chrome and Edge differ by one extension (JA4
…1517…vs…1516…) but share the HTTP/2 fingerprint — both are Chromium network stacks. - Camoufox picks a random OS for its user agent in every session (set
osto choose one) — and whatever it claims, its TLS is the same Firefox stack. That's fine: Firefox's TLS (NSS) is identical on every OS, so a real Windows or Mac Firefox of that version sends exactly this. - Since Camoufox 156 (fleet default from 2026-10-21) its JA4 no longer equals the fleet's stock Firefox 150. Firefox 156 underneath offers 15 cipher suites instead of 16, so JA4's first two parts change (the cipher part now hashes the same as Chrome's), while the extension part and the HTTP/2 fingerprint are still Firefox's. A site comparing JA4 with the user agent sees a consistent Firefox 156; match
versionand user agent if you pin either. - WebKit speaks HTTP/1.1 through a proxy, with 29 cipher suites — nothing like a browser on a real Mac (HTTP/2, fewer ciphers). Use WebKit for rendering tests, not to impersonate another browser.
- Fingerprints are stable per build: read them once per version, not per request.