[Examples & benchmarks](https://cdpfleet.com/docs/cases) / #9 · 2026-09-30 · Benchmark · Hard · for [web scraping](https://cdpfleet.com/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](https://cdpfleet.com/docs/browsers/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

| What | Why |
| --- | --- |
| `chrome`, `headless: false` | Real Chrome on a virtual display: the reference header order. |
| `locale: "de-DE"` | A context option; sets `navigator.language` and the `Accept-Language` header. |
| `extraHTTPHeaders` | Adds or overrides headers on every request of a context. |
| `page.route` + `route.continue({ headers })` | Rewrites each request's headers in flight. |
| tls.peet.ws | Echoes 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](https://github.com/cdpfleet/cdpfleet/tree/main/cases/09-headers-and-h2), 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 { 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();
}
```

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

import requests
from playwright.sync_api import sync_playwright

KEY = os.environ["CDPFLEET_API_KEY"]

res = requests.post("https://starter.cdpfleet.com/chrome/session", headers={"x-api-key": KEY},
                    json={"proxy": os.environ["PROXY_URL"], "headless": False}, timeout=60)
res.raise_for_status()

def observe(browser, label, context_options, setup=None):
    """What the server saw: header names in order, and the HTTP/2 fingerprint."""
    context = browser.new_context(**context_options)
    page = context.new_page()
    if setup:
        setup(page)
    fp = page.goto("https://tls.peet.ws/api/all", timeout=60000).json()
    context.close()
    headers = next(f for f in fp["http2"]["sent_frames"] if f["frame_type"] == "HEADERS")["headers"]
    return {
        "variant": label,
        "header_order": [h[:h.index(":", 1)] for h in headers],
        "accept_language": next((h[17:] for h in headers if h.startswith("accept-language: ")), None),
        "akamai_h2": fp["http2"]["akamai_fingerprint"],
        "ja4": fp["tls"]["ja4"],
    }

def rewrite(page):
    page.route("**/*", lambda route: route.continue_(headers={**route.request.headers, "x-request-id": "abc123"}))

with sync_playwright() as p:
    browser = p.chromium.connect(res.json()["wsUrl"], headers={"x-api-key": KEY})
    try:
        print(json.dumps([
            observe(browser, "default", {}),
            observe(browser, "locale: de-DE", {"locale": "de-DE"}),
            observe(browser, "extraHTTPHeaders", {"extra_http_headers": {"accept-language": "de-DE", "x-request-id": "abc123"}}),
            observe(browser, "route: rewrite headers", {}, rewrite),
        ], indent=2))
    finally:
        browser.close()
```

```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.util.*;
import java.util.function.Consumer;

public class Main {
  static final String KEY = System.getenv("CDPFLEET_API_KEY");

  // What the server saw: header names in order, and the HTTP/2 fingerprint.
  static JsonObject observe(Browser browser, String label, Browser.NewContextOptions options, Consumer<Page> setup) {
    BrowserContext context = browser.newContext(options);
    Page page = context.newPage();
    if (setup != null) setup.accept(page);
    JsonObject fp = JsonParser.parseString(page.navigate("https://tls.peet.ws/api/all",
        new Page.NavigateOptions().setTimeout(60000)).text()).getAsJsonObject();
    context.close();
    JsonArray order = new JsonArray();
    String acceptLanguage = null;
    for (JsonElement f : fp.getAsJsonObject("http2").getAsJsonArray("sent_frames")) {
      if (!f.getAsJsonObject().get("frame_type").getAsString().equals("HEADERS")) continue;
      for (JsonElement h : f.getAsJsonObject().getAsJsonArray("headers")) {
        String line = h.getAsString();
        order.add(line.substring(0, line.indexOf(':', 1)));
        if (line.startsWith("accept-language: ")) acceptLanguage = line.substring(17);
      }
    }
    JsonObject row = new JsonObject();
    row.addProperty("variant", label);
    row.add("header_order", order);
    row.addProperty("accept_language", acceptLanguage);
    row.add("akamai_h2", fp.getAsJsonObject("http2").get("akamai_fingerprint"));
    row.add("ja4", fp.getAsJsonObject("tls").get("ja4"));
    return row;
  }

  public static void main(String[] args) throws Exception {
    String body = "{\"proxy\": " + new Gson().toJson(System.getenv("PROXY_URL")) + ", \"headless\": false}";
    HttpResponse<String> res = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/chrome/session"))
        .header("x-api-key", KEY).header("content-type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
    if (res.statusCode() != 200) throw new RuntimeException("launch: " + res.statusCode() + " " + res.body());
    String wsUrl = JsonParser.parseString(res.body()).getAsJsonObject().get("wsUrl").getAsString();

    try (Playwright playwright = Playwright.create()) {
      Browser browser = playwright.chromium().connect(wsUrl, new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
      try {
        JsonArray rows = new JsonArray();
        rows.add(observe(browser, "default", new Browser.NewContextOptions(), null));
        rows.add(observe(browser, "locale: de-DE", new Browser.NewContextOptions().setLocale("de-DE"), null));
        rows.add(observe(browser, "extraHTTPHeaders", new Browser.NewContextOptions()
            .setExtraHTTPHeaders(Map.of("accept-language", "de-DE", "x-request-id", "abc123")), null));
        rows.add(observe(browser, "route: rewrite headers", new Browser.NewContextOptions(), page -> page.route("**/*", route -> {
          Map<String, String> headers = new HashMap<>(route.request().headers());
          headers.put("x-request-id", "abc123");
          route.resume(new Route.ResumeOptions().setHeaders(headers));
        })));
        System.out.println(new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create().toJson(rows));
      } finally {
        browser.close();
      }
    }
  }
}
```

