Cases / #3 · 2026-09-30 · Easy
Stable, beta, dev and an older major: four Chromes side by side
Pick a channel or pin a previous major, read the live catalog instead of hard-coding versions, and see what actually differs on the wire.
Run on production on 2026-09-30: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
A site breaks on the new Chrome, or you need to test the next release before your users get it, or a target only trusts the current version. cdpfleet runs Chrome stable, beta and dev, plus previous majors. Which options select which build — and does the version change the fingerprint a site sees?
What we used, and why
| What | Why |
|---|---|
GET /api/public/browsers | The live catalog: which channels and previous majors exist right now. Majors rotate, so read it instead of hard-coding numbers. |
channel: "beta" / "dev" | Selects a pre-release build. Omit it (or send "stable") for stable. |
version: "152" | Pins a retained previous major. A major the fleet doesn't have returns 503 version_unavailable instead of silently running another. Don't combine it with a pre-release channel (that's a 400). |
headless: false | A real (virtual) display, so the user agent reads Chrome/…, not HeadlessChrome/…. |
How it works
- Read the catalog and build one variant per listed Chrome build.
- Launch all variants at once — each is its own session.
- In each, compare
browser.version()with the catalog, and read the user agent,sec-ch-uaand fingerprints from tls.peet.ws.
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';
const KEY = process.env.CDPFLEET_API_KEY;
// The live catalog says which channels and previous majors exist right now.
const catalog = await (await fetch('https://cdpfleet.com/api/public/browsers')).json();
const versions = catalog.engines.find((e) => e.key === 'chrome').versions;
const variants = versions.map((v) => (v.label === 'pinned'
? { label: `version ${v.version.split('.')[0]}`, options: { version: v.version.split('.')[0] }, catalog: v.version }
: { label: `channel ${v.label}`, options: v.label === 'stable' ? {} : { channel: v.label }, catalog: v.version }));
async function probe({ label, options, catalog: expected }) {
const res = await fetch('https://starter.cdpfleet.com/chrome/session', {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
// headless: false (a real display) so the user agent doesn't say "HeadlessChrome".
body: JSON.stringify({ proxy: process.env.PROXY_URL, headless: false, ...options }),
});
if (!res.ok) return { variant: label, error: `${res.status} ${await res.text()}` };
const { wsUrl } = await res.json();
const browser = await chromium.connect(wsUrl, { headers: { 'x-api-key': KEY } });
try {
// A residential exit occasionally times out: one retry, in a fresh tab.
const load = async () => (await browser.newPage()).goto('https://tls.peet.ws/api/all', { timeout: 30000 });
const fp = await (await load().catch(load)).json();
const headers = fp.http2.sent_frames.find((f) => f.frame_type === 'HEADERS').headers;
return {
variant: label,
catalog_version: expected,
browser_version: browser.version(),
user_agent: fp.user_agent,
sec_ch_ua: headers.find((h) => h.startsWith('sec-ch-ua: '))?.slice(11) ?? null,
ja4: fp.tls.ja4,
akamai_h2_hash: fp.http2.akamai_fingerprint_hash,
};
} finally {
await browser.close();
}
}
// All variants at once: each is its own session.
console.log(JSON.stringify(await Promise.all(variants.map(probe)), null, 2));
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import json
import os
from concurrent.futures import ThreadPoolExecutor
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
# The live catalog says which channels and previous majors exist right now.
catalog = requests.get("https://cdpfleet.com/api/public/browsers", timeout=30).json()
versions = next(e for e in catalog["engines"] if e["key"] == "chrome")["versions"]
variants = []
for v in versions:
if v["label"] == "pinned":
major = v["version"].split(".")[0]
variants.append((f"version {major}", {"version": major}, v["version"]))
else:
variants.append((f"channel {v['label']}", {} if v["label"] == "stable" else {"channel": v["label"]}, v["version"]))
def probe(variant):
label, options, expected = variant
# headless: False (a real display) so the user agent doesn't say "HeadlessChrome".
res = requests.post("https://starter.cdpfleet.com/chrome/session", headers={"x-api-key": KEY},
json={"proxy": os.environ["PROXY_URL"], "headless": False, **options}, timeout=60)
if not res.ok:
return {"variant": label, "error": f"{res.status_code} {res.text}"}
# The sync API is per thread: each probe gets its own Playwright instance.
with sync_playwright() as p:
browser = p.chromium.connect(res.json()["wsUrl"], headers={"x-api-key": KEY})
try:
try:
fp = browser.new_page().goto("https://tls.peet.ws/api/all", timeout=30000).json()
except Exception: # a residential exit occasionally times out: one retry, in a fresh tab
fp = browser.new_page().goto("https://tls.peet.ws/api/all", timeout=30000).json()
headers = next(f for f in fp["http2"]["sent_frames"] if f["frame_type"] == "HEADERS")["headers"]
return {
"variant": label,
"catalog_version": expected,
"browser_version": browser.version,
"user_agent": fp["user_agent"],
"sec_ch_ua": next((h[11:] for h in headers if h.startswith("sec-ch-ua: ")), None),
"ja4": fp["tls"]["ja4"],
"akamai_h2_hash": fp["http2"]["akamai_fingerprint_hash"],
}
finally:
browser.close()
# All variants at once: each is its own session.
with ThreadPoolExecutor(len(variants)) as pool:
print(json.dumps(list(pool.map(probe, variants)), 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.*;
import java.util.concurrent.*;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final HttpClient HTTP = HttpClient.newHttpClient();
static JsonObject probe(String label, JsonObject options, String expected) throws Exception {
// headless: false (a real display) so the user agent doesn't say "HeadlessChrome".
options.addProperty("proxy", System.getenv("PROXY_URL"));
options.addProperty("headless", false);
HttpResponse<String> res = HTTP.send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/chrome/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(options.toString())).build(), HttpResponse.BodyHandlers.ofString());
JsonObject out = new JsonObject();
out.addProperty("variant", label);
if (res.statusCode() != 200) { out.addProperty("error", res.statusCode() + " " + res.body()); return out; }
String wsUrl = JsonParser.parseString(res.body()).getAsJsonObject().get("wsUrl").getAsString();
// Playwright objects are per thread: each probe gets its own instance.
try (Playwright playwright = Playwright.create()) {
Browser browser = playwright.chromium().connect(wsUrl, new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
try {
Response nav;
try {
nav = browser.newPage().navigate("https://tls.peet.ws/api/all", new Page.NavigateOptions().setTimeout(30000));
} catch (PlaywrightException err) { // a residential exit occasionally times out: one retry, in a fresh tab
nav = browser.newPage().navigate("https://tls.peet.ws/api/all", new Page.NavigateOptions().setTimeout(30000));
}
JsonObject fp = JsonParser.parseString(nav.text()).getAsJsonObject();
String secChUa = null;
for (JsonElement f : fp.getAsJsonObject("http2").getAsJsonArray("sent_frames")) {
if (!f.getAsJsonObject().get("frame_type").getAsString().equals("HEADERS")) continue;
for (JsonElement h : f.getAsJsonObject().getAsJsonArray("headers")) {
if (h.getAsString().startsWith("sec-ch-ua: ")) secChUa = h.getAsString().substring(11);
}
}
out.addProperty("catalog_version", expected);
out.addProperty("browser_version", browser.version());
out.add("user_agent", fp.get("user_agent"));
out.addProperty("sec_ch_ua", secChUa);
out.add("ja4", fp.getAsJsonObject("tls").get("ja4"));
out.add("akamai_h2_hash", fp.getAsJsonObject("http2").get("akamai_fingerprint_hash"));
return out;
} finally {
browser.close();
}
}
}
public static void main(String[] args) throws Exception {
// The live catalog says which channels and previous majors exist right now.
JsonObject catalog = JsonParser.parseString(HTTP.send(HttpRequest.newBuilder(URI.create("https://cdpfleet.com/api/public/browsers")).build(),
HttpResponse.BodyHandlers.ofString()).body()).getAsJsonObject();
List<Callable<JsonObject>> jobs = new ArrayList<>();
for (JsonElement e : catalog.getAsJsonArray("engines")) {
if (!e.getAsJsonObject().get("key").getAsString().equals("chrome")) continue;
for (JsonElement ve : e.getAsJsonObject().getAsJsonArray("versions")) {
String labelName = ve.getAsJsonObject().get("label").getAsString();
String version = ve.getAsJsonObject().get("version").getAsString();
JsonObject options = new JsonObject();
String label;
if (labelName.equals("pinned")) {
String major = version.split("\\.")[0];
options.addProperty("version", major);
label = "version " + major;
} else {
if (!labelName.equals("stable")) options.addProperty("channel", labelName);
label = "channel " + labelName;
}
jobs.add(() -> probe(label, options, version));
}
}
// All variants at once: each is its own session.
ExecutorService pool = Executors.newFixedThreadPool(jobs.size());
JsonArray rows = new JsonArray();
for (Future<JsonObject> f : pool.invokeAll(jobs)) rows.add(f.get());
pool.shutdown();
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
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();
// The live catalog says which channels and previous majors exist right now.
var catalog = JsonNode.Parse(await http.GetStringAsync("https://cdpfleet.com/api/public/browsers"))!;
var versions = catalog["engines"]!.AsArray().First(e => (string?)e!["key"] == "chrome")!["versions"]!.AsArray();
var variants = versions.Select(v =>
{
var label = (string)v!["label"]!;
var version = (string)v["version"]!;
var options = new JsonObject { ["proxy"] = proxy, ["headless"] = false }; // a real display: no "HeadlessChrome"
if (label == "pinned") { options["version"] = version.Split('.')[0]; return ($"version {version.Split('.')[0]}", options, version); }
if (label != "stable") options["channel"] = label;
return ($"channel {label}", options, version);
}).ToList();
async Task<JsonObject> Probe((string Label, JsonObject Options, string Expected) v)
{
var res = await http.PostAsJsonAsync("https://starter.cdpfleet.com/chrome/session", v.Options);
if (!res.IsSuccessStatusCode) return new JsonObject { ["variant"] = v.Label, ["error"] = $"{(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}" };
var wsUrl = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("wsUrl").GetString()!;
var browser = await playwright.Chromium.ConnectAsync(wsUrl, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
try
{
IResponse? nav;
try { nav = await (await browser.NewPageAsync()).GotoAsync("https://tls.peet.ws/api/all", new() { Timeout = 30000 }); }
catch (Exception err) when (err is PlaywrightException or TimeoutException) // a residential exit occasionally times out: one retry, in a fresh tab
{ nav = await (await browser.NewPageAsync()).GotoAsync("https://tls.peet.ws/api/all", new() { Timeout = 30000 }); }
var fp = JsonNode.Parse(await nav!.TextAsync())!;
var headers = fp["http2"]!["sent_frames"]!.AsArray().First(f => (string?)f!["frame_type"] == "HEADERS")!["headers"]!.AsArray();
return new JsonObject
{
["variant"] = v.Label,
["catalog_version"] = v.Expected,
["browser_version"] = browser.Version,
["user_agent"] = fp["user_agent"]!.DeepClone(),
["sec_ch_ua"] = headers.Select(h => (string)h!).FirstOrDefault(h => h.StartsWith("sec-ch-ua: "))?[11..],
["ja4"] = fp["tls"]!["ja4"]!.DeepClone(),
["akamai_h2_hash"] = fp["http2"]!["akamai_fingerprint_hash"]!.DeepClone(),
};
}
finally
{
await browser.CloseAsync();
}
}
// All variants at once: each is its own session.
var rows = new JsonArray((await Task.WhenAll(variants.Select(Probe))).ToArray<JsonNode?>());
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
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"sync"
"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 result struct {
Variant string `json:"variant"`
CatalogVersion string `json:"catalog_version,omitempty"`
BrowserVersion string `json:"browser_version,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
SecChUA *string `json:"sec_ch_ua,omitempty"`
JA4 string `json:"ja4,omitempty"`
AkamaiH2Hash string `json:"akamai_h2_hash,omitempty"`
Error string `json:"error,omitempty"`
}
type peet struct {
UserAgent string `json:"user_agent"`
TLS struct {
JA4 string `json:"ja4"`
} `json:"tls"`
HTTP2 struct {
AkamaiFingerprintHash string `json:"akamai_fingerprint_hash"`
SentFrames []struct {
FrameType string `json:"frame_type"`
Headers []string `json:"headers"`
} `json:"sent_frames"`
} `json:"http2"`
}
func probe(pw *playwright.Playwright, label string, options map[string]any, expected string) result {
// headless: false (a real display) so the user agent doesn't say "HeadlessChrome".
options["proxy"], options["headless"] = os.Getenv("PROXY_URL"), false
session, err := launch("chrome", options)
if err != nil {
return result{Variant: label, Error: err.Error()}
}
browser, err := pw.Chromium.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
if err != nil {
return result{Variant: label, Error: err.Error()}
}
defer browser.Close()
page, _ := browser.NewPage()
res, err := page.Goto("https://tls.peet.ws/api/all", playwright.PageGotoOptions{Timeout: playwright.Float(30000)})
if err != nil { // a residential exit occasionally times out: one retry, in a fresh tab
page, _ = browser.NewPage()
res, err = page.Goto("https://tls.peet.ws/api/all", playwright.PageGotoOptions{Timeout: playwright.Float(30000)})
}
if err != nil {
return result{Variant: label, Error: err.Error()}
}
var fp peet
res.JSON(&fp)
r := result{Variant: label, CatalogVersion: expected, BrowserVersion: browser.Version(), UserAgent: fp.UserAgent,
JA4: fp.TLS.JA4, AkamaiH2Hash: fp.HTTP2.AkamaiFingerprintHash}
for _, f := range fp.HTTP2.SentFrames {
for _, h := range f.Headers {
if f.FrameType == "HEADERS" && strings.HasPrefix(h, "sec-ch-ua: ") {
v := h[11:]
r.SecChUA = &v
}
}
}
return r
}
func main() {
// The live catalog says which channels and previous majors exist right now.
res, err := http.Get("https://cdpfleet.com/api/public/browsers")
if err != nil {
log.Fatal(err)
}
var catalog struct {
Engines []struct {
Key string `json:"key"`
Versions []struct{ Label, Version string } `json:"versions"`
} `json:"engines"`
}
json.NewDecoder(res.Body).Decode(&catalog)
res.Body.Close()
pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
log.Fatal(err)
}
defer pw.Stop()
var jobs []func() result
for _, e := range catalog.Engines {
if e.Key != "chrome" {
continue
}
for _, v := range e.Versions {
v := v
label, options := "channel "+v.Label, map[string]any{}
if v.Label == "pinned" {
major := strings.Split(v.Version, ".")[0]
label, options["version"] = "version "+major, major
} else if v.Label != "stable" {
options["channel"] = v.Label
}
jobs = append(jobs, func() result { return probe(pw, label, options, v.Version) })
}
}
// All variants at once: each is its own session.
results := make([]result, len(jobs))
var wg sync.WaitGroup
for i, job := range jobs {
wg.Add(1)
go func(i int, job func() result) { defer wg.Done(); results[i] = job() }(i, job)
}
wg.Wait()
out, _ := json.MarshalIndent(results, "", " ")
fmt.Println(string(out))
}
What we got
| Variant | browser.version() | User agent | sec-ch-ua | JA4 |
|---|---|---|---|---|
| channel stable | 153.0.8010.36 | Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/203.0.113.1 Safari/537.36 | "Google Chrome";v="153", "Not_A Brand";v="8", "Chromium";v="153" | t13d1517h2_8daaf6152771_cb7bf5808d99 |
| channel beta | 155.0.8059.12 | Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/203.0.113.2 Safari/537.36 | "Google Chrome";v="155", "Chromium";v="155", "Not(A:Brand";v="24" | t13d1517h2_8daaf6152771_cb7bf5808d99 |
| channel dev | 156.0.8072.0 | Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/203.0.113.3 Safari/537.36 | "Not:A-Brand";v="8", "Chromium";v="156", "Google Chrome";v="156" | t13d1517h2_8daaf6152771_cb7bf5808d99 |
| version 152 | 152.0.7977.82 | Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/203.0.113.4 Safari/537.36 | "Chromium";v="152", "Not?A_Brand";v="24", "Google Chrome";v="152" | t13d1517h2_8daaf6152771_cb7bf5808d99 |
From the Node.js run on 2026-09-30. 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)
[
{
"variant": "channel stable",
"catalog_version": "153.0.8010.36",
"browser_version": "153.0.8010.36",
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/203.0.113.1 Safari/537.36",
"sec_ch_ua": "\"Google Chrome\";v=\"153\", \"Not_A Brand\";v=\"8\", \"Chromium\";v=\"153\"",
"ja4": "t13d1517h2_8daaf6152771_cb7bf5808d99",
"akamai_h2_hash": "52d84b11737d980aef856699f885ca86"
},
{
"variant": "channel beta",
"catalog_version": "155.0.8059.12",
"browser_version": "155.0.8059.12",
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/203.0.113.2 Safari/537.36",
"sec_ch_ua": "\"Google Chrome\";v=\"155\", \"Chromium\";v=\"155\", \"Not(A:Brand\";v=\"24\"",
"ja4": "t13d1517h2_8daaf6152771_cb7bf5808d99",
"akamai_h2_hash": "52d84b11737d980aef856699f885ca86"
},
{
"variant": "channel dev",
"catalog_version": "156.0.8072.0",
"browser_version": "156.0.8072.0",
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/203.0.113.3 Safari/537.36",
"sec_ch_ua": "\"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"156\", \"Google Chrome\";v=\"156\"",
"ja4": "t13d1517h2_8daaf6152771_cb7bf5808d99",
"akamai_h2_hash": "52d84b11737d980aef856699f885ca86"
},
{
"variant": "version 152",
"catalog_version": "152.0.7977.82",
"browser_version": "152.0.7977.82",
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/203.0.113.4 Safari/537.36",
"sec_ch_ua": "\"Chromium\";v=\"152\", \"Not?A_Brand\";v=\"24\", \"Google Chrome\";v=\"152\"",
"ja4": "t13d1517h2_8daaf6152771_cb7bf5808d99",
"akamai_h2_hash": "52d84b11737d980aef856699f885ca86"
}
]Takeaways
- Every channel got exactly the build the catalog lists, to the last digit. A pinned major gives you that major; the patch level can differ between servers (the catalog shows the most common one).
- The user agent only carries the major (
Chrome/155.0.0.0): Chrome's user-agent reduction. The full version is insec-ch-ua-full-version-listfor sites that ask. sec-ch-uachanges per major — including its "GREASE" brand (Not_A Brand,Not(A:Brand,Not:A-Brand…), which Chrome rotates on purpose. Copying one version's header onto another is detectable.- TLS (JA4) and HTTP/2 fingerprints are identical from 152 to 156 — so a pinned major doesn't cost you anything on the network side.