cdpfleet Docs Sign up free

Examples & benchmarks / #9 · 2026-09-30 · Benchmark · Hard · for web scraping

Setting a header the wrong way: header order as a fingerprint

Four ways to change request headers in Playwright, and what each does to the header order a server sees. TLS and HTTP/2 stay put; the order doesn't.

Google Chrome

Run on production on 2026-09-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

You need German content, or a tracing header on every request. Playwright gives you locale, extraHTTPHeaders and route.continue({ headers }). They all "work" — but browsers send headers in a fixed order, and some bot checks compare that order with the browser's real one. Which methods keep Chrome looking like Chrome?

What we used, and why

WhatWhy
chrome, headless: falseReal Chrome on a virtual display: the reference header order.
locale: "de-DE"A context option; sets navigator.language and the Accept-Language header.
extraHTTPHeadersAdds or overrides headers on every request of a context.
page.route + route.continue({ headers })Rewrites each request's headers in flight.
tls.peet.wsEchoes the HTTP/2 HEADERS frame exactly as received — names in order — plus the TLS and Akamai HTTP/2 fingerprints.

How it works

  1. Launch one Chrome session.
  2. For each method, open a fresh context, apply it and load tls.peet.ws.
  3. Compare the header order, the Accept-Language value and the fingerprints.

The code

The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:

// npm install [email protected]
// env: CDPFLEET_API_KEY, PROXY_URL
import { chromium } from 'playwright';

const KEY = process.env.CDPFLEET_API_KEY;

const res = await fetch('https://starter.cdpfleet.com/chrome/session', {
  method: 'POST',
  headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
  body: JSON.stringify({ proxy: process.env.PROXY_URL, headless: false }),
});
if (!res.ok) throw new Error(`launch: ${res.status} ${await res.text()}`);
const { wsUrl } = await res.json();
const browser = await chromium.connect(wsUrl, { headers: { 'x-api-key': KEY } });

// What the server saw: header names in order, and the HTTP/2 fingerprint.
async function observe(label, contextOptions, setup) {
  const context = await browser.newContext(contextOptions);
  const page = await context.newPage();
  if (setup) await setup(page);
  const fp = await (await page.goto('https://tls.peet.ws/api/all', { timeout: 60000 })).json();
  await context.close();
  const headers = fp.http2.sent_frames.find((f) => f.frame_type === 'HEADERS').headers;
  return {
    variant: label,
    header_order: headers.map((h) => h.slice(0, h.indexOf(':', 1))),
    accept_language: headers.find((h) => h.startsWith('accept-language: '))?.slice(17) ?? null,
    akamai_h2: fp.http2.akamai_fingerprint,
    ja4: fp.tls.ja4,
  };
}

try {
  const rows = [
    await observe('default', {}),
    await observe('locale: de-DE', { locale: 'de-DE' }),
    await observe('extraHTTPHeaders', { extraHTTPHeaders: { 'accept-language': 'de-DE', 'x-request-id': 'abc123' } }),
    await observe('route: rewrite headers', {}, (page) => page.route('**/*', (route) => route.continue({
      headers: { ...route.request().headers(), 'x-request-id': 'abc123' },
    }))),
  ];
  console.log(JSON.stringify(rows, null, 2));
} finally {
  await browser.close();
}

What we got

