Cases / #66 · 2026-10-22 · Medium
Which fingerprints stay the same across launches: canvas, WebGL, audio and fonts on seven browsers
Three launches per browser, each reading a canvas hash, the WebGL renderer and a rendered WebGL image, an audio hash and a font list on the same page. Chrome, Edge, Chromium, Firefox and WebKit gave the same values every time. Brave changed its canvas and audio on every launch, and Camoufox its canvas (and usually its GPU). A profile kept Camoufox's GPU and fonts but not the canvas, and kept nothing of Brave's noise.
Google Chrome Microsoft Edge Chromium Brave Camoufox Firefox WebKit
Run on production on 2026-10-22: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
Fingerprinting scripts don't only ask "is this a bot?". They also store a hash of your canvas, GPU, audio stack and fonts and compare it next time. A value that never changes ties all your sessions together; a value that changes on every visit can look like a different device each time, which is just as unusual for an account that should be one person. So which of these values does each browser keep from one launch to the next, which does it randomise, and what does a persistent profile change?
What we used, and why
| What | Why |
|---|---|
| A small fingerprinting script | The same script in every language: a canvas drawing, the WebGL renderer string plus a hash of a rendered WebGL image, an OfflineAudioContext render, and a width probe over 30 font families. Canvas and audio are drawn twice to see whether a session agrees with itself. |
https://example.com/ | The script runs on a real page. Brave and Camoufox key their noise to the site, so on about:blank both looked perfectly stable while building this case. |
| Three launches per browser | Each a new session, headless, through the same proxy. Nothing else is set, so these are the defaults. |
"profile": "…" | Two more launches per browser with one new profile, to see what the profile carries over. See Persistent profiles. |
How it works
- For each of seven browsers, launch three sessions, open example.com and run the fingerprinting script.
- Launch twice more with one new profile, waiting for the first session's profile to be saved, and run the script again.
- Delete the profile and print every hash.
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 { randomBytes } from 'node:crypto';
import { chromium, firefox, webkit } from 'playwright';
const KEY = process.env.CDPFLEET_API_KEY;
const RUN = randomBytes(3).toString('hex');
const ENGINES = [
['chrome', chromium], ['edge', chromium], ['chromium', chromium], ['brave', chromium],
['camoufox', firefox], ['firefox', firefox], ['webkit', webkit],
];
const LAUNCHES = 3;
// What a fingerprinting script computes: canvas, WebGL (renderer + a rendered image), audio and a
// font list, each as a short hash; canvas and audio are drawn twice to see whether a session is
// consistent with itself. The same script runs in every language.
const FP = `(async () => {
const fnv = (s) => { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; } return h.toString(16).padStart(8, '0'); };
const draw = () => {
const c = document.createElement('canvas'); c.width = 240; c.height = 60;
const x = c.getContext('2d'); x.textBaseline = 'top'; x.font = '16px Arial';
x.fillStyle = '#f60'; x.fillRect(100, 1, 62, 20);
x.fillStyle = '#069'; x.fillText('cdpfleet fp 66 <@>', 2, 15);
x.fillStyle = 'rgba(102, 204, 0, 0.7)'; x.fillText('cdpfleet fp 66 <@>', 4, 17);
return c.toDataURL();
};
const webgl = () => {
try {
const c = document.createElement('canvas'); c.width = 64; c.height = 64;
const gl = c.getContext('webgl', { preserveDrawingBuffer: true });
if (!gl) return { renderer: null, image: null };
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
const renderer = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
const sh = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); return s; };
const pr = gl.createProgram();
gl.attachShader(pr, sh(gl.VERTEX_SHADER, 'attribute vec2 p;varying vec2 v;void main(){v=p;gl_Position=vec4(p,0.0,1.0);}'));
gl.attachShader(pr, sh(gl.FRAGMENT_SHADER, 'precision mediump float;varying vec2 v;void main(){gl_FragColor=vec4(sin(v.x*7.1)*0.5+0.5,cos(v.y*5.3)*0.5+0.5,fract(v.x*v.y*13.7),1.0);}'));
gl.linkProgram(pr); gl.useProgram(pr);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, 0, 1]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(pr, 'p'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
const px = new Uint8Array(64 * 64 * 4); gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, px);
return { renderer, image: fnv(px.join(',')) };
} catch (e) { return { renderer: null, image: null }; }
};
const audio = async () => {
try {
const ctx = new OfflineAudioContext(1, 44100, 44100);
const o = ctx.createOscillator(); o.type = 'triangle'; o.frequency.value = 10000;
const k = ctx.createDynamicsCompressor();
k.threshold.value = -50; k.knee.value = 40; k.ratio.value = 12; k.attack.value = 0; k.release.value = 0.25;
o.connect(k); k.connect(ctx.destination); o.start(0);
// copyFromChannel into our own array: Camoufox runs this script in an isolated world, where
// reading the page's audio buffer directly is refused.
const d = new Float32Array(500); (await ctx.startRendering()).copyFromChannel(d, 0, 4500);
let s = ''; for (let i = 0; i < 500; i++) s += d[i].toFixed(10) + ',';
return fnv(s);
} catch (e) { return 'error: ' + e.name; }
};
// Width probe: a family counts as installed when it measures differently from all three fallbacks.
const FAMILIES = ['Arial', 'Arial Black', 'Calibri', 'Cambria', 'Candara', 'Comic Sans MS', 'Consolas', 'Courier New', 'DejaVu Sans', 'Georgia', 'Helvetica', 'Helvetica Neue', 'Impact', 'Liberation Sans', 'Lucida Console', 'Lucida Grande', 'Menlo', 'Monaco', 'Noto Sans', 'Palatino', 'Segoe UI', 'Tahoma', 'Times New Roman', 'Trebuchet MS', 'Ubuntu', 'Verdana', 'Roboto', 'Open Sans', 'Fira Code', 'Avenir'];
const fonts = () => {
const c = document.createElement('canvas').getContext('2d');
const w = (f) => { c.font = '48px ' + f; return c.measureText('mmmmmmmmmmlli1WQ@#').width; };
const base = ['monospace', 'serif', 'sans-serif'].map((b) => [b, w(b)]);
return FAMILIES.filter((f) => base.some(([b, bw]) => w(JSON.stringify(f) + ', ' + b) !== bw));
};
const c1 = draw(), c2 = draw();
const a1 = await audio(), a2 = await audio();
const g = webgl();
const found = fonts();
return {
canvas: fnv(c1), canvas_same_in_session: c1 === c2,
webgl_renderer: g.renderer, webgl_image: g.image,
audio: a1, audio_same_in_session: a1 === a2,
fonts: fnv(found.join(',')), font_count: found.length,
user_agent: navigator.userAgent,
};
})()`;
async function api(method, path) {
const res = await fetch(`https://cdpfleet.com${path}`, { method, headers: { 'x-api-key': KEY } });
return { status: res.status, body: await res.json().catch(() => ({})) };
}
// The node saves a profile after the browser exits: wait for the new version before relaunching.
async function waitForVersion(name, version) {
for (let i = 0; i < 60; i++) {
const p = (await api('GET', '/v1/me/profiles')).body.profiles?.find((x) => x.name === name);
if (p?.version >= version && !p.in_use) return;
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error(`profile ${name} never reached version ${version}`);
}
// 503 = the fleet is momentarily busy: wait Retry-After and try again. 409 profile_version_downgrade =
// the profile was saved by a newer build than this server has (mid-rollout): another try lands elsewhere.
async function launch(engine, body) {
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(body),
});
if (res.ok) return res.json();
const text = await res.text();
const retry = res.status === 503 || (res.status === 409 && text.includes('profile_version_downgrade'));
if (!retry || attempt === 5) throw new Error(`${engine} launch ${res.status} ${text}`);
await new Promise((r) => setTimeout(r, (Number(res.headers.get('retry-after')) || 5) * 1000));
}
}
// One session: read the fingerprint on a real page. Brave and Camoufox key their noise to the site,
// so a blank page would hide it.
const SITE = 'https://example.com/';
// Proxies drop a tunnel now and then: up to two retries.
async function go(page) {
for (let attempt = 1; ; attempt++) {
try { return await page.goto(SITE, { timeout: 60000 }); } catch (err) {
if (attempt === 3) throw err;
await new Promise((r) => setTimeout(r, 2000));
}
}
}
async function measure(engine, type, profile) {
const s = await launch(engine, { proxy: process.env.PROXY_URL, headless: true, record: false, ...(profile ? { profile } : {}) });
const browser = await type.connect(s.wsUrl, { headers: { 'x-api-key': KEY } });
try {
// A profile lives in browser.contexts()[0]; without one, a fresh context.
const context = profile ? browser.contexts()[0] : await browser.newContext();
const page = context.pages()[0] ?? await context.newPage();
await go(page);
return await page.evaluate(FP);
} finally {
await browser.close();
}
}
const out = { launches: {}, profile: {} };
for (const [engine, type] of ENGINES) {
out.launches[engine] = [];
for (let i = 0; i < LAUNCHES; i++) out.launches[engine].push(await measure(engine, type));
// Two launches with one new profile: what does it keep?
const name = `case66-${engine}-${RUN}`;
try {
const first = await measure(engine, type, name);
await waitForVersion(name, 1);
out.profile[engine] = [first, await measure(engine, type, name)];
await waitForVersion(name, 2);
} finally {
await api('DELETE', `/v1/me/profiles/${name}`);
}
}
console.log(JSON.stringify(out, null, 2));
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import json
import os
import secrets
import time
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
RUN = secrets.token_hex(3)
ENGINES = [("chrome", "chromium"), ("edge", "chromium"), ("chromium", "chromium"), ("brave", "chromium"),
("camoufox", "firefox"), ("firefox", "firefox"), ("webkit", "webkit")]
LAUNCHES = 3
# Brave and Camoufox key their noise to the site, so the script runs on a real page.
SITE = "https://example.com/"
# What a fingerprinting script computes: canvas, WebGL (renderer + a rendered image), audio and a
# font list, each as a short hash; canvas and audio are drawn twice to see whether a session is
# consistent with itself. The same script runs in every language.
FP = """(async () => {
const fnv = (s) => { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; } return h.toString(16).padStart(8, '0'); };
const draw = () => {
const c = document.createElement('canvas'); c.width = 240; c.height = 60;
const x = c.getContext('2d'); x.textBaseline = 'top'; x.font = '16px Arial';
x.fillStyle = '#f60'; x.fillRect(100, 1, 62, 20);
x.fillStyle = '#069'; x.fillText('cdpfleet fp 66 <@>', 2, 15);
x.fillStyle = 'rgba(102, 204, 0, 0.7)'; x.fillText('cdpfleet fp 66 <@>', 4, 17);
return c.toDataURL();
};
const webgl = () => {
try {
const c = document.createElement('canvas'); c.width = 64; c.height = 64;
const gl = c.getContext('webgl', { preserveDrawingBuffer: true });
if (!gl) return { renderer: null, image: null };
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
const renderer = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
const sh = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); return s; };
const pr = gl.createProgram();
gl.attachShader(pr, sh(gl.VERTEX_SHADER, 'attribute vec2 p;varying vec2 v;void main(){v=p;gl_Position=vec4(p,0.0,1.0);}'));
gl.attachShader(pr, sh(gl.FRAGMENT_SHADER, 'precision mediump float;varying vec2 v;void main(){gl_FragColor=vec4(sin(v.x*7.1)*0.5+0.5,cos(v.y*5.3)*0.5+0.5,fract(v.x*v.y*13.7),1.0);}'));
gl.linkProgram(pr); gl.useProgram(pr);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, 0, 1]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(pr, 'p'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
const px = new Uint8Array(64 * 64 * 4); gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, px);
return { renderer, image: fnv(px.join(',')) };
} catch (e) { return { renderer: null, image: null }; }
};
const audio = async () => {
try {
const ctx = new OfflineAudioContext(1, 44100, 44100);
const o = ctx.createOscillator(); o.type = 'triangle'; o.frequency.value = 10000;
const k = ctx.createDynamicsCompressor();
k.threshold.value = -50; k.knee.value = 40; k.ratio.value = 12; k.attack.value = 0; k.release.value = 0.25;
o.connect(k); k.connect(ctx.destination); o.start(0);
// copyFromChannel into our own array: Camoufox runs this script in an isolated world, where
// reading the page's audio buffer directly is refused.
const d = new Float32Array(500); (await ctx.startRendering()).copyFromChannel(d, 0, 4500);
let s = ''; for (let i = 0; i < 500; i++) s += d[i].toFixed(10) + ',';
return fnv(s);
} catch (e) { return 'error: ' + e.name; }
};
// Width probe: a family counts as installed when it measures differently from all three fallbacks.
const FAMILIES = ['Arial', 'Arial Black', 'Calibri', 'Cambria', 'Candara', 'Comic Sans MS', 'Consolas', 'Courier New', 'DejaVu Sans', 'Georgia', 'Helvetica', 'Helvetica Neue', 'Impact', 'Liberation Sans', 'Lucida Console', 'Lucida Grande', 'Menlo', 'Monaco', 'Noto Sans', 'Palatino', 'Segoe UI', 'Tahoma', 'Times New Roman', 'Trebuchet MS', 'Ubuntu', 'Verdana', 'Roboto', 'Open Sans', 'Fira Code', 'Avenir'];
const fonts = () => {
const c = document.createElement('canvas').getContext('2d');
const w = (f) => { c.font = '48px ' + f; return c.measureText('mmmmmmmmmmlli1WQ@#').width; };
const base = ['monospace', 'serif', 'sans-serif'].map((b) => [b, w(b)]);
return FAMILIES.filter((f) => base.some(([b, bw]) => w(JSON.stringify(f) + ', ' + b) !== bw));
};
const c1 = draw(), c2 = draw();
const a1 = await audio(), a2 = await audio();
const g = webgl();
const found = fonts();
return {
canvas: fnv(c1), canvas_same_in_session: c1 === c2,
webgl_renderer: g.renderer, webgl_image: g.image,
audio: a1, audio_same_in_session: a1 === a2,
fonts: fnv(found.join(',')), font_count: found.length,
user_agent: navigator.userAgent,
};
})()"""
def api(method, path):
return requests.request(method, f"https://cdpfleet.com{path}", headers={"x-api-key": KEY}, timeout=30)
# The node saves a profile after the browser exits: wait for the new version before relaunching.
def wait_for_version(name, version):
for _ in range(60):
p = next((x for x in api("GET", "/v1/me/profiles").json().get("profiles", []) if x["name"] == name), None)
if p and p["version"] >= version and not p["in_use"]:
return
time.sleep(2)
raise RuntimeError(f"profile {name} never reached version {version}")
# 503 = the fleet is momentarily busy: wait Retry-After and try again. 409 profile_version_downgrade =
# the profile was saved by a newer build than this server has (mid-rollout): another try lands elsewhere.
def launch(engine, body):
for attempt in range(1, 6):
r = requests.post(f"https://starter.cdpfleet.com/{engine}/session", headers={"x-api-key": KEY}, json=body, timeout=120)
if r.ok:
return r.json()
retry = r.status_code == 503 or (r.status_code == 409 and "profile_version_downgrade" in r.text)
if not retry or attempt == 5:
raise RuntimeError(f"{engine} launch {r.status_code} {r.text}")
time.sleep(int(r.headers.get("retry-after") or 5))
# Proxies drop a tunnel now and then: up to two retries.
def go(page):
for attempt in range(1, 4):
try:
return page.goto(SITE, timeout=60000)
except Exception:
if attempt == 3:
raise
time.sleep(2)
def measure(pw, engine, family, profile=None):
body = {"proxy": os.environ["PROXY_URL"], "headless": True, "record": False}
if profile:
body["profile"] = profile
s = launch(engine, body)
browser = getattr(pw, family).connect(s["wsUrl"], headers={"x-api-key": KEY})
try:
# A profile lives in browser.contexts[0]; without one, a fresh context.
context = browser.contexts[0] if profile else browser.new_context()
page = context.pages[0] if context.pages else context.new_page()
go(page)
return page.evaluate(FP)
finally:
browser.close()
out = {"launches": {}, "profile": {}}
with sync_playwright() as pw:
for engine, family in ENGINES:
out["launches"][engine] = [measure(pw, engine, family) for _ in range(LAUNCHES)]
# Two launches with one new profile: what does it keep?
name = f"case66-{engine}-{RUN}"
try:
first = measure(pw, engine, family, name)
wait_for_version(name, 1)
out["profile"][engine] = [first, measure(pw, engine, family, name)]
wait_for_version(name, 2)
finally:
api("DELETE", f"/v1/me/profiles/{name}")
print(json.dumps(out, indent=2, ensure_ascii=False))
// 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.security.SecureRandom;
import java.util.*;
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 = HexFormat.of().formatHex(new SecureRandom().generateSeed(3));
static final String[][] ENGINES = {{"chrome", "chromium"}, {"edge", "chromium"}, {"chromium", "chromium"}, {"brave", "chromium"},
{"camoufox", "firefox"}, {"firefox", "firefox"}, {"webkit", "webkit"}};
static final int LAUNCHES = 3;
// Brave and Camoufox key their noise to the site, so the script runs on a real page.
static final String SITE = "https://example.com/";
// What a fingerprinting script computes: canvas, WebGL (renderer + a rendered image), audio and a
// font list, each as a short hash; canvas and audio are drawn twice to see whether a session is
// consistent with itself. The same script runs in every language.
static final String FP = """
(async () => {
const fnv = (s) => { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; } return h.toString(16).padStart(8, '0'); };
const draw = () => {
const c = document.createElement('canvas'); c.width = 240; c.height = 60;
const x = c.getContext('2d'); x.textBaseline = 'top'; x.font = '16px Arial';
x.fillStyle = '#f60'; x.fillRect(100, 1, 62, 20);
x.fillStyle = '#069'; x.fillText('cdpfleet fp 66 <@>', 2, 15);
x.fillStyle = 'rgba(102, 204, 0, 0.7)'; x.fillText('cdpfleet fp 66 <@>', 4, 17);
return c.toDataURL();
};
const webgl = () => {
try {
const c = document.createElement('canvas'); c.width = 64; c.height = 64;
const gl = c.getContext('webgl', { preserveDrawingBuffer: true });
if (!gl) return { renderer: null, image: null };
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
const renderer = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
const sh = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); return s; };
const pr = gl.createProgram();
gl.attachShader(pr, sh(gl.VERTEX_SHADER, 'attribute vec2 p;varying vec2 v;void main(){v=p;gl_Position=vec4(p,0.0,1.0);}'));
gl.attachShader(pr, sh(gl.FRAGMENT_SHADER, 'precision mediump float;varying vec2 v;void main(){gl_FragColor=vec4(sin(v.x*7.1)*0.5+0.5,cos(v.y*5.3)*0.5+0.5,fract(v.x*v.y*13.7),1.0);}'));
gl.linkProgram(pr); gl.useProgram(pr);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, 0, 1]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(pr, 'p'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
const px = new Uint8Array(64 * 64 * 4); gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, px);
return { renderer, image: fnv(px.join(',')) };
} catch (e) { return { renderer: null, image: null }; }
};
const audio = async () => {
try {
const ctx = new OfflineAudioContext(1, 44100, 44100);
const o = ctx.createOscillator(); o.type = 'triangle'; o.frequency.value = 10000;
const k = ctx.createDynamicsCompressor();
k.threshold.value = -50; k.knee.value = 40; k.ratio.value = 12; k.attack.value = 0; k.release.value = 0.25;
o.connect(k); k.connect(ctx.destination); o.start(0);
// copyFromChannel into our own array: Camoufox runs this script in an isolated world, where
// reading the page's audio buffer directly is refused.
const d = new Float32Array(500); (await ctx.startRendering()).copyFromChannel(d, 0, 4500);
let s = ''; for (let i = 0; i < 500; i++) s += d[i].toFixed(10) + ',';
return fnv(s);
} catch (e) { return 'error: ' + e.name; }
};
// Width probe: a family counts as installed when it measures differently from all three fallbacks.
const FAMILIES = ['Arial', 'Arial Black', 'Calibri', 'Cambria', 'Candara', 'Comic Sans MS', 'Consolas', 'Courier New', 'DejaVu Sans', 'Georgia', 'Helvetica', 'Helvetica Neue', 'Impact', 'Liberation Sans', 'Lucida Console', 'Lucida Grande', 'Menlo', 'Monaco', 'Noto Sans', 'Palatino', 'Segoe UI', 'Tahoma', 'Times New Roman', 'Trebuchet MS', 'Ubuntu', 'Verdana', 'Roboto', 'Open Sans', 'Fira Code', 'Avenir'];
const fonts = () => {
const c = document.createElement('canvas').getContext('2d');
const w = (f) => { c.font = '48px ' + f; return c.measureText('mmmmmmmmmmlli1WQ@#').width; };
const base = ['monospace', 'serif', 'sans-serif'].map((b) => [b, w(b)]);
return FAMILIES.filter((f) => base.some(([b, bw]) => w(JSON.stringify(f) + ', ' + b) !== bw));
};
const c1 = draw(), c2 = draw();
const a1 = await audio(), a2 = await audio();
const g = webgl();
const found = fonts();
return {
canvas: fnv(c1), canvas_same_in_session: c1 === c2,
webgl_renderer: g.renderer, webgl_image: g.image,
audio: a1, audio_same_in_session: a1 === a2,
fonts: fnv(found.join(',')), font_count: found.length,
user_agent: navigator.userAgent,
};
})()
""";
static HttpResponse<String> api(String method, String path) throws Exception {
return HTTP.send(HttpRequest.newBuilder(URI.create("https://cdpfleet.com" + path)).header("x-api-key", KEY).method(method, HttpRequest.BodyPublishers.noBody()).build(), HttpResponse.BodyHandlers.ofString());
}
// The node saves a profile after the browser exits: wait for the new version before relaunching.
static void waitForVersion(String name, int version) throws Exception {
for (int i = 0; i < 60; i++) {
for (JsonElement e : JsonParser.parseString(api("GET", "/v1/me/profiles").body()).getAsJsonObject().getAsJsonArray("profiles")) {
JsonObject p = e.getAsJsonObject();
if (p.get("name").getAsString().equals(name) && p.get("version").getAsInt() >= version && !p.get("in_use").getAsBoolean()) return;
}
Thread.sleep(2000);
}
throw new RuntimeException("profile " + name + " never reached version " + version);
}
// 503 = the fleet is momentarily busy: wait Retry-After and try again. 409 profile_version_downgrade =
// the profile was saved by a newer build than this server has (mid-rollout): another try lands elsewhere.
static JsonObject launch(String engine, JsonObject body) throws Exception {
for (int attempt = 1; ; attempt++) {
HttpResponse<String> res = HTTP.send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/" + engine + "/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toString())).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() == 200) return JsonParser.parseString(res.body()).getAsJsonObject();
boolean retry = res.statusCode() == 503 || (res.statusCode() == 409 && res.body().contains("profile_version_downgrade"));
if (!retry || attempt == 5) throw new RuntimeException(engine + " launch " + res.statusCode() + " " + res.body());
Thread.sleep(Long.parseLong(res.headers().firstValue("retry-after").orElse("5")) * 1000);
}
}
// Proxies drop a tunnel now and then: up to two retries.
static void go(Page page) throws Exception {
for (int attempt = 1; ; attempt++) {
try {
page.navigate(SITE, new Page.NavigateOptions().setTimeout(60000));
return;
} catch (PlaywrightException err) {
if (attempt == 3) throw err;
Thread.sleep(2000);
}
}
}
static BrowserType type(Playwright pw, String family) {
return family.equals("firefox") ? pw.firefox() : family.equals("webkit") ? pw.webkit() : pw.chromium();
}
static JsonElement measure(Playwright pw, String engine, String family, String profile) throws Exception {
JsonObject body = new JsonObject();
body.addProperty("proxy", System.getenv("PROXY_URL"));
body.addProperty("headless", true);
body.addProperty("record", false);
if (profile != null) body.addProperty("profile", profile);
JsonObject s = launch(engine, body);
Browser browser = type(pw, family).connect(s.get("wsUrl").getAsString(), new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
try {
// A profile lives in browser.contexts().get(0); without one, a fresh context.
BrowserContext context = profile != null ? browser.contexts().get(0) : browser.newContext();
Page page = context.pages().isEmpty() ? context.newPage() : context.pages().get(0);
go(page);
return GSON.toJsonTree(page.evaluate(FP));
} finally {
browser.close();
}
}
public static void main(String[] args) throws Exception {
JsonObject launches = new JsonObject(), profiles = new JsonObject();
try (Playwright pw = Playwright.create()) {
for (String[] e : ENGINES) {
JsonArray runs = new JsonArray();
for (int i = 0; i < LAUNCHES; i++) runs.add(measure(pw, e[0], e[1], null));
launches.add(e[0], runs);
// Two launches with one new profile: what does it keep?
String name = "case66-" + e[0] + "-" + RUN;
try {
JsonArray pair = new JsonArray();
pair.add(measure(pw, e[0], e[1], name));
waitForVersion(name, 1);
pair.add(measure(pw, e[0], e[1], name));
waitForVersion(name, 2);
profiles.add(e[0], pair);
} finally {
api("DELETE", "/v1/me/profiles/" + name);
}
}
}
JsonObject out = new JsonObject();
out.add("launches", launches);
out.add("profile", profiles);
System.out.println(GSON.toJson(out));
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
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)).ToLower();
var engines = new (string Engine, string Family)[] { ("chrome", "chromium"), ("edge", "chromium"), ("chromium", "chromium"), ("brave", "chromium"),
("camoufox", "firefox"), ("firefox", "firefox"), ("webkit", "webkit") };
const int Launches = 3;
// Brave and Camoufox key their noise to the site, so the script runs on a real page.
const string Site = "https://example.com/";
// What a fingerprinting script computes: canvas, WebGL (renderer + a rendered image), audio and a
// font list, each as a short hash; canvas and audio are drawn twice to see whether a session is
// consistent with itself. The same script runs in every language.
const string Fp = @"(async () => {
const fnv = (s) => { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; } return h.toString(16).padStart(8, '0'); };
const draw = () => {
const c = document.createElement('canvas'); c.width = 240; c.height = 60;
const x = c.getContext('2d'); x.textBaseline = 'top'; x.font = '16px Arial';
x.fillStyle = '#f60'; x.fillRect(100, 1, 62, 20);
x.fillStyle = '#069'; x.fillText('cdpfleet fp 66 <@>', 2, 15);
x.fillStyle = 'rgba(102, 204, 0, 0.7)'; x.fillText('cdpfleet fp 66 <@>', 4, 17);
return c.toDataURL();
};
const webgl = () => {
try {
const c = document.createElement('canvas'); c.width = 64; c.height = 64;
const gl = c.getContext('webgl', { preserveDrawingBuffer: true });
if (!gl) return { renderer: null, image: null };
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
const renderer = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
const sh = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); return s; };
const pr = gl.createProgram();
gl.attachShader(pr, sh(gl.VERTEX_SHADER, 'attribute vec2 p;varying vec2 v;void main(){v=p;gl_Position=vec4(p,0.0,1.0);}'));
gl.attachShader(pr, sh(gl.FRAGMENT_SHADER, 'precision mediump float;varying vec2 v;void main(){gl_FragColor=vec4(sin(v.x*7.1)*0.5+0.5,cos(v.y*5.3)*0.5+0.5,fract(v.x*v.y*13.7),1.0);}'));
gl.linkProgram(pr); gl.useProgram(pr);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, 0, 1]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(pr, 'p'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
const px = new Uint8Array(64 * 64 * 4); gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, px);
return { renderer, image: fnv(px.join(',')) };
} catch (e) { return { renderer: null, image: null }; }
};
const audio = async () => {
try {
const ctx = new OfflineAudioContext(1, 44100, 44100);
const o = ctx.createOscillator(); o.type = 'triangle'; o.frequency.value = 10000;
const k = ctx.createDynamicsCompressor();
k.threshold.value = -50; k.knee.value = 40; k.ratio.value = 12; k.attack.value = 0; k.release.value = 0.25;
o.connect(k); k.connect(ctx.destination); o.start(0);
// copyFromChannel into our own array: Camoufox runs this script in an isolated world, where
// reading the page's audio buffer directly is refused.
const d = new Float32Array(500); (await ctx.startRendering()).copyFromChannel(d, 0, 4500);
let s = ''; for (let i = 0; i < 500; i++) s += d[i].toFixed(10) + ',';
return fnv(s);
} catch (e) { return 'error: ' + e.name; }
};
// Width probe: a family counts as installed when it measures differently from all three fallbacks.
const FAMILIES = ['Arial', 'Arial Black', 'Calibri', 'Cambria', 'Candara', 'Comic Sans MS', 'Consolas', 'Courier New', 'DejaVu Sans', 'Georgia', 'Helvetica', 'Helvetica Neue', 'Impact', 'Liberation Sans', 'Lucida Console', 'Lucida Grande', 'Menlo', 'Monaco', 'Noto Sans', 'Palatino', 'Segoe UI', 'Tahoma', 'Times New Roman', 'Trebuchet MS', 'Ubuntu', 'Verdana', 'Roboto', 'Open Sans', 'Fira Code', 'Avenir'];
const fonts = () => {
const c = document.createElement('canvas').getContext('2d');
const w = (f) => { c.font = '48px ' + f; return c.measureText('mmmmmmmmmmlli1WQ@#').width; };
const base = ['monospace', 'serif', 'sans-serif'].map((b) => [b, w(b)]);
return FAMILIES.filter((f) => base.some(([b, bw]) => w(JSON.stringify(f) + ', ' + b) !== bw));
};
const c1 = draw(), c2 = draw();
const a1 = await audio(), a2 = await audio();
const g = webgl();
const found = fonts();
return {
canvas: fnv(c1), canvas_same_in_session: c1 === c2,
webgl_renderer: g.renderer, webgl_image: g.image,
audio: a1, audio_same_in_session: a1 === a2,
fonts: fnv(found.join(',')), font_count: found.length,
user_agent: navigator.userAgent,
};
})()";
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();
// The node saves a profile after the browser exits: wait for the new version before relaunching.
async Task WaitForVersion(string name, int version)
{
for (var i = 0; i < 60; i++)
{
var profiles = (await http.GetFromJsonAsync<JsonElement>("https://cdpfleet.com/v1/me/profiles")).GetProperty("profiles");
foreach (var p in profiles.EnumerateArray())
if (p.GetProperty("name").GetString() == name && p.GetProperty("version").GetInt32() >= version && !p.GetProperty("in_use").GetBoolean()) return;
await Task.Delay(2000);
}
throw new Exception($"profile {name} never reached version {version}");
}
// 503 = the fleet is momentarily busy: wait Retry-After and try again. 409 profile_version_downgrade =
// the profile was saved by a newer build than this server has (mid-rollout): another try lands elsewhere.
async Task<JsonElement> Launch(string engine, JsonObject body)
{
for (var attempt = 1; ; attempt++)
{
var res = await http.PostAsync($"https://starter.cdpfleet.com/{engine}/session", new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"));
if (res.IsSuccessStatusCode) return await res.Content.ReadFromJsonAsync<JsonElement>();
var text = await res.Content.ReadAsStringAsync();
var retry = (int)res.StatusCode == 503 || ((int)res.StatusCode == 409 && text.Contains("profile_version_downgrade"));
if (!retry || attempt == 5) throw new Exception($"{engine} launch {(int)res.StatusCode} {text}");
await Task.Delay((int)(res.Headers.RetryAfter?.Delta?.TotalMilliseconds ?? 5000));
}
}
// Proxies drop a tunnel now and then: up to two retries.
async Task Go(IPage page)
{
for (var attempt = 1; ; attempt++)
{
try { await page.GotoAsync(Site, new() { Timeout = 60000 }); return; }
catch (PlaywrightException) when (attempt < 3) { await Task.Delay(2000); }
}
}
async Task<JsonNode> Measure(string engine, string family, string? profile)
{
var body = new JsonObject { ["proxy"] = Environment.GetEnvironmentVariable("PROXY_URL"), ["headless"] = true, ["record"] = false };
if (profile != null) body["profile"] = profile;
var s = await Launch(engine, body);
var type = family == "firefox" ? playwright.Firefox : family == "webkit" ? playwright.Webkit : playwright.Chromium;
var browser = await type.ConnectAsync(s.GetProperty("wsUrl").GetString()!, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
try
{
// A profile lives in browser.Contexts[0]; without one, a fresh context.
var context = profile != null ? browser.Contexts[0] : await browser.NewContextAsync();
var page = context.Pages.Count > 0 ? context.Pages[0] : await context.NewPageAsync();
await Go(page);
return JsonNode.Parse((await page.EvaluateAsync<JsonElement>(Fp)).GetRawText())!;
}
finally
{
await browser.CloseAsync();
}
}
var launches = new JsonObject();
var profiles = new JsonObject();
foreach (var (engine, family) in engines)
{
var runs = new JsonArray();
for (var i = 0; i < Launches; i++) runs.Add(await Measure(engine, family, null));
launches[engine] = runs;
// Two launches with one new profile: what does it keep?
var name = $"case66-{engine}-{run}";
try
{
var first = await Measure(engine, family, name);
await WaitForVersion(name, 1);
var second = await Measure(engine, family, name);
await WaitForVersion(name, 2);
profiles[engine] = new JsonArray(first, second);
}
finally
{
await http.DeleteAsync($"https://cdpfleet.com/v1/me/profiles/{name}");
}
}
Console.WriteLine(new JsonObject { ["launches"] = launches, ["profile"] = profiles }.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"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
var engines = [][2]string{{"chrome", "chromium"}, {"edge", "chromium"}, {"chromium", "chromium"}, {"brave", "chromium"},
{"camoufox", "firefox"}, {"firefox", "firefox"}, {"webkit", "webkit"}}
const launches = 3
// Brave and Camoufox key their noise to the site, so the script runs on a real page.
const site = "https://example.com/"
// What a fingerprinting script computes: canvas, WebGL (renderer + a rendered image), audio and a
// font list, each as a short hash; canvas and audio are drawn twice to see whether a session is
// consistent with itself. The same script runs in every language.
const fp = `(async () => {
const fnv = (s) => { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; } return h.toString(16).padStart(8, '0'); };
const draw = () => {
const c = document.createElement('canvas'); c.width = 240; c.height = 60;
const x = c.getContext('2d'); x.textBaseline = 'top'; x.font = '16px Arial';
x.fillStyle = '#f60'; x.fillRect(100, 1, 62, 20);
x.fillStyle = '#069'; x.fillText('cdpfleet fp 66 <@>', 2, 15);
x.fillStyle = 'rgba(102, 204, 0, 0.7)'; x.fillText('cdpfleet fp 66 <@>', 4, 17);
return c.toDataURL();
};
const webgl = () => {
try {
const c = document.createElement('canvas'); c.width = 64; c.height = 64;
const gl = c.getContext('webgl', { preserveDrawingBuffer: true });
if (!gl) return { renderer: null, image: null };
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
const renderer = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
const sh = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); return s; };
const pr = gl.createProgram();
gl.attachShader(pr, sh(gl.VERTEX_SHADER, 'attribute vec2 p;varying vec2 v;void main(){v=p;gl_Position=vec4(p,0.0,1.0);}'));
gl.attachShader(pr, sh(gl.FRAGMENT_SHADER, 'precision mediump float;varying vec2 v;void main(){gl_FragColor=vec4(sin(v.x*7.1)*0.5+0.5,cos(v.y*5.3)*0.5+0.5,fract(v.x*v.y*13.7),1.0);}'));
gl.linkProgram(pr); gl.useProgram(pr);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, 0, 1]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(pr, 'p'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
const px = new Uint8Array(64 * 64 * 4); gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, px);
return { renderer, image: fnv(px.join(',')) };
} catch (e) { return { renderer: null, image: null }; }
};
const audio = async () => {
try {
const ctx = new OfflineAudioContext(1, 44100, 44100);
const o = ctx.createOscillator(); o.type = 'triangle'; o.frequency.value = 10000;
const k = ctx.createDynamicsCompressor();
k.threshold.value = -50; k.knee.value = 40; k.ratio.value = 12; k.attack.value = 0; k.release.value = 0.25;
o.connect(k); k.connect(ctx.destination); o.start(0);
// copyFromChannel into our own array: Camoufox runs this script in an isolated world, where
// reading the page's audio buffer directly is refused.
const d = new Float32Array(500); (await ctx.startRendering()).copyFromChannel(d, 0, 4500);
let s = ''; for (let i = 0; i < 500; i++) s += d[i].toFixed(10) + ',';
return fnv(s);
} catch (e) { return 'error: ' + e.name; }
};
// Width probe: a family counts as installed when it measures differently from all three fallbacks.
const FAMILIES = ['Arial', 'Arial Black', 'Calibri', 'Cambria', 'Candara', 'Comic Sans MS', 'Consolas', 'Courier New', 'DejaVu Sans', 'Georgia', 'Helvetica', 'Helvetica Neue', 'Impact', 'Liberation Sans', 'Lucida Console', 'Lucida Grande', 'Menlo', 'Monaco', 'Noto Sans', 'Palatino', 'Segoe UI', 'Tahoma', 'Times New Roman', 'Trebuchet MS', 'Ubuntu', 'Verdana', 'Roboto', 'Open Sans', 'Fira Code', 'Avenir'];
const fonts = () => {
const c = document.createElement('canvas').getContext('2d');
const w = (f) => { c.font = '48px ' + f; return c.measureText('mmmmmmmmmmlli1WQ@#').width; };
const base = ['monospace', 'serif', 'sans-serif'].map((b) => [b, w(b)]);
return FAMILIES.filter((f) => base.some(([b, bw]) => w(JSON.stringify(f) + ', ' + b) !== bw));
};
const c1 = draw(), c2 = draw();
const a1 = await audio(), a2 = await audio();
const g = webgl();
const found = fonts();
return {
canvas: fnv(c1), canvas_same_in_session: c1 === c2,
webgl_renderer: g.renderer, webgl_image: g.image,
audio: a1, audio_same_in_session: a1 === a2,
fonts: fnv(found.join(',')), font_count: found.length,
user_agent: navigator.userAgent,
};
})()`
// Field order matches the other languages' output.
type measurement struct {
Canvas string `json:"canvas"`
CanvasSameInSession bool `json:"canvas_same_in_session"`
WebGLRenderer *string `json:"webgl_renderer"`
WebGLImage *string `json:"webgl_image"`
Audio *string `json:"audio"`
AudioSameInSession bool `json:"audio_same_in_session"`
Fonts string `json:"fonts"`
FontCount int `json:"font_count"`
UserAgent string `json:"user_agent"`
}
func api(method, path string) (*http.Response, error) {
req, _ := http.NewRequest(method, "https://cdpfleet.com"+path, nil)
req.Header.Set("x-api-key", key)
return http.DefaultClient.Do(req)
}
// The node saves a profile after the browser exits: wait for the new version before relaunching.
func waitForVersion(name string, version int) error {
for i := 0; i < 60; i++ {
res, err := api("GET", "/v1/me/profiles")
if err == nil {
var body struct {
Profiles []struct {
Name string `json:"name"`
Version int `json:"version"`
InUse bool `json:"in_use"`
} `json:"profiles"`
}
json.NewDecoder(res.Body).Decode(&body)
res.Body.Close()
for _, p := range body.Profiles {
if p.Name == name && p.Version >= version && !p.InUse {
return nil
}
}
}
time.Sleep(2 * time.Second)
}
return fmt.Errorf("profile %s never reached version %d", name, version)
}
// 503 = the fleet is momentarily busy: wait Retry-After and try again. 409 profile_version_downgrade =
// the profile was saved by a newer build than this server has (mid-rollout): another try lands elsewhere.
func launch(engine string, opts map[string]any) (map[string]any, error) {
body, _ := json.Marshal(opts)
for attempt := 1; ; attempt++ {
req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+engine+"/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
}
b, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode == 200 {
var session map[string]any
return session, json.Unmarshal(b, &session)
}
retry := res.StatusCode == 503 || (res.StatusCode == 409 && strings.Contains(string(b), "profile_version_downgrade"))
if !retry || attempt == 5 {
return nil, fmt.Errorf("%s launch %d %s", engine, res.StatusCode, strings.TrimSpace(string(b)))
}
wait, err := strconv.Atoi(res.Header.Get("retry-after"))
if err != nil {
wait = 5
}
time.Sleep(time.Duration(wait) * time.Second)
}
}
// Proxies drop a tunnel now and then: up to two retries.
func goTo(page playwright.Page) error {
for attempt := 1; ; attempt++ {
_, err := page.Goto(site, playwright.PageGotoOptions{Timeout: playwright.Float(60000)})
if err == nil || attempt == 3 {
return err
}
time.Sleep(2 * time.Second)
}
}
func measure(pw *playwright.Playwright, engine, family, profile string) (measurement, error) {
opts := map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": true, "record": false}
if profile != "" {
opts["profile"] = profile
}
s, err := launch(engine, opts)
if err != nil {
return measurement{}, err
}
bt := pw.Chromium
if family == "firefox" {
bt = pw.Firefox
} else if family == "webkit" {
bt = pw.WebKit
}
browser, err := bt.Connect(s["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
if err != nil {
return measurement{}, err
}
defer browser.Close()
// A profile lives in browser.Contexts()[0]; without one, a fresh context.
var context playwright.BrowserContext
if profile != "" {
context = browser.Contexts()[0]
} else if context, err = browser.NewContext(); err != nil {
return measurement{}, err
}
var page playwright.Page
if pages := context.Pages(); len(pages) > 0 {
page = pages[0]
} else if page, err = context.NewPage(); err != nil {
return measurement{}, err
}
if err := goTo(page); err != nil {
return measurement{}, err
}
raw, err := page.Evaluate(fp)
if err != nil {
return measurement{}, err
}
b, _ := json.Marshal(raw)
var m measurement
return m, json.Unmarshal(b, &m)
}
func run() error {
rnd := make([]byte, 3)
rand.Read(rnd)
runID := hex.EncodeToString(rnd)
pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
return err
}
defer pw.Stop()
all := map[string][]measurement{}
profiles := map[string][]measurement{}
for _, e := range engines {
for i := 0; i < launches; i++ {
m, err := measure(pw, e[0], e[1], "")
if err != nil {
return err
}
all[e[0]] = append(all[e[0]], m)
}
// Two launches with one new profile: what does it keep?
name := "case66-" + e[0] + "-" + runID
err := func() error {
defer api("DELETE", "/v1/me/profiles/"+name)
first, err := measure(pw, e[0], e[1], name)
if err != nil {
return err
}
if err := waitForVersion(name, 1); err != nil {
return err
}
second, err := measure(pw, e[0], e[1], name)
if err != nil {
return err
}
profiles[e[0]] = []measurement{first, second}
return waitForVersion(name, 2)
}()
if err != nil {
return err
}
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(map[string]any{"launches": all, "profile": profiles}); err != nil {
return err
}
fmt.Print(buf.String())
return nil
}
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
What we got
| Browser | Canvas, 3 launches | WebGL renderer · image | Audio | Fonts found (of 30) | With a profile: canvas · renderer |
|---|---|---|---|---|---|
| Google Chrome | same | same · same | same | 6 (same) | kept · kept |
| Microsoft Edge | same | same · same | same | 6 (same) | kept · kept |
| Chromium | same | same · same | same | 6 (same) | kept · kept |
| Brave | 3 different | same · same | 3 different | 1 (same) | new · kept |
| Camoufox | 3 different | 3 different · same | same | 17 / 3 (2 different) | new · kept |
| Firefox | same | same · same | same | 5 (same) | kept · kept |
| WebKit | same | same · same | same | 6 (same) | kept · kept |
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)
{
"launches": {
"chrome": [
{
"canvas": "1f06343f",
"canvas_same_in_session": true,
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"webgl_image": "0eba6e27",
"audio": "b7a0343c",
"audio_same_in_session": true,
"fonts": "069b1d9e",
"font_count": 6,
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/155.0.0.0 Safari/537.36"
},
{
"canvas": "1f06343f",
"canvas_same_in_session": true,
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"webgl_image": "0eba6e27",
"audio": "b7a0343c",
"audio_same_in_session": true,
"fonts": "069b1d9e",
"font_count": 6,
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/155.0.0.0 Safari/537.36"
},
{
"canvas": "1f06343f",
"canvas_same_in_session": true,
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"webgl_image": "0eba6e27",
"audio": "b7a0343c",
"audio_same_in_session": true,
"fonts": "069b1d9e",
"font_count": 6,
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/155.0.0.0 Safari/537.36"
}
],
"edge": [
{
"canvas": "1f06343f",
"canvas_same_in_session": true,
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"webgl_image": "0eba6e27",
"audio": "b7a0343c",
"audio_same_in_session": true,
"fonts": "069b1d9e",
"font_count": 6,
"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"
},
{
"canvas": "1f06343f",
"canvas_same_in_session": true,
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"webgl_image": "0eba6e27",
"audio": "b7a0343c",
"audio_same_in_session": true,
"fonts": "069b1d9e",
"font_count": 6,
"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"
},
{
"canvas": "1f06343f",
"canvas_same_in_session": true,
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"webgl_image": "0eba6e27",
"audio": "b7a0343c",
"audio_same_in_session": true,
"fonts": "069b1d9e",
"font_count": 6,
"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"
}
],
"chromium": [
{
"canvas": "1f06343f",
"canvas_same_in_session": true,
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"webgl_image": "0eba6e27",
"audio": "d432c413",
"audio_same_in_session": true,
"fonts": "069b1d9e",
"font_count": 6,
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/148.0.7778.96 Safari/537.36"
},
{
"canvas": "1f06343f",
"canvas_same_in_session": true,
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"webgl_image": "0eba6e27",
"audio": "d432c413",
"audio_same_in_session": true,
"fonts": "069b1d9e",
"font_count": 6,
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/148.0.7778.96 Safari/537.36"
},
{
"canvas": "1f06343f",
"canvas_same_in_session": true,
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"webgl_image": "0eba6e27",
"audio": "d432c413",
"audio_same_in_session": true,
"fonts": "069b1d9e",
"font_count": 6,
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/148.0.7778.96 Safari/537.36"
}
],
"brave": [
{
"canvas": "75d44b12",
"canvas_same_in_session": true,
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"webgl_image": "0eba6e27",
"audio": "d54a031b",
"audio_same_in_session": true,
"fonts": "4887d8f3",
"font_count": 1,
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/155.0.0.0 Safari/537.36"
},
{
"canvas": "2bac1cf6",
"canvas_same_in_session": true,
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"webgl_image": "0eba6e27",
"audio": "02ad4abc",
"audio_same_in_session": true,
"fonts": "4887d8f3",
"font_count": 1,
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/155.0.0.0 Safari/537.36"
},
{
"canvas": "22ccb850",
"canvas_same_in_session": true,
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"webgl_image": "0eba6e27",
"audio": "9dc0da77",
"audio_same_in_session": true,
"fonts": "4887d8f3",
"font_count": 1,
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/155.0.0.0 Safari/537.36"
}
],
"camoufox": [
{
"canvas": "f704ceae",
"canvas_same_in_session": true,
"webgl_renderer": "ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0), or similar",
"webgl_image": "2818fc2e",
"audio": "6eb5748e",
"audio_same_in_session": true,
"fonts": "2a2dc77e",
"font_count": 17,
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:156.0) Gecko/20100101 Firefox/156.0"
},
{
"canvas": "5a2edf68",
"canvas_same_in_session": true,
"webgl_renderer": "ANGLE (Intel, Intel(R) HD Graphics 400 Direct3D11 vs_5_0 ps_5_0), or similar",
"webgl_image": "2818fc2e",
"audio": "6eb5748e",
"audio_same_in_session": true,
"fonts": "2a2dc77e",
"font_count": 17,
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:156.0) Gecko/20100101 Firefox/156.0"
},
{
"canvas": "446f6d94",
"canvas_same_in_session": true,
"webgl_renderer": "llvmpipe, or similar",
"webgl_image": "2818fc2e",
"audio": "6eb5748e",
"audio_same_in_session": true,
"fonts": "57c9fd7c",
"font_count": 3,
"user_agent": "Mozilla/5.0 (X11; Linux x86_64; rv:156.0) Gecko/20100101 Firefox/156.0"
}
],
"firefox": [
{
"canvas": "2bbe8a63",
"canvas_same_in_session": true,
"webgl_renderer": "llvmpipe, or similar",
"webgl_image": "2818fc2e",
"audio": "6eb5748e",
"audio_same_in_session": true,
"fonts": "9cd21bcd",
"font_count": 5,
"user_agent": "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
},
{
"canvas": "2bbe8a63",
"canvas_same_in_session": true,
"webgl_renderer": "llvmpipe, or similar",
"webgl_image": "2818fc2e",
"audio": "6eb5748e",
"audio_same_in_session": true,
"fonts": "9cd21bcd",
"font_count": 5,
"user_agent": "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
},
{
"canvas": "2bbe8a63",
"canvas_same_in_session": true,
"webgl_renderer": "llvmpipe, or similar",
"webgl_image": "2818fc2e",
"audio": "6eb5748e",
"audio_same_in_session": true,
"fonts": "9cd21bcd",
"font_count": 5,
"user_agent": "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
}
],
"webkit": [
{
"canvas": "a6d70d50",
"canvas_same_in_session": true,
"webgl_renderer": "Apple GPU",
"webgl_image": "311ab43e",
"audio": "07b0aac9",
"audio_same_in_session": true,
"fonts": "069b1d9e",
"font_count": 6,
"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"
},
{
"canvas": "a6d70d50",
"canvas_same_in_session": true,
"webgl_renderer": "Apple GPU",
"webgl_image": "311ab43e",
"audio": "07b0aac9",
"audio_same_in_session": true,
"fonts": "069b1d9e",
"font_count": 6,
"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"
},
{
"canvas": "a6d70d50",
"canvas_same_in_session": true,
"webgl_renderer": "Apple GPU",
"webgl_image": "311ab43e",
"audio": "07b0aac9",
"audio_same_in_session": true,
"fonts": "069b1d9e",
"font_count": 6,
"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"
}
]
},
"profile": {
"chrome": [
{
"canvas": "1f06343f",
"canvas_same_in_session": true,
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"webgl_image": "0eba6e27",
"audio": "b7a0343c",
"audio_same_in_session": true,
"fonts": "069b1d9e",
"font_count": 6,
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/155.0.0.0 Safari/537.36"
},
{
"canvas": "1f06343f",
"canvas_same_in_session": true,
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"webgl_image": "0eba6e27",
"audio": "b7a0343c",
"audio_same_in_session": true,
"fonts": "069b1d9e",
"font_count": 6,
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/155.0.0.0 Safari/537.36"
}
],
"edge": [
{
"canvas": "1f06343f",
"canvas_same_in_session": true,
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"webgl_image": "0eba6e27",
"audio": "b7a0343c",
"audio_same_in_session": true,
"fonts": "069b1d9e",
"font_count": 6,
"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"
},
{
"canvas": "1f06343f",
"canvas_same_in_session": true,
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"webgl_image": "0eba6e27",
"audio": "b7a0343c",
"audio_same_in_session": true,
"fonts": "069b1d9e",
"font_count": 6,
"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"
}
],
"chromium": [
{
"canvas": "1f06343f",
"canvas_same_in_session": true,
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"webgl_image": "0eba6e27",
"audio": "d432c413",
"audio_same_in_session": true,
"fonts": "069b1d9e",
"font_count": 6,
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/148.0.7778.96 Safari/537.36"
},
{
"canvas": "1f06343f",
"canvas_same_in_session": true,
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"webgl_image": "0eba6e27",
"audio": "d432c413",
"audio_same_in_session": true,
"fonts": "069b1d9e",
"font_count": 6,
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/148.0.7778.96 Safari/537.36"
}
],
"brave": [
{
"canvas": "653fa36e",
"canvas_same_in_session": true,
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"webgl_image": "0eba6e27",
"audio": "a188ff79",
"audio_same_in_session": true,
"fonts": "4887d8f3",
"font_count": 1,
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/155.0.0.0 Safari/537.36"
},
{
"canvas": "a63d5c5e",
"canvas_same_in_session": true,
"webgl_renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
"webgl_image": "0eba6e27",
"audio": "a89dabd9",
"audio_same_in_session": true,
"fonts": "4887d8f3",
"font_count": 1,
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/155.0.0.0 Safari/537.36"
}
],
"camoufox": [
{
"canvas": "d145e8ec",
"canvas_same_in_session": true,
"webgl_renderer": "Apple M1, or similar",
"webgl_image": "2818fc2e",
"audio": "6eb5748e",
"audio_same_in_session": true,
"fonts": "6160a704",
"font_count": 17,
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:156.0) Gecko/20100101 Firefox/156.0"
},
{
"canvas": "1033ef85",
"canvas_same_in_session": true,
"webgl_renderer": "Apple M1, or similar",
"webgl_image": "2818fc2e",
"audio": "6eb5748e",
"audio_same_in_session": true,
"fonts": "6160a704",
"font_count": 17,
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:156.0) Gecko/20100101 Firefox/156.0"
}
],
"firefox": [
{
"canvas": "2bbe8a63",
"canvas_same_in_session": true,
"webgl_renderer": "llvmpipe, or similar",
"webgl_image": "2818fc2e",
"audio": "6eb5748e",
"audio_same_in_session": true,
"fonts": "9cd21bcd",
"font_count": 5,
"user_agent": "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
},
{
"canvas": "2bbe8a63",
"canvas_same_in_session": true,
"webgl_renderer": "llvmpipe, or similar",
"webgl_image": "2818fc2e",
"audio": "6eb5748e",
"audio_same_in_session": true,
"fonts": "9cd21bcd",
"font_count": 5,
"user_agent": "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
}
],
"webkit": [
{
"canvas": "a6d70d50",
"canvas_same_in_session": true,
"webgl_renderer": "Apple GPU",
"webgl_image": "311ab43e",
"audio": "07b0aac9",
"audio_same_in_session": true,
"fonts": "069b1d9e",
"font_count": 6,
"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"
},
{
"canvas": "a6d70d50",
"canvas_same_in_session": true,
"webgl_renderer": "Apple GPU",
"webgl_image": "311ab43e",
"audio": "07b0aac9",
"audio_same_in_session": true,
"fonts": "069b1d9e",
"font_count": 6,
"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"
}
]
}
}Takeaways
- Chrome, Edge and Chromium gave the same canvas, WebGL image and font list on every launch, and the same values as each other. They run on the same servers with the same software renderer (SwiftShader), so there is nothing to tell launches apart. The audio hash differs between Chromium and Chrome/Edge (different builds), but stays the same per browser. A profile changes nothing here because nothing varies.
- Firefox and WebKit were just as stable, each with its own values. WebKit reports "Apple GPU" and found the same fonts as the Chromium family.
- Brave changed its canvas and audio hashes on every launch, and a profile didn't keep them. Within one session both stayed the same, so the page sees a consistent browser per visit and a new one on each launch. Brave's WebGL image was not changed (it matched Chrome's), and its font probe found only 1 of the 30 families, where the Chromium family found 6: Brave hides installed fonts from pages.
- Camoufox changed its canvas on every launch, and usually its WebGL renderer too. The renderer comes with the persona Camoufox picks from a limited set of common configurations, so in one of the five runs all three launches happened to get the same GPU. With a profile, the renderer and fonts came back the same (the stored persona), but the canvas still changed, as in case 49.
- Camoufox's rendered WebGL image and audio hash were identical to plain Firefox's on every launch, whatever GPU the renderer string named. A script that compares the image with the claimed GPU can see that "NVIDIA" and "Apple M1" draw exactly like Firefox's software renderer. The audio hash is also stable, so it links Camoufox sessions to each other.
- A profile saved by a newer browser build won't open on an older one. While building this case the fleet was part-way through a Chrome update: a Chrome profile saved on a server with the new build was refused on one with the old build (
409 profile_version_downgrade), and the next attempt landed on an updated server. The programs retry that error up to five times. - For what each signal is and who reads it, see What a page can read about your browser; for headless and CDP tells, case 16 and case 43; for fonts, case 52; for what public bot-test pages conclude, case 63. Measured on 2026-10-22; browser updates can change any of this.