Cases / #2 · 2026-09-30 · Medium
Emulating an iPhone on real Chrome: what changes, and what gives you away
Playwright device descriptors fix the viewport, touch and user agent — and leave five tells a site can read.
Run on production on 2026-09-30: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
You need the mobile version of a site, so you open a context with Playwright's iPhone 15 Pro or Pixel 7 descriptor. The page renders the mobile layout. But is the browser now convincing as a phone? A bot check can look at the network (TLS, client hints) and at JavaScript APIs the descriptor doesn't touch. Let's compare a desktop context, an iPhone context and a Pixel context in the same Chrome session.
What we used, and why
| What | Why |
|---|---|
chrome | Real Google Chrome, the most common desktop browser. One session serves all three contexts. |
devices['iPhone 15 Pro'], devices['Pixel 7'] | Playwright's descriptors: user agent, viewport, screen, device scale factor, isMobile, hasTouch. (Playwright for Java has no registry, so the Java version spells the same values out.) |
| tls.peet.ws | Shows what the network sees: the user agent, client-hint headers and TLS fingerprint. |
page.evaluate | Reads what page scripts see: viewport, touch points, pointer type, navigator.platform, navigator.userAgentData. |
How it works
- Launch one Chrome session.
- Open three contexts: plain, iPhone 15 Pro, Pixel 7.
- In each, load tls.peet.ws and read the JavaScript-visible device signals.
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 { chromium, devices } 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: true }),
});
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 a page can see about the device, plus what the network sees (tls.peet.ws).
async function inspect(context) {
const page = await context.newPage();
const fp = await (await page.goto('https://tls.peet.ws/api/all', { timeout: 60000 })).json();
const js = await page.evaluate(() => ({
viewport: `${innerWidth}x${innerHeight}`,
screen: `${screen.width}x${screen.height}`,
device_pixel_ratio: devicePixelRatio,
max_touch_points: navigator.maxTouchPoints,
coarse_pointer: matchMedia('(pointer: coarse)').matches,
platform: navigator.platform,
ua_data_mobile: navigator.userAgentData?.mobile ?? null,
ua_data_platform: navigator.userAgentData?.platform ?? null,
}));
const headers = fp.http2.sent_frames.find((f) => f.frame_type === 'HEADERS').headers;
const header = (name) => headers.find((h) => h.startsWith(`${name}: `))?.slice(name.length + 2) ?? null;
await page.close();
return {
user_agent: fp.user_agent,
...js,
sec_ch_ua_mobile: header('sec-ch-ua-mobile'),
sec_ch_ua_platform: header('sec-ch-ua-platform'),
ja4: fp.tls.ja4,
akamai_h2_hash: fp.http2.akamai_fingerprint_hash,
};
}
try {
const desktop = await browser.newContext();
const iphone = await browser.newContext({ ...devices['iPhone 15 Pro'] });
const pixel = await browser.newContext({ ...devices['Pixel 7'] });
console.log(JSON.stringify({
desktop: await inspect(desktop),
'iPhone 15 Pro': await inspect(iphone),
'Pixel 7': await inspect(pixel),
}, null, 2));
} finally {
await browser.close();
}
# 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": True}, timeout=60)
res.raise_for_status()
ws_url = res.json()["wsUrl"]
PAGE_SIGNALS = """() => ({
viewport: `${innerWidth}x${innerHeight}`,
screen: `${screen.width}x${screen.height}`,
device_pixel_ratio: devicePixelRatio,
max_touch_points: navigator.maxTouchPoints,
coarse_pointer: matchMedia('(pointer: coarse)').matches,
platform: navigator.platform,
ua_data_mobile: navigator.userAgentData ? navigator.userAgentData.mobile : null,
ua_data_platform: navigator.userAgentData ? navigator.userAgentData.platform : null,
})"""
def inspect(context):
"""What a page can see about the device, plus what the network sees (tls.peet.ws)."""
page = context.new_page()
fp = page.goto("https://tls.peet.ws/api/all", timeout=60000).json()
js = page.evaluate(PAGE_SIGNALS)
headers = next(f for f in fp["http2"]["sent_frames"] if f["frame_type"] == "HEADERS")["headers"]
header = lambda name: next((h[len(name) + 2:] for h in headers if h.startswith(f"{name}: ")), None)
page.close()
return {
"user_agent": fp["user_agent"],
**js,
"sec_ch_ua_mobile": header("sec-ch-ua-mobile"),
"sec_ch_ua_platform": header("sec-ch-ua-platform"),
"ja4": fp["tls"]["ja4"],
"akamai_h2_hash": fp["http2"]["akamai_fingerprint_hash"],
}
with sync_playwright() as p:
browser = p.chromium.connect(ws_url, headers={"x-api-key": KEY})
try:
print(json.dumps({
"desktop": inspect(browser.new_context()),
"iPhone 15 Pro": inspect(browser.new_context(**p.devices["iPhone 15 Pro"])),
"Pixel 7": inspect(browser.new_context(**p.devices["Pixel 7"])),
}, indent=2))
finally:
browser.close()
// 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.Map;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final String PAGE_SIGNALS = """
() => ({
viewport: `${innerWidth}x${innerHeight}`,
screen: `${screen.width}x${screen.height}`,
device_pixel_ratio: devicePixelRatio,
max_touch_points: navigator.maxTouchPoints,
coarse_pointer: matchMedia('(pointer: coarse)').matches,
platform: navigator.platform,
ua_data_mobile: navigator.userAgentData ? navigator.userAgentData.mobile : null,
ua_data_platform: navigator.userAgentData ? navigator.userAgentData.platform : null,
})""";
// Playwright for Java has no device registry: these are its iPhone 15 Pro and Pixel 7.
static Browser.NewContextOptions device(String ua, int w, int h, int sw, int sh, double scale) {
return new Browser.NewContextOptions().setUserAgent(ua).setViewportSize(w, h).setScreenSize(sw, sh)
.setDeviceScaleFactor(scale).setIsMobile(true).setHasTouch(true);
}
// What a page can see about the device, plus what the network sees (tls.peet.ws).
static JsonObject inspect(BrowserContext context) {
Page page = context.newPage();
JsonObject fp = JsonParser.parseString(page.navigate("https://tls.peet.ws/api/all",
new Page.NavigateOptions().setTimeout(60000)).text()).getAsJsonObject();
JsonObject js = new Gson().toJsonTree(page.evaluate(PAGE_SIGNALS)).getAsJsonObject();
JsonArray headers = null;
for (JsonElement f : fp.getAsJsonObject("http2").getAsJsonArray("sent_frames")) {
if (f.getAsJsonObject().get("frame_type").getAsString().equals("HEADERS")) headers = f.getAsJsonObject().getAsJsonArray("headers");
}
page.close();
JsonObject out = new JsonObject();
out.add("user_agent", fp.get("user_agent"));
for (String k : js.keySet()) out.add(k, js.get(k));
out.addProperty("sec_ch_ua_mobile", header(headers, "sec-ch-ua-mobile"));
out.addProperty("sec_ch_ua_platform", header(headers, "sec-ch-ua-platform"));
out.add("ja4", fp.getAsJsonObject("tls").get("ja4"));
out.add("akamai_h2_hash", fp.getAsJsonObject("http2").get("akamai_fingerprint_hash"));
return out;
}
static String header(JsonArray headers, String name) {
for (JsonElement h : headers) if (h.getAsString().startsWith(name + ": ")) return h.getAsString().substring(name.length() + 2);
return null;
}
public static void main(String[] args) throws Exception {
String body = "{\"proxy\": " + new Gson().toJson(System.getenv("PROXY_URL")) + ", \"headless\": true}";
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 {
JsonObject out = new JsonObject();
out.add("desktop", inspect(browser.newContext()));
out.add("iPhone 15 Pro", inspect(browser.newContext(device(
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1",
393, 659, 393, 852, 3))));
out.add("Pixel 7", inspect(browser.newContext(device(
"Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.7778.96 Mobile Safari/537.36",
412, 839, 412, 915, 2.625))));
System.out.println(new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create().toJson(out));
} finally {
browser.close();
}
}
}
}
// 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 = true });
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 } });
const string PageSignals = """
() => ({
viewport: `${innerWidth}x${innerHeight}`,
screen: `${screen.width}x${screen.height}`,
device_pixel_ratio: devicePixelRatio,
max_touch_points: navigator.maxTouchPoints,
coarse_pointer: matchMedia('(pointer: coarse)').matches,
platform: navigator.platform,
ua_data_mobile: navigator.userAgentData ? navigator.userAgentData.mobile : null,
ua_data_platform: navigator.userAgentData ? navigator.userAgentData.platform : null,
})
""";
// What a page can see about the device, plus what the network sees (tls.peet.ws).
async Task<JsonObject> Inspect(IBrowserContext context)
{
var page = await context.NewPageAsync();
var fp = JsonNode.Parse(await (await page.GotoAsync("https://tls.peet.ws/api/all", new() { Timeout = 60000 }))!.TextAsync())!;
var js = JsonNode.Parse((await page.EvaluateAsync<JsonElement>(PageSignals)).GetRawText())!.AsObject();
var headers = fp["http2"]!["sent_frames"]!.AsArray().First(f => (string?)f!["frame_type"] == "HEADERS")!["headers"]!.AsArray()
.Select(h => (string)h!).ToList();
string? Header(string name) => headers.FirstOrDefault(h => h.StartsWith(name + ": "))?[(name.Length + 2)..];
await page.CloseAsync();
var outp = new JsonObject { ["user_agent"] = fp["user_agent"]!.DeepClone() };
foreach (var (k, v) in js) outp[k] = v?.DeepClone();
outp["sec_ch_ua_mobile"] = Header("sec-ch-ua-mobile");
outp["sec_ch_ua_platform"] = Header("sec-ch-ua-platform");
outp["ja4"] = fp["tls"]!["ja4"]!.DeepClone();
outp["akamai_h2_hash"] = fp["http2"]!["akamai_fingerprint_hash"]!.DeepClone();
return outp;
}
try
{
var result = new JsonObject
{
["desktop"] = await Inspect(await browser.NewContextAsync()),
["iPhone 15 Pro"] = await Inspect(await browser.NewContextAsync(playwright.Devices["iPhone 15 Pro"])),
["Pixel 7"] = await Inspect(await browser.NewContextAsync(playwright.Devices["Pixel 7"])),
};
Console.WriteLine(result.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
}
finally
{
await browser.CloseAsync();
}
// 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"
"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)
}
const pageSignals = `() => ({
viewport: innerWidth + 'x' + innerHeight,
screen: screen.width + 'x' + screen.height,
device_pixel_ratio: devicePixelRatio,
max_touch_points: navigator.maxTouchPoints,
coarse_pointer: matchMedia('(pointer: coarse)').matches,
platform: navigator.platform,
ua_data_mobile: navigator.userAgentData ? navigator.userAgentData.mobile : null,
ua_data_platform: navigator.userAgentData ? navigator.userAgentData.platform : null,
})`
type peet struct {
UserAgent string `json:"user_agent"`
TLS struct {
JA4 string `json:"ja4"`
} `json:"tls"`
HTTP2 struct {
AkamaiFingerprintHash string `json:"akamai_fingerprint_hash"`
SentFrames []struct {
FrameType string `json:"frame_type"`
Headers []string `json:"headers"`
} `json:"sent_frames"`
} `json:"http2"`
}
// What a page can see about the device, plus what the network sees (tls.peet.ws).
func inspect(context playwright.BrowserContext) map[string]any {
page, _ := context.NewPage()
defer page.Close()
res, err := page.Goto("https://tls.peet.ws/api/all", playwright.PageGotoOptions{Timeout: playwright.Float(60000)})
if err != nil {
log.Fatal(err)
}
var fp peet
if err := res.JSON(&fp); err != nil {
log.Fatal(err)
}
js, err := page.Evaluate(pageSignals)
if err != nil {
log.Fatal(err)
}
out := js.(map[string]any)
header := func(name string) any {
for _, f := range fp.HTTP2.SentFrames {
for _, h := range f.Headers {
if f.FrameType == "HEADERS" && strings.HasPrefix(h, name+": ") {
return h[len(name)+2:]
}
}
}
return nil
}
out["user_agent"] = fp.UserAgent
out["sec_ch_ua_mobile"] = header("sec-ch-ua-mobile")
out["sec_ch_ua_platform"] = header("sec-ch-ua-platform")
out["ja4"] = fp.TLS.JA4
out["akamai_h2_hash"] = fp.HTTP2.AkamaiFingerprintHash
return out
}
// Playwright's device descriptors as new-context options.
func device(d *playwright.DeviceDescriptor) playwright.BrowserNewContextOptions {
return playwright.BrowserNewContextOptions{
UserAgent: playwright.String(d.UserAgent), Viewport: d.Viewport, Screen: d.Screen,
DeviceScaleFactor: playwright.Float(d.DeviceScaleFactor), IsMobile: playwright.Bool(d.IsMobile), HasTouch: playwright.Bool(d.HasTouch),
}
}
func main() {
session, err := launch("chrome", map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": true})
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()
desktop, _ := browser.NewContext()
iphone, _ := browser.NewContext(device(pw.Devices["iPhone 15 Pro"]))
pixel, _ := browser.NewContext(device(pw.Devices["Pixel 7"]))
out, _ := json.MarshalIndent(map[string]any{
"desktop": inspect(desktop),
"iPhone 15 Pro": inspect(iphone),
"Pixel 7": inspect(pixel),
}, "", " ")
fmt.Println(string(out))
}
What we got
| Context | User agent | Viewport | DPR | Touch | navigator.platform | userAgentData | JA4 |
|---|---|---|---|---|---|---|---|
| desktop | Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/203.0.113.1 Safari/537.36 | 1280x720 | 1 | 0 | Linux x86_64 | Linux | t13d1517h2_8daaf6152771_cb7bf5808d99 |
| iPhone 15 Pro | Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1 | 980x1644 | 3 | 1 | Linux x86_64 | iOS | t13d1517h2_8daaf6152771_cb7bf5808d99 |
| Pixel 7 | Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.7778.96 Mobile Safari/537.36 | 981x1996 | 2.625 | 1 | Linux x86_64 | Android | 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)
{
"desktop": {
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/203.0.113.1 Safari/537.36",
"viewport": "1280x720",
"screen": "1280x720",
"device_pixel_ratio": 1,
"max_touch_points": 0,
"coarse_pointer": false,
"platform": "Linux x86_64",
"ua_data_mobile": false,
"ua_data_platform": "Linux",
"sec_ch_ua_mobile": "?0",
"sec_ch_ua_platform": "\"Linux\"",
"ja4": "t13d1517h2_8daaf6152771_cb7bf5808d99",
"akamai_h2_hash": "52d84b11737d980aef856699f885ca86"
},
"iPhone 15 Pro": {
"user_agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.4 Mobile/15E148 Safari/604.1",
"viewport": "980x1644",
"screen": "393x852",
"device_pixel_ratio": 3,
"max_touch_points": 1,
"coarse_pointer": true,
"platform": "Linux x86_64",
"ua_data_mobile": true,
"ua_data_platform": "iOS",
"sec_ch_ua_mobile": "?1",
"sec_ch_ua_platform": "\"iOS\"",
"ja4": "t13d1517h2_8daaf6152771_cb7bf5808d99",
"akamai_h2_hash": "52d84b11737d980aef856699f885ca86"
},
"Pixel 7": {
"user_agent": "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.7778.96 Mobile Safari/537.36",
"viewport": "981x1996",
"screen": "412x915",
"device_pixel_ratio": 2.625,
"max_touch_points": 1,
"coarse_pointer": true,
"platform": "Linux x86_64",
"ua_data_mobile": true,
"ua_data_platform": "Android",
"sec_ch_ua_mobile": "?1",
"sec_ch_ua_platform": "\"Android\"",
"ja4": "t13d1517h2_8daaf6152771_cb7bf5808d99",
"akamai_h2_hash": "52d84b11737d980aef856699f885ca86"
}
}Takeaways
- What changes: the user agent, viewport, screen size, device pixel ratio, touch points,
pointer: coarse, and thesec-ch-ua-mobile/sec-ch-ua-platformclient hints. - Tell 1 — TLS: the JA4 and HTTP/2 fingerprints are Chrome's in all three contexts. An "iPhone Safari" with Chrome's TLS is exactly what fingerprinting vendors look for.
- Tell 2 — client hints on "Safari": Safari never sends
sec-ch-ua-*headers or exposesnavigator.userAgentData; the emulated iPhone does both (and saysplatform: iOS). - Tell 3 —
navigator.platformstaysLinux x86_64in both mobile contexts. A real iPhone saysiPhone, a real PixelLinux armv81. - Tell 4 — version skew on Android: the Pixel descriptor's user agent says Chrome 148 while the browser (and its client hints) is Chrome 153.
- Tell 5 — headless: the desktop context's user agent says
HeadlessChrome. Useheadless: false(2 threads) when that matters, or set a user agent. - Emulation is fine for layout and responsive testing. To look like a real phone to a bot check, use a real device — cdpfleet's Android and iPhone sessions are in early access.