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

# Installed fonts as a fingerprint: what a width probe sees on each Camoufox persona

The classic font probe — 46 families measured against the generic fonts — on Camoufox's Windows, macOS and Linux personas, with extra \`fonts\` and with \`custom\_fonts\_only\`. The list follows the OS and repeats from launch to launch; \`fonts\` adds families to the persona's list (again since 2026-10-30; from 2026-10-22 it replaced the list).

[Camoufox](https://cdpfleet.com/docs/browsers/camoufox)

Run on production on 2026-10-30: ✓ 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

Which fonts a machine has is one of the oldest fingerprinting signals: a script sets a test string in each candidate family and measures it. A Windows user agent with macOS fonts, or a machine with three fonts in total, stands out. Camoufox builds the font list from its OS persona and lets you add to it or replace it. What does a probe actually see, is it the same every launch, and what do `fonts` and `custom_fonts_only` change?

## What we used, and why

| What | Why |
| --- | --- |
| A width probe over 46 families | Each family is set with `monospace`, `serif` and `sans-serif` as fallback; if any measurement differs from the generic family alone, the family counts as installed. This is how common fingerprinting libraries do it. |
| `os: "windows" | "macos" | "linux"` | The persona decides which families the page can see. |
| `fonts: [...]` | Families to add to the persona's list. |
| `custom_fonts_only: true` | Expose only the families in `fonts`. |
| A hash of the detected list | One value per launch that a site could store and compare. |

## How it works

1.  Launch Camoufox six times one after another: Windows twice, macOS, Linux, Windows with three extra fonts, and Windows with a custom list only.
2.  In each, run the width probe over the same 46 candidates and record the detected families, their hash, and whether the three generic families measure the same.

## The code

The same program in five languages (also [on GitHub](https://github.com/cdpfleet/cdpfleet/tree/main/cases/52-fonts-fingerprint), 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:pass@proxy.example.com:8000`

 

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

const KEY = process.env.CDPFLEET_API_KEY;

// The families a font-fingerprinting script typically tries: Windows, macOS, Linux and popular extras.
const CANDIDATES = [
  'Arial', 'Arial Black', 'Calibri', 'Cambria', 'Candara', 'Comic Sans MS', 'Consolas', 'Constantia', 'Corbel', 'Courier New',
  'Georgia', 'Impact', 'Lucida Console', 'Lucida Sans Unicode', 'Microsoft Sans Serif', 'Palatino Linotype', 'Segoe UI', 'Tahoma',
  'Times New Roman', 'Trebuchet MS', 'Verdana', 'American Typewriter', 'Avenir', 'Avenir Next', 'Futura', 'Geneva', 'Gill Sans',
  'Helvetica', 'Helvetica Neue', 'Lucida Grande', 'Menlo', 'Monaco', 'Optima', 'SF Pro', 'DejaVu Sans', 'DejaVu Serif',
  'Liberation Sans', 'Liberation Mono', 'Noto Sans', 'Ubuntu', 'Cantarell', 'Roboto', 'Open Sans', 'Fira Code', 'JetBrains Mono',
  'Source Code Pro',
];

// The classic width probe: a family is "installed" if text set in it, with a generic fallback,
// measures differently from the generic family alone.
const PROBE = `(fonts) => {
  const span = document.createElement('span');
  span.textContent = 'mmmmmmmmmmlli10OWQ@#';
  span.style.cssText = 'position:absolute;left:-9999px;font-size:72px';
  document.body.appendChild(span);
  const size = (family) => { span.style.fontFamily = family; return span.offsetWidth + 'x' + span.offsetHeight; };
  const generics = ['monospace', 'serif', 'sans-serif'];
  const base = {};
  for (const g of generics) base[g] = size(g);
  const found = fonts.filter((f) => generics.some((g) => size('"' + f + '", ' + g) !== base[g]));
  span.remove();
  return { platform: navigator.platform, found, generic_sizes: generics.map((g) => base[g]) };
}`;

const SETUPS = [
  { setup: 'os: windows (launch 1)', body: { os: 'windows' } },
  { setup: 'os: windows (launch 2)', body: { os: 'windows' } },
  { setup: 'os: macos', body: { os: 'macos' } },
  { setup: 'os: linux', body: { os: 'linux' } },
  { setup: 'windows + fonts', body: { os: 'windows', fonts: ['Menlo', 'Helvetica Neue', 'Noto Sans'] } },
  { setup: 'windows + fonts + custom_fonts_only', body: { os: 'windows', fonts: ['Calibri', 'Consolas', 'Segoe UI', 'Noto Sans'], custom_fonts_only: true } },
];

// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async function launch(body) {
  for (let attempt = 1; ; attempt++) {
    const res = await fetch('https://starter.cdpfleet.com/camoufox/session', {
      method: 'POST',
      headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
      body: JSON.stringify(body),
    });
    if (res.ok) return res.json();
    if (res.status !== 503 || 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));
  }
}

// Proxies drop a tunnel now and then: up to two retries.
async function go(page, url) {
  for (let attempt = 1; ; attempt++) {
    try {
      return await page.goto(url, { timeout: 60000 });
    } catch (err) {
      if (attempt === 3) throw err;
      await new Promise((r) => setTimeout(r, 2000));
    }
  }
}

const results = [];
for (const s of SETUPS) {
  const session = await launch({ ...s.body, proxy: process.env.PROXY_URL, headless: true, record: false });
  const browser = await firefox.connect(session.wsUrl, { headers: { 'x-api-key': KEY } });
  try {
    const page = await (await browser.newContext()).newPage();
    await go(page, 'https://httpbin.org/html');
    const r = await page.evaluate(`(${PROBE})(${JSON.stringify(CANDIDATES)})`);
    results.push({
      setup: s.setup,
      platform: r.platform,
      detected: r.found.length,
      font_hash: createHash('sha256').update(r.found.join(',')).digest('hex').slice(0, 10),
      generics_identical: new Set(r.generic_sizes).size === 1,
      fonts: r.found,
    });
  } finally {
    await browser.close();
  }
}
console.log(JSON.stringify(results, null, 2));
```

```python
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import hashlib
import json
import os
import time

import requests
from playwright.sync_api import sync_playwright

KEY = os.environ["CDPFLEET_API_KEY"]

# The families a font-fingerprinting script typically tries: Windows, macOS, Linux and popular extras.
CANDIDATES = [
    "Arial", "Arial Black", "Calibri", "Cambria", "Candara", "Comic Sans MS", "Consolas", "Constantia", "Corbel", "Courier New",
    "Georgia", "Impact", "Lucida Console", "Lucida Sans Unicode", "Microsoft Sans Serif", "Palatino Linotype", "Segoe UI", "Tahoma",
    "Times New Roman", "Trebuchet MS", "Verdana", "American Typewriter", "Avenir", "Avenir Next", "Futura", "Geneva", "Gill Sans",
    "Helvetica", "Helvetica Neue", "Lucida Grande", "Menlo", "Monaco", "Optima", "SF Pro", "DejaVu Sans", "DejaVu Serif",
    "Liberation Sans", "Liberation Mono", "Noto Sans", "Ubuntu", "Cantarell", "Roboto", "Open Sans", "Fira Code", "JetBrains Mono",
    "Source Code Pro",
]

# The classic width probe: a family is "installed" if text set in it, with a generic fallback,
# measures differently from the generic family alone.
PROBE = """(fonts) => {
  const span = document.createElement('span');
  span.textContent = 'mmmmmmmmmmlli10OWQ@#';
  span.style.cssText = 'position:absolute;left:-9999px;font-size:72px';
  document.body.appendChild(span);
  const size = (family) => { span.style.fontFamily = family; return span.offsetWidth + 'x' + span.offsetHeight; };
  const generics = ['monospace', 'serif', 'sans-serif'];
  const base = {};
  for (const g of generics) base[g] = size(g);
  const found = fonts.filter((f) => generics.some((g) => size('"' + f + '", ' + g) !== base[g]));
  span.remove();
  return { platform: navigator.platform, found, generic_sizes: generics.map((g) => base[g]) };
}"""

SETUPS = [
    ("os: windows (launch 1)", {"os": "windows"}),
    ("os: windows (launch 2)", {"os": "windows"}),
    ("os: macos", {"os": "macos"}),
    ("os: linux", {"os": "linux"}),
    ("windows + fonts", {"os": "windows", "fonts": ["Menlo", "Helvetica Neue", "Noto Sans"]}),
    ("windows + fonts + custom_fonts_only", {"os": "windows", "fonts": ["Calibri", "Consolas", "Segoe UI", "Noto Sans"], "custom_fonts_only": True}),
]

# 503 means the fleet is momentarily busy: wait Retry-After and try again.
def launch(body):
    for attempt in range(1, 6):
        r = requests.post("https://starter.cdpfleet.com/camoufox/session", headers={"x-api-key": KEY}, json=body, timeout=120)
        if r.ok:
            return r.json()
        if r.status_code != 503 or attempt == 5:
            raise RuntimeError(f"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, url):
    for attempt in range(1, 4):
        try:
            return page.goto(url, timeout=60000)
        except Exception:
            if attempt == 3:
                raise
            time.sleep(2)

results = []
with sync_playwright() as p:
    for name, body in SETUPS:
        session = launch({**body, "proxy": os.environ["PROXY_URL"], "headless": True, "record": False})
        browser = p.firefox.connect(session["wsUrl"], headers={"x-api-key": KEY})
        try:
            page = browser.new_context().new_page()
            go(page, "https://httpbin.org/html")
            r = page.evaluate(PROBE, CANDIDATES)
            results.append({
                "setup": name,
                "platform": r["platform"],
                "detected": len(r["found"]),
                "font_hash": hashlib.sha256(",".join(r["found"]).encode()).hexdigest()[:10],
                "generics_identical": len(set(r["generic_sizes"])) == 1,
                "fonts": r["found"],
            })
        finally:
            browser.close()
print(json.dumps(results, 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, PROXY_URL
import com.google.gson.*;
import com.microsoft.playwright.*;
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
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();

  // The families a font-fingerprinting script typically tries: Windows, macOS, Linux and popular extras.
  static final List<String> CANDIDATES = List.of(
      "Arial", "Arial Black", "Calibri", "Cambria", "Candara", "Comic Sans MS", "Consolas", "Constantia", "Corbel", "Courier New",
      "Georgia", "Impact", "Lucida Console", "Lucida Sans Unicode", "Microsoft Sans Serif", "Palatino Linotype", "Segoe UI", "Tahoma",
      "Times New Roman", "Trebuchet MS", "Verdana", "American Typewriter", "Avenir", "Avenir Next", "Futura", "Geneva", "Gill Sans",
      "Helvetica", "Helvetica Neue", "Lucida Grande", "Menlo", "Monaco", "Optima", "SF Pro", "DejaVu Sans", "DejaVu Serif",
      "Liberation Sans", "Liberation Mono", "Noto Sans", "Ubuntu", "Cantarell", "Roboto", "Open Sans", "Fira Code", "JetBrains Mono",
      "Source Code Pro");

  // The classic width probe: a family is "installed" if text set in it, with a generic fallback,
  // measures differently from the generic family alone.
  static final String PROBE = """
      (fonts) => {
        const span = document.createElement('span');
        span.textContent = 'mmmmmmmmmmlli10OWQ@#';
        span.style.cssText = 'position:absolute;left:-9999px;font-size:72px';
        document.body.appendChild(span);
        const size = (family) => { span.style.fontFamily = family; return span.offsetWidth + 'x' + span.offsetHeight; };
        const generics = ['monospace', 'serif', 'sans-serif'];
        const base = {};
        for (const g of generics) base[g] = size(g);
        const found = fonts.filter((f) => generics.some((g) => size('"' + f + '", ' + g) !== base[g]));
        span.remove();
        return { platform: navigator.platform, found, generic_sizes: generics.map((g) => base[g]) };
      }""";

  static final String[][] SETUPS = {
      {"os: windows (launch 1)", "{\"os\":\"windows\"}"},
      {"os: windows (launch 2)", "{\"os\":\"windows\"}"},
      {"os: macos", "{\"os\":\"macos\"}"},
      {"os: linux", "{\"os\":\"linux\"}"},
      {"windows + fonts", "{\"os\":\"windows\",\"fonts\":[\"Menlo\",\"Helvetica Neue\",\"Noto Sans\"]}"},
      {"windows + fonts + custom_fonts_only", "{\"os\":\"windows\",\"fonts\":[\"Calibri\",\"Consolas\",\"Segoe UI\",\"Noto Sans\"],\"custom_fonts_only\":true}"},
  };

  // 503 means the fleet is momentarily busy: wait Retry-After and try again.
  static JsonObject launch(JsonObject body) throws Exception {
    for (int attempt = 1; ; attempt++) {
      HttpResponse<String> res = HTTP.send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/camoufox/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();
      if (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);
    }
  }

  // Proxies drop a tunnel now and then: up to two retries.
  static void go(Page page, String url) throws Exception {
    for (int attempt = 1; ; attempt++) {
      try {
        page.navigate(url, new Page.NavigateOptions().setTimeout(60000));
        return;
      } catch (PlaywrightException e) {
        if (attempt == 3) throw e;
        Thread.sleep(2000);
      }
    }
  }

  public static void main(String[] args) throws Exception {
    JsonArray results = new JsonArray();
    try (Playwright pw = Playwright.create()) {
      for (String[] s : SETUPS) {
        JsonObject body = JsonParser.parseString(s[1]).getAsJsonObject();
        body.addProperty("proxy", System.getenv("PROXY_URL"));
        body.addProperty("headless", true);
        body.addProperty("record", false);
        JsonObject session = launch(body);
        Browser browser = pw.firefox().connect(session.get("wsUrl").getAsString(), new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
        try {
          Page page = browser.newContext().newPage();
          go(page, "https://httpbin.org/html");
          JsonObject r = GSON.toJsonTree(page.evaluate(PROBE, CANDIDATES)).getAsJsonObject();
          List<String> found = new ArrayList<>();
          r.getAsJsonArray("found").forEach((f) -> found.add(f.getAsString()));
          Set<String> sizes = new HashSet<>();
          r.getAsJsonArray("generic_sizes").forEach((g) -> sizes.add(g.getAsString()));
          JsonObject out = new JsonObject();
          out.addProperty("setup", s[0]);
          out.addProperty("platform", r.get("platform").getAsString());
          out.addProperty("detected", found.size());
          out.addProperty("font_hash", HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(String.join(",", found).getBytes(StandardCharsets.UTF_8))).substring(0, 10));
          out.addProperty("generics_identical", sizes.size() == 1);
          JsonArray fonts = new JsonArray();
          found.forEach(fonts::add);
          out.add("fonts", fonts);
          results.add(out);
        } finally {
          browser.close();
        }
      }
    }
    System.out.println(GSON.toJson(results));
  }
}
```

```csharp
// 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")!;

// The families a font-fingerprinting script typically tries: Windows, macOS, Linux and popular extras.
string[] candidates =
[
    "Arial", "Arial Black", "Calibri", "Cambria", "Candara", "Comic Sans MS", "Consolas", "Constantia", "Corbel", "Courier New",
    "Georgia", "Impact", "Lucida Console", "Lucida Sans Unicode", "Microsoft Sans Serif", "Palatino Linotype", "Segoe UI", "Tahoma",
    "Times New Roman", "Trebuchet MS", "Verdana", "American Typewriter", "Avenir", "Avenir Next", "Futura", "Geneva", "Gill Sans",
    "Helvetica", "Helvetica Neue", "Lucida Grande", "Menlo", "Monaco", "Optima", "SF Pro", "DejaVu Sans", "DejaVu Serif",
    "Liberation Sans", "Liberation Mono", "Noto Sans", "Ubuntu", "Cantarell", "Roboto", "Open Sans", "Fira Code", "JetBrains Mono",
    "Source Code Pro",
];

// The classic width probe: a family is "installed" if text set in it, with a generic fallback,
// measures differently from the generic family alone.
const string Probe = @"(fonts) => {
  const span = document.createElement('span');
  span.textContent = 'mmmmmmmmmmlli10OWQ@#';
  span.style.cssText = 'position:absolute;left:-9999px;font-size:72px';
  document.body.appendChild(span);
  const size = (family) => { span.style.fontFamily = family; return span.offsetWidth + 'x' + span.offsetHeight; };
  const generics = ['monospace', 'serif', 'sans-serif'];
  const base = {};
  for (const g of generics) base[g] = size(g);
  const found = fonts.filter((f) => generics.some((g) => size('""' + f + '"", ' + g) !== base[g]));
  span.remove();
  return { platform: navigator.platform, found, generic_sizes: generics.map((g) => base[g]) };
}";

var setups = new (string Name, JsonObject Body)[]
{
    ("os: windows (launch 1)", new JsonObject { ["os"] = "windows" }),
    ("os: windows (launch 2)", new JsonObject { ["os"] = "windows" }),
    ("os: macos", new JsonObject { ["os"] = "macos" }),
    ("os: linux", new JsonObject { ["os"] = "linux" }),
    ("windows + fonts", new JsonObject { ["os"] = "windows", ["fonts"] = new JsonArray("Menlo", "Helvetica Neue", "Noto Sans") }),
    ("windows + fonts + custom_fonts_only", new JsonObject { ["os"] = "windows", ["fonts"] = new JsonArray("Calibri", "Consolas", "Segoe UI", "Noto Sans"), ["custom_fonts_only"] = true }),
};

using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();

// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async Task<JsonElement> Launch(JsonObject body)
{
    for (var attempt = 1; ; attempt++)
    {
        var res = await http.PostAsync("https://starter.cdpfleet.com/camoufox/session", new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"));
        if (res.IsSuccessStatusCode) return await res.Content.ReadFromJsonAsync<JsonElement>();
        if ((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?.TotalMilliseconds ?? 5000));
    }
}

// Proxies drop a tunnel now and then: up to two retries.
async Task Go(IPage page, string url)
{
    for (var attempt = 1; ; attempt++)
    {
        try { await page.GotoAsync(url, new() { Timeout = 60000 }); return; }
        catch (PlaywrightException) when (attempt < 3) { await Task.Delay(2000); }
    }
}

var results = new JsonArray();
foreach (var (name, setupBody) in setups)
{
    var body = (JsonObject)setupBody.DeepClone();
    body["proxy"] = Environment.GetEnvironmentVariable("PROXY_URL");
    body["headless"] = true;
    body["record"] = false;
    var session = await Launch(body);
    var browser = await playwright.Firefox.ConnectAsync(session.GetProperty("wsUrl").GetString()!, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
    try
    {
        var page = await (await browser.NewContextAsync()).NewPageAsync();
        await Go(page, "https://httpbin.org/html");
        var r = await page.EvaluateAsync<JsonElement>(Probe, candidates);
        var found = r.GetProperty("found").EnumerateArray().Select((f) => f.GetString()!).ToList();
        results.Add(new JsonObject
        {
            ["setup"] = name,
            ["platform"] = r.GetProperty("platform").GetString(),
            ["detected"] = found.Count,
            ["font_hash"] = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join(",", found)))).ToLower()[..10],
            ["generics_identical"] = r.GetProperty("generic_sizes").EnumerateArray().Select((g) => g.GetString()).Distinct().Count() == 1,
            ["fonts"] = new JsonArray(found.Select((f) => (JsonNode)JsonValue.Create(f)!).ToArray()),
        });
    }
    finally
    {
        await browser.CloseAsync();
    }
}
Console.WriteLine(results.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, PROXY_URL
package main

import (
	"bytes"
	"crypto/sha256"
	"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")

// The families a font-fingerprinting script typically tries: Windows, macOS, Linux and popular extras.
var candidates = []string{
	"Arial", "Arial Black", "Calibri", "Cambria", "Candara", "Comic Sans MS", "Consolas", "Constantia", "Corbel", "Courier New",
	"Georgia", "Impact", "Lucida Console", "Lucida Sans Unicode", "Microsoft Sans Serif", "Palatino Linotype", "Segoe UI", "Tahoma",
	"Times New Roman", "Trebuchet MS", "Verdana", "American Typewriter", "Avenir", "Avenir Next", "Futura", "Geneva", "Gill Sans",
	"Helvetica", "Helvetica Neue", "Lucida Grande", "Menlo", "Monaco", "Optima", "SF Pro", "DejaVu Sans", "DejaVu Serif",
	"Liberation Sans", "Liberation Mono", "Noto Sans", "Ubuntu", "Cantarell", "Roboto", "Open Sans", "Fira Code", "JetBrains Mono",
	"Source Code Pro",
}

// The classic width probe: a family is "installed" if text set in it, with a generic fallback,
// measures differently from the generic family alone.
const probe = `(fonts) => {
  const span = document.createElement('span');
  span.textContent = 'mmmmmmmmmmlli10OWQ@#';
  span.style.cssText = 'position:absolute;left:-9999px;font-size:72px';
  document.body.appendChild(span);
  const size = (family) => { span.style.fontFamily = family; return span.offsetWidth + 'x' + span.offsetHeight; };
  const generics = ['monospace', 'serif', 'sans-serif'];
  const base = {};
  for (const g of generics) base[g] = size(g);
  const found = fonts.filter((f) => generics.some((g) => size('"' + f + '", ' + g) !== base[g]));
  span.remove();
  return { platform: navigator.platform, found, generic_sizes: generics.map((g) => base[g]) };
}`

type setup struct {
	Name string
	Body map[string]any
}

var setups = []setup{
	{"os: windows (launch 1)", map[string]any{"os": "windows"}},
	{"os: windows (launch 2)", map[string]any{"os": "windows"}},
	{"os: macos", map[string]any{"os": "macos"}},
	{"os: linux", map[string]any{"os": "linux"}},
	{"windows + fonts", map[string]any{"os": "windows", "fonts": []string{"Menlo", "Helvetica Neue", "Noto Sans"}}},
	{"windows + fonts + custom_fonts_only", map[string]any{"os": "windows", "fonts": []string{"Calibri", "Consolas", "Segoe UI", "Noto Sans"}, "custom_fonts_only": true}},
}

type result struct {
	Setup             string   `json:"setup"`
	Platform          string   `json:"platform"`
	Detected          int      `json:"detected"`
	FontHash          string   `json:"font_hash"`
	GenericsIdentical bool     `json:"generics_identical"`
	Fonts             []string `json:"fonts"`
}

// 503 means the fleet is momentarily busy: wait Retry-After and try again.
func launch(opts map[string]any) (map[string]any, error) {
	body, _ := json.Marshal(opts)
	for attempt := 1; ; attempt++ {
		req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/camoufox/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)
		}
		if res.StatusCode != 503 || attempt == 5 {
			return nil, fmt.Errorf("launch %d %s", 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, url string) error {
	for attempt := 1; ; attempt++ {
		_, err := page.Goto(url, playwright.PageGotoOptions{Timeout: playwright.Float(60000)})
		if err == nil || attempt == 3 {
			return err
		}
		time.Sleep(2 * time.Second)
	}
}

func one(pw *playwright.Playwright, s setup) (result, error) {
	out := result{Setup: s.Name, Fonts: []string{}}
	opts := map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": true, "record": false}
	for k, v := range s.Body {
		opts[k] = v
	}
	session, err := launch(opts)
	if err != nil {
		return out, err
	}
	browser, err := pw.Firefox.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
	if err != nil {
		return out, err
	}
	defer browser.Close()
	context, err := browser.NewContext()
	if err != nil {
		return out, err
	}
	page, err := context.NewPage()
	if err != nil {
		return out, err
	}
	if err := goTo(page, "https://httpbin.org/html"); err != nil {
		return out, err
	}
	raw, err := page.Evaluate(probe, candidates)
	if err != nil {
		return out, err
	}
	r := raw.(map[string]any)
	out.Platform = r["platform"].(string)
	for _, f := range r["found"].([]any) {
		out.Fonts = append(out.Fonts, f.(string))
	}
	out.Detected = len(out.Fonts)
	h := sha256.Sum256([]byte(strings.Join(out.Fonts, ",")))
	out.FontHash = hex.EncodeToString(h[:])[:10]
	sizes := map[string]bool{}
	for _, v := range r["generic_sizes"].([]any) {
		sizes[v.(string)] = true
	}
	out.GenericsIdentical = len(sizes) == 1
	return out, nil
}

func run() error {
	pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
	if err != nil {
		return err
	}
	defer pw.Stop()
	results := []result{}
	for _, s := range setups {
		r, err := one(pw, s)
		if err != nil {
			return fmt.Errorf("%s: %w", s.Name, err)
		}
		results = append(results, r)
	}
	b, _ := json.MarshalIndent(results, "", "  ")
	fmt.Println(string(b))
	return nil
}

func main() {
	if err := run(); err != nil {
		log.Fatal(err)
	}
}
```

## What we got

| Setup | navigator.platform | Detected (of 46) | List hash | Generics identical | Families found |
| --- | --- | --- | --- | --- | --- |
| os: windows (launch 1) | Win32 | 22 | 6564fcf93f | no | Arial, Arial Black, Calibri, Cambria, Candara, Comic Sans MS, Consolas, Constantia, Corbel, Courier New, Georgia, Impact, Lucida Console, Lucida Sans Unicode, Microsoft Sans Serif, Palatino Linotype, Segoe UI, Tahoma, Times New Roman, Trebuchet MS, Verdana, Helvetica |
| os: windows (launch 2) | Win32 | 22 | 6564fcf93f | no | Arial, Arial Black, Calibri, Cambria, Candara, Comic Sans MS, Consolas, Constantia, Corbel, Courier New, Georgia, Impact, Lucida Console, Lucida Sans Unicode, Microsoft Sans Serif, Palatino Linotype, Segoe UI, Tahoma, Times New Roman, Trebuchet MS, Verdana, Helvetica |
| os: macos | MacIntel | 23 | 23e513dd7a | no | Arial, Arial Black, Comic Sans MS, Courier New, Georgia, Impact, Microsoft Sans Serif, Tahoma, Times New Roman, Trebuchet MS, Verdana, American Typewriter, Avenir, Avenir Next, Futura, Geneva, Gill Sans, Helvetica, Helvetica Neue, Lucida Grande, Menlo, Monaco, Optima |
| os: linux | Linux x86\_64 | 13 | f80842cabf | no | Arial, Calibri, Cambria, Courier New, Palatino Linotype, Times New Roman, Helvetica, DejaVu Sans, DejaVu Serif, Liberation Sans, Liberation Mono, Noto Sans, Ubuntu |
| windows + fonts | Win32 | 25 | 21080195c2 | no | Arial, Arial Black, Calibri, Cambria, Candara, Comic Sans MS, Consolas, Constantia, Corbel, Courier New, Georgia, Impact, Lucida Console, Lucida Sans Unicode, Microsoft Sans Serif, Palatino Linotype, Segoe UI, Tahoma, Times New Roman, Trebuchet MS, Verdana, Helvetica, Helvetica Neue, Menlo, Noto Sans |
| windows + fonts + custom\_fonts\_only | Win32 | 3 | 4b85191c2b | yes | Calibri, Segoe UI, Noto Sans |

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

```
[
  {
    "setup": "os: windows (launch 1)",
    "platform": "Win32",
    "detected": 22,
    "font_hash": "6564fcf93f",
    "generics_identical": false,
    "fonts": [
      "Arial",
      "Arial Black",
      "Calibri",
      "Cambria",
      "Candara",
      "Comic Sans MS",
      "Consolas",
      "Constantia",
      "Corbel",
      "Courier New",
      "Georgia",
      "Impact",
      "Lucida Console",
      "Lucida Sans Unicode",
      "Microsoft Sans Serif",
      "Palatino Linotype",
      "Segoe UI",
      "Tahoma",
      "Times New Roman",
      "Trebuchet MS",
      "Verdana",
      "Helvetica"
    ]
  },
  {
    "setup": "os: windows (launch 2)",
    "platform": "Win32",
    "detected": 22,
    "font_hash": "6564fcf93f",
    "generics_identical": false,
    "fonts": [
      "Arial",
      "Arial Black",
      "Calibri",
      "Cambria",
      "Candara",
      "Comic Sans MS",
      "Consolas",
      "Constantia",
      "Corbel",
      "Courier New",
      "Georgia",
      "Impact",
      "Lucida Console",
      "Lucida Sans Unicode",
      "Microsoft Sans Serif",
      "Palatino Linotype",
      "Segoe UI",
      "Tahoma",
      "Times New Roman",
      "Trebuchet MS",
      "Verdana",
      "Helvetica"
    ]
  },
  {
    "setup": "os: macos",
    "platform": "MacIntel",
    "detected": 23,
    "font_hash": "23e513dd7a",
    "generics_identical": false,
    "fonts": [
      "Arial",
      "Arial Black",
      "Comic Sans MS",
      "Courier New",
      "Georgia",
      "Impact",
      "Microsoft Sans Serif",
      "Tahoma",
      "Times New Roman",
      "Trebuchet MS",
      "Verdana",
      "American Typewriter",
      "Avenir",
      "Avenir Next",
      "Futura",
      "Geneva",
      "Gill Sans",
      "Helvetica",
      "Helvetica Neue",
      "Lucida Grande",
      "Menlo",
      "Monaco",
      "Optima"
    ]
  },
  {
    "setup": "os: linux",
    "platform": "Linux x86_64",
    "detected": 13,
    "font_hash": "f80842cabf",
    "generics_identical": false,
    "fonts": [
      "Arial",
      "Calibri",
      "Cambria",
      "Courier New",
      "Palatino Linotype",
      "Times New Roman",
      "Helvetica",
      "DejaVu Sans",
      "DejaVu Serif",
      "Liberation Sans",
      "Liberation Mono",
      "Noto Sans",
      "Ubuntu"
    ]
  },
  {
    "setup": "windows + fonts",
    "platform": "Win32",
    "detected": 25,
    "font_hash": "21080195c2",
    "generics_identical": false,
    "fonts": [
      "Arial",
      "Arial Black",
      "Calibri",
      "Cambria",
      "Candara",
      "Comic Sans MS",
      "Consolas",
      "Constantia",
      "Corbel",
      "Courier New",
      "Georgia",
      "Impact",
      "Lucida Console",
      "Lucida Sans Unicode",
      "Microsoft Sans Serif",
      "Palatino Linotype",
      "Segoe UI",
      "Tahoma",
      "Times New Roman",
      "Trebuchet MS",
      "Verdana",
      "Helvetica",
      "Helvetica Neue",
      "Menlo",
      "Noto Sans"
    ]
  },
  {
    "setup": "windows + fonts + custom_fonts_only",
    "platform": "Win32",
    "detected": 3,
    "font_hash": "4b85191c2b",
    "generics_identical": true,
    "fonts": [
      "Calibri",
      "Segoe UI",
      "Noto Sans"
    ]
  }
]
```

## Takeaways

-   **Each OS shows its own families, and the list repeats from launch to launch:** Windows had Segoe UI, Calibri and Consolas (22 found); macOS had Helvetica Neue, Menlo, Monaco and Avenir (23); Linux now shows a full Linux set, with DejaVu, Liberation, Noto Sans and Ubuntu (13–14). Measured 2026-10-23 on the fleet's Camoufox 156 with the official Camoufox library (from 2026-10-22); the earlier build showed only three Linux families. Unlike the canvas, which Camoufox re-seeds every launch ([case 49](https://cdpfleet.com/docs/cases/camoufox-same-device)), the font list is a trait of the persona.
-   **On the plain personas, `serif`, `sans-serif` and `monospace` now render in different faces**, as on a normal desktop. The earlier build drew all three in one face, which a script comparing them could see.
-   **`fonts` adds to the persona's list.** With `fonts: ["Menlo", "Helvetica Neue", "Noto Sans"]` on the Windows persona, the probe found the 22 Windows families plus all three (25), and the generic families kept their different faces. Measured 2026-10-30; between 2026-10-22 and 2026-10-29 the fleet's Camoufox replaced the list instead (only Menlo and Helvetica Neue showed), until the node fix restored the documented behaviour.
-   **`custom_fonts_only` exposes your listed families**, minus the one the generic families fall back to: of Calibri, Consolas, Segoe UI and Noto Sans, the probe found Calibri, Segoe UI and Noto Sans, but not Consolas.
-   **What to take from it:** keep the persona's own list and add only the families a site needs with `fonts`; use `custom_fonts_only` when you want to control the whole list.

---
Source: https://cdpfleet.com/docs/cases/fonts-fingerprint · Markdown: https://cdpfleet.com/docs/cases/fonts-fingerprint.md · All docs for AI tools: https://cdpfleet.com/llms.txt