MethodAccept-LanguageHeader order (after the pseudo-headers)JA4
defaulten-US,en;q=0.9pragma, cache-control, sec-ch-ua, sec-ch-ua-mobile, sec-ch-ua-platform, upgrade-insecure-requests, user-agent, accept, sec-fetch-site, sec-fetch-mode, sec-fetch-user, sec-fetch-dest, accept-encoding, accept-language, priorityt13d1517h2_8daaf6152771_cb7bf5808d99
locale: de-DEde-DEpragma, cache-control, sec-ch-ua, sec-ch-ua-mobile, sec-ch-ua-platform, upgrade-insecure-requests, user-agent, accept-language, accept, sec-fetch-site, sec-fetch-mode, sec-fetch-user, sec-fetch-dest, accept-encoding, priorityt13d1517h2_8daaf6152771_cb7bf5808d99
extraHTTPHeadersde-DEpragma, cache-control, sec-ch-ua, sec-ch-ua-mobile, sec-ch-ua-platform, upgrade-insecure-requests, user-agent, accept-language, x-request-id, accept, sec-fetch-site, sec-fetch-mode, sec-fetch-user, sec-fetch-dest, accept-encoding, priorityt13d1517h2_8daaf6152771_cb7bf5808d99
route: rewrite headersen-US,en;q=0.9pragma, cache-control, accept, upgrade-insecure-requests, user-agent, x-request-id, sec-ch-ua, sec-ch-ua-mobile, sec-ch-ua-platform, sec-fetch-site, sec-fetch-mode, sec-fetch-user, sec-fetch-dest, accept-encoding, accept-language, priorityt13d1517h2_8daaf6152771_cb7bf5808d99

From the Node.js run on 2026-09-30. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.

Raw output (Node.js)
[
  {
    "variant": "default",
    "header_order": [
      ":method",
      ":authority",
      ":scheme",
      ":path",
      "pragma",
      "cache-control",
      "sec-ch-ua",
      "sec-ch-ua-mobile",
      "sec-ch-ua-platform",
      "upgrade-insecure-requests",
      "user-agent",
      "accept",
      "sec-fetch-site",
      "sec-fetch-mode",
      "sec-fetch-user",
      "sec-fetch-dest",
      "accept-encoding",
      "accept-language",
      "priority"
    ],
    "accept_language": "en-US,en;q=0.9",
    "akamai_h2": "1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
    "ja4": "t13d1517h2_8daaf6152771_cb7bf5808d99"
  },
  {
    "variant": "locale: de-DE",
    "header_order": [
      ":method",
      ":authority",
      ":scheme",
      ":path",
      "pragma",
      "cache-control",
      "sec-ch-ua",
      "sec-ch-ua-mobile",
      "sec-ch-ua-platform",
      "upgrade-insecure-requests",
      "user-agent",
      "accept-language",
      "accept",
      "sec-fetch-site",
      "sec-fetch-mode",
      "sec-fetch-user",
      "sec-fetch-dest",
      "accept-encoding",
      "priority"
    ],
    "accept_language": "de-DE",
    "akamai_h2": "1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
    "ja4": "t13d1517h2_8daaf6152771_cb7bf5808d99"
  },
  {
    "variant": "extraHTTPHeaders",
    "header_order": [
      ":method",
      ":authority",
      ":scheme",
      ":path",
      "pragma",
      "cache-control",
      "sec-ch-ua",
      "sec-ch-ua-mobile",
      "sec-ch-ua-platform",
      "upgrade-insecure-requests",
      "user-agent",
      "accept-language",
      "x-request-id",
      "accept",
      "sec-fetch-site",
      "sec-fetch-mode",
      "sec-fetch-user",
      "sec-fetch-dest",
      "accept-encoding",
      "priority"
    ],
    "accept_language": "de-DE",
    "akamai_h2": "1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
    "ja4": "t13d1517h2_8daaf6152771_cb7bf5808d99"
  },
  {
    "variant": "route: rewrite headers",
    "header_order": [
      ":method",
      ":authority",
      ":scheme",
      ":path",
      "pragma",
      "cache-control",
      "accept",
      "upgrade-insecure-requests",
      "user-agent",
      "x-request-id",
      "sec-ch-ua",
      "sec-ch-ua-mobile",
      "sec-ch-ua-platform",
      "sec-fetch-site",
      "sec-fetch-mode",
      "sec-fetch-user",
      "sec-fetch-dest",
      "accept-encoding",
      "accept-language",
      "priority"
    ],
    "accept_language": "en-US,en;q=0.9",
    "akamai_h2": "1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p",
    "ja4": "t13d1517h2_8daaf6152771_cb7bf5808d99"
  }
]

Takeaways