cdpfleet Docs GitHub Dashboard

Cases / #21 · 2026-10-05 · Medium

Four ways to make a request, four different visitors: goto, in-page fetch, page.request and your own client

The same URL fetched from the browser, from a script inside the page, from Playwright's request API and from your machine — and what the server saw each time: exit IP, user agent, HTTP version and TLS fingerprint.

Chromium

Run on production on 2026-10-05: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go

The problem

Playwright gives you several ways to fetch a URL once a browser is running, and they look interchangeable: page.goto, a fetch() inside page.evaluate, page.request.get, or plain HTTP from your script with the browser's cookies copied over. They are not interchangeable. Each runs on a different machine or in a different network stack, so the server sees a different IP, a different TLS handshake and sometimes a different HTTP version — and an anti-bot system compares exactly those things with the user agent. Which paths go through your proxy, which look like the browser on the wire, and which only pretend to?

What we used, and why

WhatWhy
chromium, headless: "new"One session; the question is about request paths, not the browser.
https://tls.peet.ws/api/allReports the caller's IP, user agent, HTTP version and TLS fingerprint (JA4) — the facts a server-side bot check keys on.
page.goto and fetch() in page.evaluateBoth run in the browser: its proxy, cookies, headers and TLS stack.
page.request.getPlaywright's HTTP client (Node.js), run by the Playwright server next to the browser; it copies the browser's user agent and cookies.
fetch() in the script itselfYour machine, your runtime's TLS, no proxy — the reference point.
http://ip-api.com/json/<ip>?fields=hostingWhether each exit is a residential ISP (the proxy) or a hosting provider (a server).

How it works

  1. Launch one headless Chromium session through the proxy.
  2. Fetch the fingerprint endpoint four ways: a navigation, a same-origin fetch from inside the page, page.request.get, and a plain fetch from the script.
  3. For each: record exit IP and its type, user agent, HTTP version, JA4, number of TLS extensions and whether the handshake was resumed (pre_shared_key).
  4. Compare the cipher-suite part of JA4 with the navigation's to tell "same TLS stack" from "same connection".

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;
// Reports the caller's IP, user agent, HTTP version and TLS fingerprint (JA4).
const PEET = 'https://tls.peet.ws/api/all';

const res = await fetch('https://starter.cdpfleet.com/chromium/session', {
  method: 'POST',
  headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
  body: JSON.stringify({ proxy: process.env.PROXY_URL, headless: 'new' }),
});
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 } });

async function row(path, seen, runs_on) {
  const ip = seen.ip.split(':')[0];
  // Who owns the exit: a residential ISP (your proxy) or a hosting provider (a server)?
  const who = await (await fetch(`http://ip-api.com/json/${ip}?fields=hosting`)).json();
  return {
    path, runs_on, exit_ip: ip, exit_type: who.hosting ? 'datacenter' : 'residential',
    user_agent: seen.user_agent, http_version: seen.http_version, ja4: seen.tls.ja4,
    tls_extensions: seen.tls.extensions.length,
    resumed_tls: seen.tls.extensions.some((e) => /pre_shared_key/.test(e.name)),
  };
}

try {
  const page = await browser.newPage();
  // 1. A navigation: the browser itself makes the request.
  const nav = await (await page.goto(PEET, { timeout: 60000 })).json();
  // 2. fetch() inside the page (same origin): the browser's network stack, cookies and headers.
  const inPage = await page.evaluate(() => fetch('/api/all').then((r) => r.json()));
  // 3. page.request: Playwright's own HTTP client, run by the Playwright server next to the browser.
  const viaRequest = await (await page.request.get(PEET, { timeout: 60000 })).json();
  // 4. Your own HTTP client on your machine, for reference.
  const local = await (await fetch(PEET)).json();

  const out = [
    await row('page.goto', nav, 'the browser'),
    await row('fetch() in page.evaluate', inPage, 'the browser'),
    await row('page.request.get', viaRequest, 'Playwright server'),
    await row('fetch() in your script', local, 'your machine'),
  ];
  // JA4's middle part hashes the cipher suites: the same TLS stack keeps it across connections.
  for (const r of out) r.same_tls_stack_as_browser = r.ja4.split('_')[1] === out[0].ja4.split('_')[1];
  console.log(JSON.stringify(out, null, 2));
} finally {
  await browser.close();
}

What we got

PathRuns onExitHTTPJA4TLS ext.Resumed TLSBrowser's TLS stackUser agent
page.gotothe browserresidentialh2t13d1516h2_8daaf6152771_d8a2da3f94cd18noyesMozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/148.0.7778.96 Safari/537.36
fetch() in page.evaluatethe browserresidentialh2t13d1517h2_8daaf6152771_b6f405a0062419yesyesMozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/148.0.7778.96 Safari/537.36
page.request.getPlaywright serverresidentialHTTP/1.1t13d5211_b262b3658495_8e6e362c5eac11nonoMozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/148.0.7778.96 Safari/537.36
fetch() in your scriptyour machinedatacenterHTTP/1.1t13d5911h1_a33745022dd6_1f22a2ca17c411nononode

From the Node.js run on 2026-10-05. 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)
[
  {
    "path": "page.goto",
    "runs_on": "the browser",
    "exit_ip": "203.0.113.1",
    "exit_type": "residential",
    "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/148.0.7778.96 Safari/537.36",
    "http_version": "h2",
    "ja4": "t13d1516h2_8daaf6152771_d8a2da3f94cd",
    "tls_extensions": 18,
    "resumed_tls": false,
    "same_tls_stack_as_browser": true
  },
  {
    "path": "fetch() in page.evaluate",
    "runs_on": "the browser",
    "exit_ip": "203.0.113.2",
    "exit_type": "residential",
    "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/148.0.7778.96 Safari/537.36",
    "http_version": "h2",
    "ja4": "t13d1517h2_8daaf6152771_b6f405a00624",
    "tls_extensions": 19,
    "resumed_tls": true,
    "same_tls_stack_as_browser": true
  },
  {
    "path": "page.request.get",
    "runs_on": "Playwright server",
    "exit_ip": "203.0.113.3",
    "exit_type": "residential",
    "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/148.0.7778.96 Safari/537.36",
    "http_version": "HTTP/1.1",
    "ja4": "t13d5211_b262b3658495_8e6e362c5eac",
    "tls_extensions": 11,
    "resumed_tls": false,
    "same_tls_stack_as_browser": false
  },
  {
    "path": "fetch() in your script",
    "runs_on": "your machine",
    "exit_ip": "203.0.113.4",
    "exit_type": "datacenter",
    "user_agent": "node",
    "http_version": "HTTP/1.1",
    "ja4": "t13d5911h1_a33745022dd6_1f22a2ca17c4",
    "tls_extensions": 11,
    "resumed_tls": false,
    "same_tls_stack_as_browser": false
  }
]

Takeaways