```csharp
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
using System.Net.Http.Json;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;

var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
var res = await http.PostAsJsonAsync("https://starter.cdpfleet.com/chrome/session",
    new { proxy = Environment.GetEnvironmentVariable("PROXY_URL"), headless = false });
if (!res.IsSuccessStatusCode) throw new Exception($"launch: {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
var wsUrl = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("wsUrl").GetString()!;

using var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.ConnectAsync(wsUrl, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });

// What the server saw: header names in order, and the HTTP/2 fingerprint.
async Task<JsonObject> Observe(string label, BrowserNewContextOptions options, Func<IPage, Task>? setup = null)
{
    var context = await browser.NewContextAsync(options);
    var page = await context.NewPageAsync();
    if (setup != null) await setup(page);
    var fp = JsonNode.Parse(await (await page.GotoAsync("https://tls.peet.ws/api/all", new() { Timeout = 60000 }))!.TextAsync())!;
    await context.CloseAsync();
    var headers = fp["http2"]!["sent_frames"]!.AsArray().First(f => (string?)f!["frame_type"] == "HEADERS")!["headers"]!.AsArray()
        .Select(h => (string)h!).ToList();
    return new JsonObject
    {
        ["variant"] = label,
        ["header_order"] = new JsonArray(headers.Select(h => (JsonNode?)h[..h.IndexOf(':', 1)]).ToArray()),
        ["accept_language"] = headers.FirstOrDefault(h => h.StartsWith("accept-language: "))?[17..],
        ["akamai_h2"] = fp["http2"]!["akamai_fingerprint"]!.DeepClone(),
        ["ja4"] = fp["tls"]!["ja4"]!.DeepClone(),
    };
}

try
{
    var rows = new JsonArray
    {
        await Observe("default", new()),
        await Observe("locale: de-DE", new() { Locale = "de-DE" }),
        await Observe("extraHTTPHeaders", new() { ExtraHTTPHeaders = new Dictionary<string, string> { ["accept-language"] = "de-DE", ["x-request-id"] = "abc123" } }),
        await Observe("route: rewrite headers", new(), page => page.RouteAsync("**/*", route =>
        {
            var headers = new Dictionary<string, string>(route.Request.Headers) { ["x-request-id"] = "abc123" };
            return route.ContinueAsync(new() { Headers = headers });
        })),
    };
    Console.WriteLine(rows.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
}
finally
{
    await browser.CloseAsync();
}
```

