[Examples & benchmarks](https://cdpfleet.com/docs/cases) / #84 · 2026-11-06 · Benchmark · Medium · for [web scraping](https://cdpfleet.com/web-scraping)

# Does the TLS fingerprint (JA4) explain which browsers got in? Twelve browsers against six vendors

Each of the twelve browsers from the real-site lab week loads a TLS echo page once, so we know the JA4, peetprint and HTTP/2 fingerprint it sends. Grouped by identical JA4, their recorded outcomes against Cloudflare, DataDome, Akamai, HUMAN, Kasada and AWS WAF (2026-10-26..30) show whether the fingerprint lines up with who got the page. Fingerprints measured 2026-11-06.

[Google Chrome](https://cdpfleet.com/docs/browsers/chrome) [Microsoft Edge](https://cdpfleet.com/docs/browsers/edge) [Brave](https://cdpfleet.com/docs/browsers/brave) [Opera](https://cdpfleet.com/docs/browsers/opera) [Yandex Browser](https://cdpfleet.com/docs/browsers/yandex) [Naver Whale](https://cdpfleet.com/docs/browsers/whale) [Chromium](https://cdpfleet.com/docs/browsers/chromium) [Patchright](https://cdpfleet.com/docs/browsers/patchright) [Firefox](https://cdpfleet.com/docs/browsers/firefox) [WebKit](https://cdpfleet.com/docs/browsers/webkit) [Camoufox](https://cdpfleet.com/docs/browsers/camoufox)

Run on production on 2026-11-06: ✓ 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

A common explanation for "this browser gets blocked and that one doesn't" is the TLS fingerprint: the ClientHello a browser sends, summarised as a JA4 string, plus how it opens HTTP/2. Our real-site lab week (2026-10-26 to 10-30) recorded which of twelve browsers got the page behind [Cloudflare](https://cdpfleet.com/docs/cases/cloudflare-real-sites-by-browser), [DataDome](https://cdpfleet.com/docs/cases/datadome-real-sites-by-browser), [Akamai](https://cdpfleet.com/docs/cases/akamai-real-sites-by-browser), [HUMAN](https://cdpfleet.com/docs/cases/human-real-sites-by-browser), [Kasada and AWS WAF](https://cdpfleet.com/docs/cases/kasada-and-aws-waf-by-browser) on 16 real sites. This case measures the fingerprint each of those twelve setups sends and lines it up with those recorded outcomes: do browsers that send the same JA4 get the same answer, and do different JA4s get different ones? It measures a correlation in one small sample and claims nothing beyond it.

## What we used, and why

| What | Why |
| --- | --- |
| The twelve setups of the lab week | Chrome headful and headless, Edge, Brave, Opera, Yandex, Whale, headless Chromium and Patchright (all Chromium-based), Firefox, WebKit and Camoufox (based on Firefox source; not affiliated with Mozilla), launched exactly as in cases 70-77. The returning Camoufox profile of those cases is the same Camoufox build and isn't measured or compared separately. |
| `tls.peet.ws/api/all` | A TLS echo page: it answers with the JA4, the peetprint (a fuller TLS fingerprint) and the Akamai-format HTTP/2 fingerprint of the connection it received. The browser loads it itself with `page.goto()` — Playwright's own `request` client would send a different fingerprint. One load per setup. If it doesn't answer (it has had outages), `tls.browserleaks.com/json` is loaded instead (JA4 and HTTP/2, no peetprint); every published run got its answer from tls.peet.ws. (In one unpublished Java run tls.peet.ws didn't answer Camoufox; tls.browserleaks.com returned the same JA4.) |
| `proxy`: `cdpfleet-resi-country-us-session-<id>` | One sticky residential exit for the whole run, one session at a time, like the lab week. A proxy tunnel passes the browser's own TLS handshake through; our per-version snapshots, measured through a different proxy, got the same fingerprints. |
| The lab week's stored results | Not re-measured: the counts come from the five runs (one per language) stored in cases 70, 71, 73, 75 and 77, counted like the [scoreboard](https://cdpfleet.com/bot-protection/scoreboard): got in = the real page, or an automatic check that cleared by itself; network errors left out. |
| Our per-version snapshots | Every browser build is measured once on tls.peet.ws when it first appears on the fleet. They show which fingerprint each setup's build sent during the lab week, so a browser update between then and today can't be mistaken for a difference. |

## How it works

1.  For each of the twelve setups, one after another: launch it through the sticky residential exit, open a new context, load the TLS echo page once and keep its JA4, peetprint and HTTP/2 fingerprint with the browser build.
2.  Print every setup's fingerprint.
3.  Group the setups by the JA4 they sent in the lab week (today's, checked against the week's snapshots; Yandex by its snapshot, see below).
4.  Per vendor and per site, count how often the members of each group got in, and list the pairs with the identical JA4, peetprint and HTTP/2 fingerprint where one got in on every load and the other on none.

## The code

The same program in five languages (also [on GitHub](https://github.com/cdpfleet/cdpfleet/tree/main/cases/84-ja4-vs-vendor-verdicts), with the raw output). Set these environment variables first:

-   `CDPFLEET_API_KEY` — your API key (dashboard → API keys)

 

```node
// npm install playwright@1.60.0
// env: CDPFLEET_API_KEY
import { randomBytes } from 'node:crypto';
import { chromium, firefox, webkit } from 'playwright';

const KEY = process.env.CDPFLEET_API_KEY;
const RUN = randomBytes(3).toString('hex');
// The same twelve setups as the real-site lab cases 70-77 (Rebrowser needs a 1.52 client, so it's left out).
const SETUPS = [
  ['chrome_headful', 'Chrome, headful', 'chrome', chromium, false],
  ['chrome_headless', 'Chrome, headless', 'chrome', chromium, 'new'],
  ['edge', 'Edge, headful', 'edge', chromium, false],
  ['brave', 'Brave, headful', 'brave', chromium, false],
  ['opera', 'Opera, headful', 'opera', chromium, false],
  ['yandex', 'Yandex, headful', 'yandex', chromium, false],
  ['whale', 'Whale, headful', 'whale', chromium, false],
  ['chromium_headless', 'Chromium, headless', 'chromium', chromium, true],
  ['patchright', 'Patchright, headful', 'patchright', chromium, false],
  ['firefox', 'Firefox, headful', 'firefox', firefox, false],
  ['webkit', 'WebKit, headful', 'webkit', webkit, false],
  ['camoufox', 'Camoufox, headful', 'camoufox', firefox, false],
];

async function launch(engine, headless, proxy) {
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(`https://starter.cdpfleet.com/${engine}/session`, {
      method: 'POST',
      headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
      body: JSON.stringify({ proxy, headless, record: false }),
    });
    if (res.ok) return (await res.json()).wsUrl;
    if (![429, 503].includes(res.status) || attempt === 5) throw new Error(`launch ${res.status} ${await res.text()}`);
    await new Promise((r) => setTimeout(r, (Number(res.headers.get('retry-after')) || 5) * 1000));
  }
}

// The browser itself loads a TLS echo page, which answers with the ClientHello and HTTP/2 frames
// it received from this very browser. tls.peet.ws first; tls.browserleaks.com/json only if peet
// doesn't answer (it has had outages). Each is one page load in a fresh tab.
const ECHOES = [
  ['tls.peet.ws', 'https://tls.peet.ws/api/all', (j) => ({ http_version: j.http_version, ja4: j.tls?.ja4, ja4_r: j.tls?.ja4_r ?? null, peetprint_hash: j.tls?.peetprint_hash ?? null, akamai_h2: j.http2?.akamai_fingerprint ?? null, akamai_h2_hash: j.http2?.akamai_fingerprint_hash ?? null, user_agent: j.user_agent })],
  ['tls.browserleaks.com', 'https://tls.browserleaks.com/json', (j) => ({ http_version: null, ja4: j.ja4, ja4_r: j.ja4_r || null, peetprint_hash: null, akamai_h2: j.akamai_text || null, akamai_h2_hash: j.akamai_hash || null, user_agent: j.user_agent })],
];

async function fingerprint(browser) {
  const context = await browser.newContext();
  let last = null;
  for (const [source, url, read] of ECHOES) {
    const tab = await context.newPage();
    try {
      const res = await tab.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
      const fp = read(JSON.parse(await res.text()));
      if (fp.ja4) return { source, ...fp };
      last = `${source}: no ja4 in the answer`;
    } catch (err) {
      last = `${source}: ${err.message.split('\n')[0]}`;
    } finally {
      await tab.close().catch(() => {});
    }
  }
  throw new Error(last);
}

// One sticky residential exit for the whole run, and one session at a time.
const proxy = `cdpfleet-resi-country-us-session-c84${RUN}-lifetime-30`;
const fingerprints = {};
for (const [id, , engine, type, headless] of SETUPS) {
  try {
    const browser = await type.connect(await launch(engine, headless, proxy), { headers: { 'x-api-key': KEY } });
    try {
      fingerprints[id] = { version: browser.version(), ...(await fingerprint(browser)) };
    } finally {
      await browser.close();
    }
  } catch (err) {
    fingerprints[id] = { error: err.message.split('\n')[0].slice(0, 200) };
  }
}
console.log(JSON.stringify({ measured_at: new Date().toISOString().slice(0, 10), setups: Object.fromEntries(SETUPS.map((s) => [s[0], s[1]])), fingerprints }, null, 2));
```

```python
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY
import json
import os
import secrets
import time
from datetime import datetime, timezone

import requests
from playwright.sync_api import sync_playwright

KEY = os.environ["CDPFLEET_API_KEY"]
RUN = secrets.token_hex(3)
# The same twelve setups as the real-site lab cases 70-77 (Rebrowser needs a 1.52 client, so it's left out).
SETUPS = [
    ("chrome_headful", "Chrome, headful", "chrome", "chromium", False),
    ("chrome_headless", "Chrome, headless", "chrome", "chromium", "new"),
    ("edge", "Edge, headful", "edge", "chromium", False),
    ("brave", "Brave, headful", "brave", "chromium", False),
    ("opera", "Opera, headful", "opera", "chromium", False),
    ("yandex", "Yandex, headful", "yandex", "chromium", False),
    ("whale", "Whale, headful", "whale", "chromium", False),
    ("chromium_headless", "Chromium, headless", "chromium", "chromium", True),
    ("patchright", "Patchright, headful", "patchright", "chromium", False),
    ("firefox", "Firefox, headful", "firefox", "firefox", False),
    ("webkit", "WebKit, headful", "webkit", "webkit", False),
    ("camoufox", "Camoufox, headful", "camoufox", "firefox", False),
]

def launch(engine, headless, proxy):
    for attempt in range(1, 6):
        res = requests.post(f"https://starter.cdpfleet.com/{engine}/session", headers={"x-api-key": KEY},
                            json={"proxy": proxy, "headless": headless, "record": False}, timeout=120)
        if res.ok:
            return res.json()["wsUrl"]
        if res.status_code not in (429, 503) or attempt == 5:
            raise RuntimeError(f"launch {res.status_code} {res.text}")
        time.sleep(int(res.headers.get("retry-after") or 5))

def from_peet(j):
    tls, h2 = j.get("tls") or {}, j.get("http2") or {}
    return {"http_version": j.get("http_version"), "ja4": tls.get("ja4"), "ja4_r": tls.get("ja4_r"), "peetprint_hash": tls.get("peetprint_hash"),
            "akamai_h2": h2.get("akamai_fingerprint"), "akamai_h2_hash": h2.get("akamai_fingerprint_hash"), "user_agent": j.get("user_agent")}

def from_browserleaks(j):
    return {"http_version": None, "ja4": j.get("ja4"), "ja4_r": j.get("ja4_r") or None, "peetprint_hash": None,
            "akamai_h2": j.get("akamai_text") or None, "akamai_h2_hash": j.get("akamai_hash") or None, "user_agent": j.get("user_agent")}

# The browser itself loads a TLS echo page, which answers with the ClientHello and HTTP/2 frames
# it received from this very browser. tls.peet.ws first; tls.browserleaks.com/json only if peet
# doesn't answer (it has had outages). Each is one page load in a fresh tab.
ECHOES = [
    ("tls.peet.ws", "https://tls.peet.ws/api/all", from_peet),
    ("tls.browserleaks.com", "https://tls.browserleaks.com/json", from_browserleaks),
]

def fingerprint(browser):
    context = browser.new_context()
    last = None
    for source, url, read in ECHOES:
        tab = context.new_page()
        try:
            res = tab.goto(url, wait_until="domcontentloaded", timeout=30000)
            fp = read(json.loads(res.text()))
            if fp["ja4"]:
                return {"source": source, **fp}
            last = f"{source}: no ja4 in the answer"
        except Exception as err:
            last = f"{source}: {str(err).splitlines()[0]}"
        finally:
            try:
                tab.close()
            except Exception:
                pass
    raise RuntimeError(last)

# One sticky residential exit for the whole run, and one session at a time.
proxy = f"cdpfleet-resi-country-us-session-c84{RUN}-lifetime-30"
fingerprints = {}
with sync_playwright() as pw:
    for sid, _, engine, kind, headless in SETUPS:
        try:
            browser = getattr(pw, kind).connect(launch(engine, headless, proxy), headers={"x-api-key": KEY})
            try:
                fingerprints[sid] = {"version": browser.version, **fingerprint(browser)}
            finally:
                browser.close()
        except Exception as err:
            fingerprints[sid] = {"error": str(err).splitlines()[0][:200]}

print(json.dumps({"measured_at": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
                  "setups": {s[0]: s[1] for s in SETUPS}, "fingerprints": fingerprints}, indent=2))
```

```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
import com.google.gson.*;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.WaitUntilState;
import java.net.URI;
import java.net.http.*;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.*;
import java.util.function.Function;

public class Main {
  static final String KEY = System.getenv("CDPFLEET_API_KEY");
  static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().serializeNulls().create();
  static final HttpClient HTTP = HttpClient.newHttpClient();
  static final String RUN = String.format("%06x", new SecureRandom().nextInt(1 << 24));

  record Setup(String id, String name, String engine, String type, Object headless) {}

  // The same twelve setups as the real-site lab cases 70-77 (Rebrowser needs a 1.52 client, so it's left out).
  static final List<Setup> SETUPS = List.of(
      new Setup("chrome_headful", "Chrome, headful", "chrome", "chromium", false),
      new Setup("chrome_headless", "Chrome, headless", "chrome", "chromium", "new"),
      new Setup("edge", "Edge, headful", "edge", "chromium", false),
      new Setup("brave", "Brave, headful", "brave", "chromium", false),
      new Setup("opera", "Opera, headful", "opera", "chromium", false),
      new Setup("yandex", "Yandex, headful", "yandex", "chromium", false),
      new Setup("whale", "Whale, headful", "whale", "chromium", false),
      new Setup("chromium_headless", "Chromium, headless", "chromium", "chromium", true),
      new Setup("patchright", "Patchright, headful", "patchright", "chromium", false),
      new Setup("firefox", "Firefox, headful", "firefox", "firefox", false),
      new Setup("webkit", "WebKit, headful", "webkit", "webkit", false),
      new Setup("camoufox", "Camoufox, headful", "camoufox", "firefox", false));

  static String launch(String engine, Object headless, String proxy) throws Exception {
    Map<String, Object> body = new LinkedHashMap<>(Map.of("proxy", proxy, "headless", headless, "record", false));
    for (int attempt = 1; ; attempt++) {
      HttpRequest req = HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/" + engine + "/session"))
          .header("x-api-key", KEY).header("content-type", "application/json")
          .POST(HttpRequest.BodyPublishers.ofString(GSON.toJson(body))).build();
      HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
      if (res.statusCode() == 200) return JsonParser.parseString(res.body()).getAsJsonObject().get("wsUrl").getAsString();
      if ((res.statusCode() != 429 && res.statusCode() != 503) || attempt == 5) throw new RuntimeException("launch " + res.statusCode() + " " + res.body());
      Thread.sleep(Long.parseLong(res.headers().firstValue("retry-after").orElse("5")) * 1000);
    }
  }

  // A string field of a JSON object (nested path), or null when missing or empty.
  static String str(JsonObject o, String... path) {
    JsonElement e = o;
    for (String p : path) {
      if (e == null || !e.isJsonObject()) return null;
      e = e.getAsJsonObject().get(p);
    }
    return e == null || e.isJsonNull() || e.getAsString().isEmpty() ? null : e.getAsString();
  }

  static Map<String, Object> fp(String http, String ja4, String ja4r, String peet, String h2, String h2Hash, String ua) {
    Map<String, Object> m = new LinkedHashMap<>();
    m.put("http_version", http);
    m.put("ja4", ja4);
    m.put("ja4_r", ja4r);
    m.put("peetprint_hash", peet);
    m.put("akamai_h2", h2);
    m.put("akamai_h2_hash", h2Hash);
    m.put("user_agent", ua);
    return m;
  }

  record Echo(String source, String url, Function<JsonObject, Map<String, Object>> read) {}

  // The browser itself loads a TLS echo page, which answers with the ClientHello and HTTP/2 frames
  // it received from this very browser. tls.peet.ws first; tls.browserleaks.com/json only if peet
  // doesn't answer (it has had outages). Each is one page load in a fresh tab.
  static final List<Echo> ECHOES = List.of(
      new Echo("tls.peet.ws", "https://tls.peet.ws/api/all", j -> fp(str(j, "http_version"), str(j, "tls", "ja4"), str(j, "tls", "ja4_r"),
          str(j, "tls", "peetprint_hash"), str(j, "http2", "akamai_fingerprint"), str(j, "http2", "akamai_fingerprint_hash"), str(j, "user_agent"))),
      new Echo("tls.browserleaks.com", "https://tls.browserleaks.com/json", j -> fp(null, str(j, "ja4"), str(j, "ja4_r"),
          null, str(j, "akamai_text"), str(j, "akamai_hash"), str(j, "user_agent"))));

  static Map<String, Object> fingerprint(Browser browser) {
    BrowserContext context = browser.newContext();
    String last = null;
    for (Echo echo : ECHOES) {
      Page tab = context.newPage();
      try {
        Response res = tab.navigate(echo.url(), new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED).setTimeout(30000));
        Map<String, Object> f = echo.read().apply(JsonParser.parseString(res.text()).getAsJsonObject());
        if (f.get("ja4") != null) {
          Map<String, Object> m = new LinkedHashMap<>();
          m.put("source", echo.source());
          m.putAll(f);
          return m;
        }
        last = echo.source() + ": no ja4 in the answer";
      } catch (Exception e) {
        last = echo.source() + ": " + String.valueOf(e.getMessage()).split("\n")[0];
      } finally {
        try { tab.close(); } catch (Exception ignored) {}
      }
    }
    throw new RuntimeException(last);
  }

  public static void main(String[] args) {
    // One sticky residential exit for the whole run, and one session at a time.
    String proxy = "cdpfleet-resi-country-us-session-c84" + RUN + "-lifetime-30";
    Map<String, Object> fingerprints = new LinkedHashMap<>();
    try (Playwright pw = Playwright.create()) {
      Map<String, BrowserType> types = Map.of("chromium", pw.chromium(), "firefox", pw.firefox(), "webkit", pw.webkit());
      for (Setup s : SETUPS) {
        try {
          Browser browser = types.get(s.type()).connect(launch(s.engine(), s.headless(), proxy),
              new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
          try {
            Map<String, Object> m = new LinkedHashMap<>();
            m.put("version", browser.version());
            m.putAll(fingerprint(browser));
            fingerprints.put(s.id(), m);
          } finally {
            browser.close();
          }
        } catch (Exception e) {
          String msg = String.valueOf(e.getMessage()).split("\n")[0];
          fingerprints.put(s.id(), Map.of("error", msg.substring(0, Math.min(200, msg.length()))));
        }
      }
    }
    Map<String, Object> setups = new LinkedHashMap<>();
    for (Setup s : SETUPS) setups.put(s.id(), s.name());
    Map<String, Object> out = new LinkedHashMap<>();
    out.put("measured_at", Instant.now().toString().substring(0, 10));
    out.put("setups", setups);
    out.put("fingerprints", fingerprints);
    System.out.println(GSON.toJson(out));
  }
}
```

```csharp
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY
using System.Net.Http.Json;
using System.Security.Cryptography;
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 run = Convert.ToHexString(RandomNumberGenerator.GetBytes(3)).ToLowerInvariant();
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();

// The same twelve setups as the real-site lab cases 70-77 (Rebrowser needs a 1.52 client, so it's left out).
var setups = new (string Id, string Name, string Engine, IBrowserType Type, JsonNode Headless)[]
{
    ("chrome_headful", "Chrome, headful", "chrome", playwright.Chromium, false),
    ("chrome_headless", "Chrome, headless", "chrome", playwright.Chromium, "new"),
    ("edge", "Edge, headful", "edge", playwright.Chromium, false),
    ("brave", "Brave, headful", "brave", playwright.Chromium, false),
    ("opera", "Opera, headful", "opera", playwright.Chromium, false),
    ("yandex", "Yandex, headful", "yandex", playwright.Chromium, false),
    ("whale", "Whale, headful", "whale", playwright.Chromium, false),
    ("chromium_headless", "Chromium, headless", "chromium", playwright.Chromium, true),
    ("patchright", "Patchright, headful", "patchright", playwright.Chromium, false),
    ("firefox", "Firefox, headful", "firefox", playwright.Firefox, false),
    ("webkit", "WebKit, headful", "webkit", playwright.Webkit, false),
    ("camoufox", "Camoufox, headful", "camoufox", playwright.Firefox, false),
};

async Task<string> Launch(string engine, JsonNode headless, string proxy)
{
    var body = new JsonObject { ["proxy"] = proxy, ["headless"] = headless.DeepClone(), ["record"] = false };
    for (var attempt = 1; ; attempt++)
    {
        var res = await http.PostAsync($"https://starter.cdpfleet.com/{engine}/session", JsonContent.Create(body));
        if (res.IsSuccessStatusCode) return (await res.Content.ReadFromJsonAsync<JsonObject>())!["wsUrl"]!.GetValue<string>();
        if (((int)res.StatusCode != 429 && (int)res.StatusCode != 503) || attempt == 5)
            throw new Exception($"launch {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
        await Task.Delay((int)(res.Headers.RetryAfter?.Delta?.TotalSeconds ?? 5) * 1000);
    }
}

// A string field (nested path) of the echo's answer, or null when missing or empty.
static string? Str(JsonNode? n, params string[] path)
{
    foreach (var p in path) n = n is JsonObject o ? o[p] : null;
    var s = n is JsonValue v && v.TryGetValue<string>(out var x) ? x : null;
    return string.IsNullOrEmpty(s) ? null : s;
}

static JsonObject Fp(string? httpVersion, string? ja4, string? ja4r, string? peet, string? h2, string? h2Hash, string? ua) => new()
{
    ["http_version"] = httpVersion, ["ja4"] = ja4, ["ja4_r"] = ja4r, ["peetprint_hash"] = peet,
    ["akamai_h2"] = h2, ["akamai_h2_hash"] = h2Hash, ["user_agent"] = ua,
};

// The browser itself loads a TLS echo page, which answers with the ClientHello and HTTP/2 frames
// it received from this very browser. tls.peet.ws first; tls.browserleaks.com/json only if peet
// doesn't answer (it has had outages). Each is one page load in a fresh tab.
var echoes = new (string Source, string Url, Func<JsonNode, JsonObject> Read)[]
{
    ("tls.peet.ws", "https://tls.peet.ws/api/all", j => Fp(Str(j, "http_version"), Str(j, "tls", "ja4"), Str(j, "tls", "ja4_r"),
        Str(j, "tls", "peetprint_hash"), Str(j, "http2", "akamai_fingerprint"), Str(j, "http2", "akamai_fingerprint_hash"), Str(j, "user_agent"))),
    ("tls.browserleaks.com", "https://tls.browserleaks.com/json", j => Fp(null, Str(j, "ja4"), Str(j, "ja4_r"),
        null, Str(j, "akamai_text"), Str(j, "akamai_hash"), Str(j, "user_agent"))),
};

async Task<JsonObject> Fingerprint(IBrowser browser)
{
    var context = await browser.NewContextAsync();
    string? last = null;
    foreach (var (source, url, read) in echoes)
    {
        var tab = await context.NewPageAsync();
        try
        {
            var res = await tab.GotoAsync(url, new() { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 30000 });
            var fp = read(JsonNode.Parse(await res!.TextAsync())!);
            if (fp["ja4"] != null)
            {
                var m = new JsonObject { ["source"] = source };
                foreach (var (k, v) in fp) m[k] = v?.DeepClone();
                return m;
            }
            last = $"{source}: no ja4 in the answer";
        }
        catch (Exception e)
        {
            last = $"{source}: {e.Message.Split('\n')[0]}";
        }
        finally
        {
            try { await tab.CloseAsync(); } catch { }
        }
    }
    throw new Exception(last);
}

// One sticky residential exit for the whole run, and one session at a time.
var proxy = $"cdpfleet-resi-country-us-session-c84{run}-lifetime-30";
var fingerprints = new JsonObject();
foreach (var s in setups)
{
    try
    {
        var browser = await s.Type.ConnectAsync(await Launch(s.Engine, s.Headless, proxy),
            new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
        try
        {
            var m = new JsonObject { ["version"] = browser.Version };
            foreach (var (k, v) in await Fingerprint(browser)) m[k] = v?.DeepClone();
            fingerprints[s.Id] = m;
        }
        finally
        {
            await browser.CloseAsync();
        }
    }
    catch (Exception e)
    {
        var msg = e.Message.Split('\n')[0];
        fingerprints[s.Id] = new JsonObject { ["error"] = msg[..Math.Min(200, msg.Length)] };
    }
}

var output = new JsonObject
{
    ["measured_at"] = DateTime.UtcNow.ToString("yyyy-MM-dd"),
    ["setups"] = new JsonObject(setups.Select(s => KeyValuePair.Create(s.Id, (JsonNode?)s.Name))),
    ["fingerprints"] = fingerprints,
};
Console.WriteLine(output.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
```

```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
package main

import (
	"bytes"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"

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

var key = os.Getenv("CDPFLEET_API_KEY")
var runID = func() string { b := make([]byte, 3); rand.Read(b); return hex.EncodeToString(b) }()

type setup struct {
	ID, Name, Engine, Type string
	Headless               any // false, true or "new"
}

// The same twelve setups as the real-site lab cases 70-77 (Rebrowser needs a 1.52 client, so it's left out).
var setups = []setup{
	{"chrome_headful", "Chrome, headful", "chrome", "chromium", false},
	{"chrome_headless", "Chrome, headless", "chrome", "chromium", "new"},
	{"edge", "Edge, headful", "edge", "chromium", false},
	{"brave", "Brave, headful", "brave", "chromium", false},
	{"opera", "Opera, headful", "opera", "chromium", false},
	{"yandex", "Yandex, headful", "yandex", "chromium", false},
	{"whale", "Whale, headful", "whale", "chromium", false},
	{"chromium_headless", "Chromium, headless", "chromium", "chromium", true},
	{"patchright", "Patchright, headful", "patchright", "chromium", false},
	{"firefox", "Firefox, headful", "firefox", "firefox", false},
	{"webkit", "WebKit, headful", "webkit", "webkit", false},
	{"camoufox", "Camoufox, headful", "camoufox", "firefox", false},
}

type fingerprint struct {
	Version       string  `json:"version"`
	Source        string  `json:"source"`
	HTTPVersion   *string `json:"http_version"`
	JA4           *string `json:"ja4"`
	JA4R          *string `json:"ja4_r"`
	PeetprintHash *string `json:"peetprint_hash"`
	AkamaiH2      *string `json:"akamai_h2"`
	AkamaiH2Hash  *string `json:"akamai_h2_hash"`
	UserAgent     *string `json:"user_agent"`
}

func launch(engine string, headless any, proxy string) (string, error) {
	b, _ := json.Marshal(map[string]any{"proxy": proxy, "headless": headless, "record": false})
	for attempt := 1; ; attempt++ {
		req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+engine+"/session", bytes.NewReader(b))
		req.Header.Set("x-api-key", key)
		req.Header.Set("content-type", "application/json")
		res, err := http.DefaultClient.Do(req)
		if err != nil {
			return "", err
		}
		data, _ := io.ReadAll(res.Body)
		res.Body.Close()
		if res.StatusCode == 200 {
			var s struct{ WsUrl string }
			json.Unmarshal(data, &s)
			return s.WsUrl, nil
		}
		if (res.StatusCode != 429 && res.StatusCode != 503) || attempt == 5 {
			return "", fmt.Errorf("launch %d %s", res.StatusCode, data)
		}
		wait, _ := strconv.Atoi(res.Header.Get("retry-after"))
		if wait == 0 {
			wait = 5
		}
		time.Sleep(time.Duration(wait) * time.Second)
	}
}

// A string field (nested path) of the echo's answer, or nil when missing or empty.
func str(j map[string]any, path ...string) *string {
	var v any = j
	for _, p := range path {
		m, ok := v.(map[string]any)
		if !ok {
			return nil
		}
		v = m[p]
	}
	if s, ok := v.(string); ok && s != "" {
		return &s
	}
	return nil
}

type echo struct {
	Source, URL string
	Read        func(map[string]any) fingerprint
}

// The browser itself loads a TLS echo page, which answers with the ClientHello and HTTP/2 frames
// it received from this very browser. tls.peet.ws first; tls.browserleaks.com/json only if peet
// doesn't answer (it has had outages). Each is one page load in a fresh tab.
var echoes = []echo{
	{"tls.peet.ws", "https://tls.peet.ws/api/all", func(j map[string]any) fingerprint {
		return fingerprint{HTTPVersion: str(j, "http_version"), JA4: str(j, "tls", "ja4"), JA4R: str(j, "tls", "ja4_r"), PeetprintHash: str(j, "tls", "peetprint_hash"),
			AkamaiH2: str(j, "http2", "akamai_fingerprint"), AkamaiH2Hash: str(j, "http2", "akamai_fingerprint_hash"), UserAgent: str(j, "user_agent")}
	}},
	{"tls.browserleaks.com", "https://tls.browserleaks.com/json", func(j map[string]any) fingerprint {
		return fingerprint{JA4: str(j, "ja4"), JA4R: str(j, "ja4_r"), AkamaiH2: str(j, "akamai_text"), AkamaiH2Hash: str(j, "akamai_hash"), UserAgent: str(j, "user_agent")}
	}},
}

func measure(browser playwright.Browser) (fingerprint, error) {
	context, err := browser.NewContext()
	if err != nil {
		return fingerprint{}, err
	}
	last := ""
	for _, e := range echoes {
		tab, err := context.NewPage()
		if err != nil {
			return fingerprint{}, err
		}
		res, err := tab.Goto(e.URL, playwright.PageGotoOptions{WaitUntil: playwright.WaitUntilStateDomcontentloaded, Timeout: playwright.Float(30000)})
		var body string
		if err == nil {
			body, err = res.Text()
		}
		tab.Close()
		if err != nil {
			last = e.Source + ": " + strings.Split(err.Error(), "\n")[0]
			continue
		}
		var j map[string]any
		if err := json.Unmarshal([]byte(body), &j); err != nil {
			last = e.Source + ": " + err.Error()
			continue
		}
		fp := e.Read(j)
		if fp.JA4 != nil {
			fp.Source = e.Source
			return fp, nil
		}
		last = e.Source + ": no ja4 in the answer"
	}
	return fingerprint{}, errors.New(last)
}

func main() {
	pw, err := playwright.Run()
	if err != nil {
		log.Fatal(err)
	}
	defer pw.Stop()
	types := map[string]playwright.BrowserType{"chromium": pw.Chromium, "firefox": pw.Firefox, "webkit": pw.WebKit}

	// One sticky residential exit for the whole run, and one session at a time.
	proxy := "cdpfleet-resi-country-us-session-c84" + runID + "-lifetime-30"
	fingerprints := map[string]any{}
	names := map[string]string{}
	for _, s := range setups {
		names[s.ID] = s.Name
		fail := func(err error) {
			msg := strings.Split(err.Error(), "\n")[0]
			if len(msg) > 200 {
				msg = msg[:200]
			}
			fingerprints[s.ID] = map[string]string{"error": msg}
		}
		ws, err := launch(s.Engine, s.Headless, proxy)
		if err != nil {
			fail(err)
			continue
		}
		browser, err := types[s.Type].Connect(ws, playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
		if err != nil {
			fail(err)
			continue
		}
		fp, err := measure(browser)
		version := browser.Version()
		browser.Close()
		if err != nil {
			fail(err)
			continue
		}
		fp.Version = version
		fingerprints[s.ID] = fp
	}

	// encoding/json sorts map keys; the setups' order is in the case's own table.
	out, _ := json.MarshalIndent(map[string]any{
		"measured_at":  time.Now().UTC().Format("2006-01-02"),
		"setups":       names,
		"fingerprints": fingerprints,
	}, "", "  ")
	fmt.Println(string(out))
}
```

## What we got

| Setup (JA4 group) | browser.version() today | JA4 today (peetprint · HTTP/2) | Cloudflare, got in (2026-10-26) | DataDome, got in (2026-10-27) | Akamai, got in (2026-10-28) | HUMAN, got in (2026-10-29) | Kasada, got in (2026-10-30) | AWS WAF, got in (2026-10-30) |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| J1 · Chrome, headful | 157.0.8103.38 | t13d1517h2\_8daaf6152771\_cb7bf5808d99 (fc97c1cd · 52d84b11) | 14/14 | 15/15 | 9/14 | 9/14 | 1/10 | 9/9 |
| J1 · Chrome, headless | 157.0.8103.38 | t13d1517h2\_8daaf6152771\_cb7bf5808d99 (fc97c1cd · 52d84b11) | 5/15 | 4/14 | 5/15 | 5/15 | 0/10 | 5/10 |
| J1 · Opera, headful | 155.0.8059.85 | t13d1517h2\_8daaf6152771\_cb7bf5808d99 (fc97c1cd · 52d84b11) | 15/15 | 11/15 | 15/15 | 8/15 | 0/10 | 10/10 |
| J1 · Yandex, headful | 26.10.0.0 | t13d1517h2\_8daaf6152771\_cb7bf5808d99 (fc97c1cd · 52d84b11) | 15/15 (as J2 that week) | 6/15 (as J2 that week) | 5/15 (as J2 that week) | 13/15 (as J2 that week) | 0/10 (build changed that day) | 10/10 (build changed that day) |
| J2 · Edge, headful | 156.0.4314.58 | t13d1516h2\_8daaf6152771\_806a8c22fdea (8f568b2a · 52d84b11) | 15/15 | 11/15 | 10/15 | 7/14 | 0/10 | 10/10 |
| J2 · Brave, headful | 156.0.8078.80 | t13d1516h2\_8daaf6152771\_806a8c22fdea (8f568b2a · 52d84b11) | 15/15 | 10/15 | 15/15 | 6/15 | 1/10 | 10/10 |
| J2 · Whale, headful | 150.0.7871.252 | t13d1516h2\_8daaf6152771\_806a8c22fdea (67c3e911 · 52d84b11) | 15/15 | 11/15 | 10/15 | 14/15 | 0/10 | 10/10 |
| J3 · Chromium, headless | 148.0.7778.96 | t13d1516h2\_8daaf6152771\_d8a2da3f94cd (1d4ffe9b · 52d84b11) | 5/15 | 5/15 | 5/15 | 5/15 | 0/10 | 5/10 |
| J3 · Patchright, headful | 148.0.7778.96 | t13d1516h2\_8daaf6152771\_d8a2da3f94cd (1d4ffe9b · 52d84b11) | 10/15 | 10/15 | 10/15 | 6/15 | 0/10 | 10/10 |
| J4 · Firefox, headful | 150.0.2 | t13d1617h2\_86a278354501\_3cbfd9057e0d (fd4547ee · 6ea73faa) | 10/15 | 11/15 | 10/15 | 5/15 | 0/10 | 9/10 |
| J5 · Camoufox, headful | 156.0.1-beta.34 | t13d1517h2\_8daaf6152771\_3cbfd9057e0d (a4bbbc7e · 6ea73faa) | 15/15 | 7/15 | 12/15 | 13/15 | 6/10 | 10/10 |
| J6 · WebKit, headful | 26.4 | t13d2913h1\_723694b0fccc\_5671b5df5029 (e2a4f6ec · HTTP/1.1, no HTTP/2) | 5/15 | 8/15 | 10/15 | 12/15 | 0/10 | 8/10 |

From the Node.js run on 2026-11-06. 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)

```
{
  "measured_at": "2026-11-06",
  "setups": {
    "chrome_headful": "Chrome, headful",
    "chrome_headless": "Chrome, headless",
    "edge": "Edge, headful",
    "brave": "Brave, headful",
    "opera": "Opera, headful",
    "yandex": "Yandex, headful",
    "whale": "Whale, headful",
    "chromium_headless": "Chromium, headless",
    "patchright": "Patchright, headful",
    "firefox": "Firefox, headful",
    "webkit": "WebKit, headful",
    "camoufox": "Camoufox, headful"
  },
  "fingerprints": {
    "chrome_headful": {
      "version": "157.0.8103.38",
      "source": "tls.peet.ws",
      "http_version": "h2",
      "ja4": "t13d1517h2_8daaf6152771_cb7bf5808d99",
      "ja4_r": "t13d1517h2_002f,0035,009c,009d,1301,1302,1303,c013,c014,c02b,c02c,c02f,c030,cca8,cca9_0005,000a,000b,000d,0012,0017,001b,0023,002b,002d,0033,44cd,ca34,fe0d,ff01_0904,0905,0906,0403,0804,0401,0503,0805,0501,0806,0601",
      "peetprint_hash": "fc97c1cdfb1409c9a9326c1b726d1dee",
      "akamai_h2": "1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
      "akamai_h2_hash": "52d84b11737d980aef856699f885ca86",
      "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/157.0.0.0 Safari/537.36"
    },
    "chrome_headless": {
      "version": "157.0.8103.38",
      "source": "tls.peet.ws",
      "http_version": "h2",
      "ja4": "t13d1517h2_8daaf6152771_cb7bf5808d99",
      "ja4_r": "t13d1517h2_002f,0035,009c,009d,1301,1302,1303,c013,c014,c02b,c02c,c02f,c030,cca8,cca9_0005,000a,000b,000d,0012,0017,001b,0023,002b,002d,0033,44cd,ca34,fe0d,ff01_0904,0905,0906,0403,0804,0401,0503,0805,0501,0806,0601",
      "peetprint_hash": "fc97c1cdfb1409c9a9326c1b726d1dee",
      "akamai_h2": "1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
      "akamai_h2_hash": "52d84b11737d980aef856699f885ca86",
      "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/157.0.0.0 Safari/537.36"
    },
    "edge": {
      "version": "156.0.4314.58",
      "source": "tls.peet.ws",
      "http_version": "h2",
      "ja4": "t13d1516h2_8daaf6152771_806a8c22fdea",
      "ja4_r": "t13d1516h2_002f,0035,009c,009d,1301,1302,1303,c013,c014,c02b,c02c,c02f,c030,cca8,cca9_0005,000a,000b,000d,0012,0017,001b,0023,002b,002d,0033,44cd,fe0d,ff01_0904,0905,0906,0403,0804,0401,0503,0805,0501,0806,0601",
      "peetprint_hash": "8f568b2a409b82d0ec24cb84bf3bc3fc",
      "akamai_h2": "1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
      "akamai_h2_hash": "52d84b11737d980aef856699f885ca86",
      "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36 Edg/156.0.0.0"
    },
    "brave": {
      "version": "156.0.8078.80",
      "source": "tls.peet.ws",
      "http_version": "h2",
      "ja4": "t13d1516h2_8daaf6152771_806a8c22fdea",
      "ja4_r": "t13d1516h2_002f,0035,009c,009d,1301,1302,1303,c013,c014,c02b,c02c,c02f,c030,cca8,cca9_0005,000a,000b,000d,0012,0017,001b,0023,002b,002d,0033,44cd,fe0d,ff01_0904,0905,0906,0403,0804,0401,0503,0805,0501,0806,0601",
      "peetprint_hash": "8f568b2a409b82d0ec24cb84bf3bc3fc",
      "akamai_h2": "1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
      "akamai_h2_hash": "52d84b11737d980aef856699f885ca86",
      "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36"
    },
    "opera": {
      "version": "155.0.8059.85",
      "source": "tls.peet.ws",
      "http_version": "h2",
      "ja4": "t13d1517h2_8daaf6152771_cb7bf5808d99",
      "ja4_r": "t13d1517h2_002f,0035,009c,009d,1301,1302,1303,c013,c014,c02b,c02c,c02f,c030,cca8,cca9_0005,000a,000b,000d,0012,0017,001b,0023,002b,002d,0033,44cd,ca34,fe0d,ff01_0904,0905,0906,0403,0804,0401,0503,0805,0501,0806,0601",
      "peetprint_hash": "fc97c1cdfb1409c9a9326c1b726d1dee",
      "akamai_h2": "1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
      "akamai_h2_hash": "52d84b11737d980aef856699f885ca86",
      "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36 OPR/139.0.0.0"
    },
    "yandex": {
      "version": "203.0.113.1",
      "source": "tls.peet.ws",
      "http_version": "h2",
      "ja4": "t13d1517h2_8daaf6152771_cb7bf5808d99",
      "ja4_r": "t13d1517h2_002f,0035,009c,009d,1301,1302,1303,c013,c014,c02b,c02c,c02f,c030,cca8,cca9_0005,000a,000b,000d,0012,0017,001b,0023,002b,002d,0033,44cd,ca34,fe0d,ff01_0904,0905,0906,0403,0804,0401,0503,0805,0501,0806,0601",
      "peetprint_hash": "fc97c1cdfb1409c9a9326c1b726d1dee",
      "akamai_h2": "1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
      "akamai_h2_hash": "52d84b11737d980aef856699f885ca86",
      "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 YaBrowser/26.10.0.0 Safari/537.36"
    },
    "whale": {
      "version": "150.0.7871.252",
      "source": "tls.peet.ws",
      "http_version": "h2",
      "ja4": "t13d1516h2_8daaf6152771_806a8c22fdea",
      "ja4_r": "t13d1516h2_002f,0035,009c,009d,1301,1302,1303,c013,c014,c02b,c02c,c02f,c030,cca8,cca9_0005,000a,000b,000d,0012,0017,001b,0023,002b,002d,0033,44cd,fe0d,ff01_0904,0905,0906,0403,0804,0401,0503,0805,0501,0806,0601",
      "peetprint_hash": "67c3e9111bed9e7f03d2f21d6d88994b",
      "akamai_h2": "1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
      "akamai_h2_hash": "52d84b11737d980aef856699f885ca86",
      "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Whale/5.39.412.57 Safari/537.36"
    },
    "chromium_headless": {
      "version": "148.0.7778.96",
      "source": "tls.peet.ws",
      "http_version": "h2",
      "ja4": "t13d1516h2_8daaf6152771_d8a2da3f94cd",
      "ja4_r": "t13d1516h2_002f,0035,009c,009d,1301,1302,1303,c013,c014,c02b,c02c,c02f,c030,cca8,cca9_0005,000a,000b,000d,0012,0017,001b,0023,002b,002d,0033,44cd,fe0d,ff01_0403,0804,0401,0503,0805,0501,0806,0601",
      "peetprint_hash": "1d4ffe9b0e34acac0bd883fa7f79d7b5",
      "akamai_h2": "1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
      "akamai_h2_hash": "52d84b11737d980aef856699f885ca86",
      "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/148.0.7778.96 Safari/537.36"
    },
    "patchright": {
      "version": "148.0.7778.96",
      "source": "tls.peet.ws",
      "http_version": "h2",
      "ja4": "t13d1516h2_8daaf6152771_d8a2da3f94cd",
      "ja4_r": "t13d1516h2_002f,0035,009c,009d,1301,1302,1303,c013,c014,c02b,c02c,c02f,c030,cca8,cca9_0005,000a,000b,000d,0012,0017,001b,0023,002b,002d,0033,44cd,fe0d,ff01_0403,0804,0401,0503,0805,0501,0806,0601",
      "peetprint_hash": "1d4ffe9b0e34acac0bd883fa7f79d7b5",
      "akamai_h2": "1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
      "akamai_h2_hash": "52d84b11737d980aef856699f885ca86",
      "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"
    },
    "firefox": {
      "version": "150.0.2",
      "source": "tls.peet.ws",
      "http_version": "h2",
      "ja4": "t13d1617h2_86a278354501_3cbfd9057e0d",
      "ja4_r": "t13d1617h2_002f,0035,009c,009d,1301,1302,1303,c00a,c013,c014,c02b,c02c,c02f,c030,cca8,cca9_0005,000a,000b,000d,0012,0017,001b,001c,0022,0023,002b,002d,0033,fe0d,ff01_0403,0503,0603,0804,0805,0806,0401,0501,0601,0203,0201",
      "peetprint_hash": "fd4547eeb41f073156b7bc8125a79a3c",
      "akamai_h2": "1:65536;2:0;4:131072;5:16384|12517377|0|m,p,a,s",
      "akamai_h2_hash": "6ea73faa8fc5aac76bded7bd238f6433",
      "user_agent": "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
    },
    "webkit": {
      "version": "26.4",
      "source": "tls.peet.ws",
      "http_version": "HTTP/1.1",
      "ja4": "t13d2913h1_723694b0fccc_5671b5df5029",
      "ja4_r": "t13d2913h1_002f,0033,0035,0039,009c,009d,009e,009f,1301,1302,1303,1304,c009,c00a,c013,c014,c02b,c02c,c02f,c030,c09c,c09d,c09e,c09f,c0ac,c0ad,cca8,cca9,ccaa_0005,000a,000b,000d,0015,001c,0023,002b,002d,0033,ff01_0401,0809,0804,0403,0807,0501,080a,0805,0503,0808,0601,080b,0806,0603,0201,0203",
      "peetprint_hash": "e2a4f6ec1c75767498bc87d934bfccec",
      "akamai_h2": null,
      "akamai_h2_hash": null,
      "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"
    },
    "camoufox": {
      "version": "156.0.1-beta.34",
      "source": "tls.peet.ws",
      "http_version": "h2",
      "ja4": "t13d1517h2_8daaf6152771_3cbfd9057e0d",
      "ja4_r": "t13d1517h2_002f,0035,009c,009d,1301,1302,1303,c013,c014,c02b,c02c,c02f,c030,cca8,cca9_0005,000a,000b,000d,0012,0017,001b,001c,0022,0023,002b,002d,0033,fe0d,ff01_0403,0503,0603,0804,0805,0806,0401,0501,0601,0203,0201",
      "peetprint_hash": "a4bbbc7e3adf3db97d276fd0adb1efc4",
      "akamai_h2": "1:65536;2:0;4:131072;5:16384|12517377|0|m,p,a,s",
      "akamai_h2_hash": "6ea73faa8fc5aac76bded7bd238f6433",
      "user_agent": "Mozilla/5.0 (X11; Linux x86_64; rv:156.0) Gecko/20100101 Firefox/156.0"
    }
  }
}
```

## Takeaways

-   **Twelve setups, six JA4s.** On 2026-11-06, in five runs (one per language) of one load each, Chrome headful and headless, Opera and Yandex sent `t13d1517h2_8daaf6152771_cb7bf5808d99` (J1, Chrome's); Edge, Brave and Whale `t13d1516h2_8daaf6152771_806a8c22fdea` (J2: the same ciphers, one extension fewer, `0xca34`); headless Chromium and Patchright, both build 148, `t13d1516h2_8daaf6152771_d8a2da3f94cd` (J3: without the `0x0904`\-`0x0906` signature algorithms); Firefox, Camoufox and WebKit one each. Every Chromium-based setup sent the same HTTP/2 fingerprint; Firefox and Camoufox shared another; WebKit spoke HTTP/1.1 to the echo page. The peetprint split one group: Whale differs from Edge and Brave. Headless and headful Chrome sent identical fingerprints.
-   **The lab week's builds sent the same — except Yandex.** For 11 of the 12 setups, today's JA4 and peetprint equal our snapshots of the builds in use on 2026-10-26..30. Yandex changed on 2026-10-30: 26.8 (Chromium 150) sent Whale's exact fingerprint (J2), 26.10 (Chromium 152) sends Chrome's (J1). So below Yandex counts in J2 for Cloudflare, DataDome, Akamai and HUMAN, and is left out for Kasada and AWS WAF, measured the day its build changed.
-   **Identical fingerprints, opposite outcomes, on 8 of the 16 sites.** Counting pairs of setups with the identical JA4, peetprint and HTTP/2 fingerprint where one got in on every load and the other on none: 20 pairs, on Cloudflare sites B and C, DataDome B and C, Akamai B and C, HUMAN B and AWS WAF D. Most are headless against headful of the same engine — Chrome headful against Chrome headless on seven of those sites, Patchright 5/5 against headless Chromium 0/5 on three — a difference TLS doesn't carry. Four are between two headful browsers: on Akamai site C (2026-10-28) Opera got in 5/5 and Chrome headful 0/5, Brave 5/5 and Edge 0/5; on Akamai B and DataDome B, Whale 5/5 and Yandex 26.8 0/5.
-   **Chrome's JA4 against every other JA4, per vendor** (got in / measured loads, pooled over each vendor's sites and five runs): Cloudflare (2026-10-26) 34/44 vs 139/179; DataDome (10-27) 30/44 vs 109/179; Akamai (10-28) 29/44 vs 116/179; HUMAN (10-29) 22/44 vs 103/178; Kasada (10-30) 1/30 vs 8/110; AWS WAF (10-30) 24/29 vs 96/109. Inside Chrome's own group the range was wider each time: Cloudflare 5/15 (Chrome headless) to 15/15 (Opera), DataDome 4/14 to 15/15, Akamai 5/15 to 15/15, HUMAN 5/15 to 9/14.
-   **Within a JA4 group the outcomes spread at least as far as between groups, on five of six vendors.** The widest gap inside one group against the gap between the groups' pooled counts: Cloudflare 5/15 to 15/15 inside J1, groups 5/15 (WebKit) to 15/15 and 60/60; DataDome 4/14 to 15/15 inside J1, groups 7/15 (Camoufox) to 11/15 (Firefox); Akamai 5/15 to 15/15 inside J1 (and Yandex 5/15 to Brave 15/15 inside J2, all headful), groups 15/30 (J3) to 12/15 (Camoufox); HUMAN Brave 6/15 to Whale 14/15 inside J2, groups 5/15 (Firefox) to 13/15 (Camoufox); AWS WAF 5/10 to 10/10 inside J1, groups 15/20 (J3) to 30/30 (J2).
-   **Kasada is the one vendor where the groups split — and that group is one browser.** Camoufox's JA4 got in 6/10, every other JA4 at most 1 in 30 (2/100 together: Chrome headful and Brave once each, on site B). Camoufox's JA4 is its own: Chrome's 15-cipher list (`8daaf6152771`) with Firefox's extensions and signature algorithms (`3cbfd9057e0d`); Firefox, one cipher more (`0xc00a`) and the same HTTP/2 fingerprint, got 0/10. With one browser in the group, this sample can't tell its JA4 apart from everything else that differs in Camoufox.
-   **What this does and doesn't show.** On these 16 sites in the week of 2026-10-26, browsers with the identical TLS and HTTP/2 fingerprint often got opposite answers, so the fingerprint alone didn't decide the outcome; the clearest split in the data, headless against headful, is invisible in TLS. It doesn't show that vendors ignore TLS — a fingerprint can count in a score without deciding it. The sample is small: 12 setups, five loads per site, one country's exits, one week, fingerprints measured 7-11 days after the outcomes (bridged by the snapshots); no statistic beyond these counts is supported. How each case measured its outcomes: [cases 70](https://cdpfleet.com/docs/cases/cloudflare-real-sites-by-browser), [71](https://cdpfleet.com/docs/cases/datadome-real-sites-by-browser), [73](https://cdpfleet.com/docs/cases/akamai-real-sites-by-browser), [75](https://cdpfleet.com/docs/cases/human-real-sites-by-browser), [77](https://cdpfleet.com/docs/cases/kasada-and-aws-waf-by-browser); the fingerprints of individual browser pairs: [/docs/compare](https://cdpfleet.com/docs/compare).

---
Source: https://cdpfleet.com/docs/cases/ja4-vs-vendor-verdicts · Markdown: https://cdpfleet.com/docs/cases/ja4-vs-vendor-verdicts.md · All docs for AI tools: https://cdpfleet.com/llms.txt