```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"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"strings"

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

var key = os.Getenv("CDPFLEET_API_KEY")

func launch(name string, options map[string]any) (map[string]any, error) {
	body, _ := json.Marshal(options)
	req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+name+"/session", bytes.NewReader(body))
	req.Header.Set("x-api-key", key)
	req.Header.Set("content-type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	if res.StatusCode != http.StatusOK {
		msg, _ := io.ReadAll(res.Body)
		return nil, fmt.Errorf("launch %s: %s %s", name, res.Status, msg)
	}
	var session map[string]any
	return session, json.NewDecoder(res.Body).Decode(&session)
}

type row struct {
	Variant        string   `json:"variant"`
	HeaderOrder    []string `json:"header_order"`
	AcceptLanguage *string  `json:"accept_language"`
	AkamaiH2       string   `json:"akamai_h2"`
	JA4            string   `json:"ja4"`
}

// What the server saw: header names in order, and the HTTP/2 fingerprint.
func observe(browser playwright.Browser, label string, options playwright.BrowserNewContextOptions, setup func(playwright.Page)) row {
	context, err := browser.NewContext(options)
	if err != nil {
		log.Fatal(err)
	}
	page, _ := context.NewPage()
	if setup != nil {
		setup(page)
	}
	res, err := page.Goto("https://tls.peet.ws/api/all", playwright.PageGotoOptions{Timeout: playwright.Float(60000)})
	if err != nil {
		log.Fatal(err)
	}
	var fp struct {
		TLS struct {
			JA4 string `json:"ja4"`
		} `json:"tls"`
		HTTP2 struct {
			AkamaiFingerprint string `json:"akamai_fingerprint"`
			SentFrames        []struct {
				FrameType string   `json:"frame_type"`
				Headers   []string `json:"headers"`
			} `json:"sent_frames"`
		} `json:"http2"`
	}
	res.JSON(&fp)
	context.Close()
	r := row{Variant: label, HeaderOrder: []string{}, AkamaiH2: fp.HTTP2.AkamaiFingerprint, JA4: fp.TLS.JA4}
	for _, f := range fp.HTTP2.SentFrames {
		if f.FrameType != "HEADERS" {
			continue
		}
		for _, h := range f.Headers {
			r.HeaderOrder = append(r.HeaderOrder, h[:strings.Index(h[1:], ":")+1])
			if strings.HasPrefix(h, "accept-language: ") {
				v := h[17:]
				r.AcceptLanguage = &v
			}
		}
	}
	return r
}

func main() {
	session, err := launch("chrome", map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": false})
	if err != nil {
		log.Fatal(err)
	}
	pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
	if err != nil {
		log.Fatal(err)
	}
	defer pw.Stop()
	browser, err := pw.Chromium.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
	if err != nil {
		log.Fatal(err)
	}
	defer browser.Close()

	rows := []row{
		observe(browser, "default", playwright.BrowserNewContextOptions{}, nil),
		observe(browser, "locale: de-DE", playwright.BrowserNewContextOptions{Locale: playwright.String("de-DE")}, nil),
		observe(browser, "extraHTTPHeaders", playwright.BrowserNewContextOptions{
			ExtraHttpHeaders: map[string]string{"accept-language": "de-DE", "x-request-id": "abc123"}}, nil),
		observe(browser, "route: rewrite headers", playwright.BrowserNewContextOptions{}, func(page playwright.Page) {
			page.Route("**/*", func(route playwright.Route) {
				headers := route.Request().Headers()
				headers["x-request-id"] = "abc123"
				route.Continue(playwright.RouteContinueOptions{Headers: headers})
			})
		}),
	}
	out, _ := json.MarshalIndent(rows, "", "  ")
	fmt.Println(string(out))
}
```

## What we got

| Method | Accept-Language | Header order (after the pseudo-headers) | JA4 |
| --- | --- | --- | --- |
| default | en-US,en;q=0.9 | 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 | t13d1517h2\_8daaf6152771\_cb7bf5808d99 |
| locale: de-DE | de-DE | 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 | t13d1517h2\_8daaf6152771\_cb7bf5808d99 |
| extraHTTPHeaders | de-DE | 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 | t13d1517h2\_8daaf6152771\_cb7bf5808d99 |
| route: rewrite headers | en-US,en;q=0.9 | 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 | t13d1517h2\_8daaf6152771\_cb7bf5808d99 |

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

Raw output (Node.js)

```
[
  {
    "variant": "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

-   **TLS (JA4) and the Akamai HTTP/2 fingerprint never change** — they come from the network stack, not from headers.
-   **Chrome's own order puts `accept-language` near the end, after `accept-encoding`.** Both `locale` and `extraHTTPHeaders` move it up, right after `user-agent` — an order real Chrome doesn't send on a navigation.
-   **`extraHTTPHeaders` also sends exactly what you wrote:** `de-DE` instead of Chrome's own `de-DE,de;q=0.9`.
-   **`route.continue({ headers })` rewrites the order entirely:** `accept` jumps up, client hints move after `user-agent`, custom headers land in the middle. It's the most visible of all.
-   **Best:** don't touch headers on navigations if a site checks them. For the language, prefer a launch option the browser applies itself (e.g. Camoufox's `locale`, see the Camoufox persona case) — and check the result against tls.peet.ws the same way as here.

---
Source: https://cdpfleet.com/docs/cases/headers-and-h2 · Markdown: https://cdpfleet.com/docs/cases/headers-and-h2.md · All docs for AI tools: https://cdpfleet.com/llms.txt
