Cases / #20 · 2026-10-05 · Easy
Three visitors, one thread: browser contexts instead of sessions
Cookies, storage, locale and clock isolated per persona inside a single browser session — what a context gives you for free, what it shares, and the proxy surprise in the exit IPs.
Run on production on 2026-10-05: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
Running several identities — accounts, A/B variants, regional visitors — is usually done with one browser per identity, which on a fleet means one session and at least one thread each. Playwright's browser contexts promise the same isolation inside one browser: separate cookie jars, storage, permissions, locale and timezone, like incognito windows that don't know about each other. Is the isolation real when the browser is remote, what do the contexts still share, and what does it cost?
What we used, and why
| What | Why |
|---|---|
One chromium session, headless: "new" | All three personas live in it: 1 thread in total. |
browser.newContext({ locale, timezoneId }) | Each context gets its own language and clock — New York, São Paulo, Tokyo — without touching the others. |
context.addCookies and localStorage | A login token per persona, planted before the first page; storage written from the page. |
| Reading everything after all three exist | A leak between contexts would only show once the others have written their state. |
http://ip-api.com/json from each context | Where each persona's traffic exits — all three use the session's proxy. |
How it works
- Launch one headless Chromium session; note the
weightfrom the launch response. - For each persona, create a context with its locale and timezone, plant a session cookie for example.com, open the page and write the persona's name to
localStorage. - With all three open, read back from each:
document.cookie, the storage value,navigator.language, the resolved timezone, a fixed UTC instant as local time. - Fetch the exit IP from each context and print one row per persona.
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 } from 'playwright';
const KEY = process.env.CDPFLEET_API_KEY;
// Three visitors who must not see each other's state — in ONE browser session (1 thread).
const PERSONAS = [
{ name: 'alice', locale: 'en-US', timezone: 'America/New_York' },
{ name: 'bruno', locale: 'pt-BR', timezone: 'America/Sao_Paulo' },
{ name: 'chie', locale: 'ja-JP', timezone: 'Asia/Tokyo' },
];
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, weight } = await res.json();
const browser = await chromium.connect(wsUrl, { headers: { 'x-api-key': KEY } });
try {
const contexts = [];
for (const p of PERSONAS) {
// Each context is a separate profile: its own cookies, storage, locale and clock.
const context = await browser.newContext({ locale: p.locale, timezoneId: p.timezone });
await context.addCookies([{ name: 'session', value: `${p.name}-token`, domain: 'example.com', path: '/' }]);
const page = await context.newPage();
await page.goto('https://example.com/', { timeout: 60000 });
await page.evaluate((n) => localStorage.setItem('owner', n), p.name);
contexts.push({ p, page });
}
const out = [];
for (const { p, page } of contexts) {
// Read everything after all three exist, so any leak between them would show.
const seen = await page.evaluate(() => ({
cookie: document.cookie,
storage_owner: localStorage.getItem('owner'),
language: navigator.language,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
clock: new Date('2026-10-05T12:00:00Z').toLocaleTimeString(),
}));
const ip = await (await page.goto('http://ip-api.com/json/?fields=query', { timeout: 60000 })).json();
out.push({ persona: p.name, threads: weight, ...seen, exit_ip: ip.query });
}
console.log(JSON.stringify(out, 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"]
# Three visitors who must not see each other's state — in ONE browser session (1 thread).
PERSONAS = [
{"name": "alice", "locale": "en-US", "timezone": "America/New_York"},
{"name": "bruno", "locale": "pt-BR", "timezone": "America/Sao_Paulo"},
{"name": "chie", "locale": "ja-JP", "timezone": "Asia/Tokyo"},
]
SEEN = """() => ({
cookie: document.cookie,
storage_owner: localStorage.getItem('owner'),
language: navigator.language,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
clock: new Date('2026-10-05T12:00:00Z').toLocaleTimeString(),
})"""
res = requests.post("https://starter.cdpfleet.com/chromium/session", headers={"x-api-key": KEY}, timeout=60,
json={"proxy": os.environ["PROXY_URL"], "headless": "new"})
if not res.ok:
raise SystemExit(f"launch {res.status_code} {res.text}")
session = res.json()
with sync_playwright() as p:
browser = p.chromium.connect(session["wsUrl"], headers={"x-api-key": KEY})
try:
pages = []
for persona in PERSONAS:
# Each context is a separate profile: its own cookies, storage, locale and clock.
context = browser.new_context(locale=persona["locale"], timezone_id=persona["timezone"])
context.add_cookies([{"name": "session", "value": f"{persona['name']}-token", "domain": "example.com", "path": "/"}])
page = context.new_page()
page.goto("https://example.com/", timeout=60000)
page.evaluate("(n) => localStorage.setItem('owner', n)", persona["name"])
pages.append((persona, page))
out = []
for persona, page in pages:
# Read everything after all three exist, so any leak between them would show.
seen = page.evaluate(SEEN)
ip = page.goto("http://ip-api.com/json/?fields=query", timeout=60000).json()
out.append({"persona": persona["name"], "threads": session["weight"], **seen, "exit_ip": ip["query"]})
print(json.dumps(out, indent=2, ensure_ascii=False))
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 com.microsoft.playwright.options.Cookie;
import java.net.URI;
import java.net.http.*;
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();
// Three visitors who must not see each other's state — in ONE browser session (1 thread).
static final String[][] PERSONAS = {
{"alice", "en-US", "America/New_York"},
{"bruno", "pt-BR", "America/Sao_Paulo"},
{"chie", "ja-JP", "Asia/Tokyo"},
};
static final String SEEN = "() => ({\n"
+ " cookie: document.cookie,\n"
+ " storage_owner: localStorage.getItem('owner'),\n"
+ " language: navigator.language,\n"
+ " timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n"
+ " clock: new Date('2026-10-05T12:00:00Z').toLocaleTimeString(),\n"
+ "})";
public static void main(String[] args) throws Exception {
String body = "{\"proxy\": " + GSON.toJson(System.getenv("PROXY_URL")) + ", \"headless\": \"new\"}";
HttpResponse<String> res = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/chromium/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());
JsonObject session = JsonParser.parseString(res.body()).getAsJsonObject();
try (Playwright playwright = Playwright.create()) {
Browser browser = playwright.chromium().connect(session.get("wsUrl").getAsString(),
new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
try {
List<Page> pages = new ArrayList<>();
for (String[] p : PERSONAS) {
// Each context is a separate profile: its own cookies, storage, locale and clock.
BrowserContext context = browser.newContext(new Browser.NewContextOptions().setLocale(p[1]).setTimezoneId(p[2]));
context.addCookies(List.of(new Cookie("session", p[0] + "-token").setDomain("example.com").setPath("/")));
Page page = context.newPage();
page.navigate("https://example.com/", new Page.NavigateOptions().setTimeout(60000));
page.evaluate("(n) => localStorage.setItem('owner', n)", p[0]);
pages.add(page);
}
JsonArray out = new JsonArray();
for (int i = 0; i < PERSONAS.length; i++) {
Page page = pages.get(i);
// Read everything after all three exist, so any leak between them would show.
JsonObject seen = GSON.toJsonTree(page.evaluate(SEEN)).getAsJsonObject();
JsonObject ip = JsonParser.parseString(page.navigate("http://ip-api.com/json/?fields=query",
new Page.NavigateOptions().setTimeout(60000)).text()).getAsJsonObject();
JsonObject row = new JsonObject();
row.addProperty("persona", PERSONAS[i][0]);
row.add("threads", session.get("weight"));
for (String k : new String[] {"cookie", "storage_owner", "language", "timezone", "clock"}) row.add(k, seen.get(k));
row.add("exit_ip", ip.get("query"));
out.add(row);
}
System.out.println(GSON.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")!;
// Three visitors who must not see each other's state — in ONE browser session (1 thread).
var personas = new[]
{
(Name: "alice", Locale: "en-US", Timezone: "America/New_York"),
(Name: "bruno", Locale: "pt-BR", Timezone: "America/Sao_Paulo"),
(Name: "chie", Locale: "ja-JP", Timezone: "Asia/Tokyo"),
};
const string Seen = @"() => ({
cookie: document.cookie,
storage_owner: localStorage.getItem('owner'),
language: navigator.language,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
clock: new Date('2026-10-05T12:00:00Z').toLocaleTimeString(),
})";
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
var res = await http.PostAsJsonAsync("https://starter.cdpfleet.com/chromium/session",
new { proxy = Environment.GetEnvironmentVariable("PROXY_URL"), headless = "new" });
if (!res.IsSuccessStatusCode) throw new Exception($"launch {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
var session = await res.Content.ReadFromJsonAsync<JsonElement>();
using var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.ConnectAsync(session.GetProperty("wsUrl").GetString()!,
new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
try
{
var pages = new List<IPage>();
foreach (var p in personas)
{
// Each context is a separate profile: its own cookies, storage, locale and clock.
var context = await browser.NewContextAsync(new() { Locale = p.Locale, TimezoneId = p.Timezone });
await context.AddCookiesAsync(new[] { new Cookie { Name = "session", Value = $"{p.Name}-token", Domain = "example.com", Path = "/" } });
var page = await context.NewPageAsync();
await page.GotoAsync("https://example.com/", new() { Timeout = 60000 });
await page.EvaluateAsync("(n) => localStorage.setItem('owner', n)", p.Name);
pages.Add(page);
}
var output = new JsonArray();
for (var i = 0; i < personas.Length; i++)
{
var page = pages[i];
// Read everything after all three exist, so any leak between them would show.
var seen = await page.EvaluateAsync<JsonElement>(Seen);
var ip = JsonNode.Parse(await (await page.GotoAsync("http://ip-api.com/json/?fields=query", new() { Timeout = 60000 }))!.TextAsync())!;
var row = new JsonObject
{
["persona"] = personas[i].Name,
["threads"] = session.GetProperty("weight").GetInt32(),
};
foreach (var k in new[] { "cookie", "storage_owner", "language", "timezone", "clock" })
row[k] = JsonNode.Parse(seen.GetProperty(k).GetRawText());
row["exit_ip"] = (string)ip["query"]!;
output.Add(row);
}
Console.WriteLine(output.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"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
// Three visitors who must not see each other's state — in ONE browser session (1 thread).
var personas = []struct{ name, locale, timezone string }{
{"alice", "en-US", "America/New_York"},
{"bruno", "pt-BR", "America/Sao_Paulo"},
{"chie", "ja-JP", "Asia/Tokyo"},
}
const seen = "() => ({\n" +
" cookie: document.cookie,\n" +
" storage_owner: localStorage.getItem('owner'),\n" +
" language: navigator.language,\n" +
" timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,\n" +
" clock: new Date('2026-10-05T12:00:00Z').toLocaleTimeString(),\n" +
"})"
type row struct {
Persona string `json:"persona"`
Threads int `json:"threads"`
Cookie any `json:"cookie"`
StorageOwner any `json:"storage_owner"`
Language any `json:"language"`
Timezone any `json:"timezone"`
Clock any `json:"clock"`
ExitIP any `json:"exit_ip"`
}
func must[T any](v T, err error) T {
if err != nil {
log.Fatal(err)
}
return v
}
func main() {
body, _ := json.Marshal(map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": "new"})
req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/chromium/session", bytes.NewReader(body))
req.Header.Set("x-api-key", key)
req.Header.Set("content-type", "application/json")
res := must(http.DefaultClient.Do(req))
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(res.Body)
log.Fatalf("launch %s %s", res.Status, msg)
}
var session map[string]any
must(0, json.NewDecoder(res.Body).Decode(&session))
pw := must(playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true}))
defer pw.Stop()
browser := must(pw.Chromium.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}}))
defer browser.Close()
pages := []playwright.Page{}
for _, p := range personas {
// Each context is a separate profile: its own cookies, storage, locale and clock.
context := must(browser.NewContext(playwright.BrowserNewContextOptions{Locale: playwright.String(p.locale), TimezoneId: playwright.String(p.timezone)}))
must(0, context.AddCookies([]playwright.OptionalCookie{{Name: "session", Value: p.name + "-token", Domain: playwright.String("example.com"), Path: playwright.String("/")}}))
page := must(context.NewPage())
must(page.Goto("https://example.com/", playwright.PageGotoOptions{Timeout: playwright.Float(60000)}))
must(page.Evaluate("(n) => localStorage.setItem('owner', n)", p.name))
pages = append(pages, page)
}
out := []row{}
for i, p := range personas {
page := pages[i]
// Read everything after all three exist, so any leak between them would show.
s := must(page.Evaluate(seen)).(map[string]any)
ipRes := must(page.Goto("http://ip-api.com/json/?fields=query", playwright.PageGotoOptions{Timeout: playwright.Float(60000)}))
var ip map[string]any
must(0, ipRes.JSON(&ip))
out = append(out, row{p.name, int(session["weight"].(float64)), s["cookie"], s["storage_owner"], s["language"], s["timezone"], s["clock"], ip["query"]})
}
text, _ := json.MarshalIndent(out, "", " ")
fmt.Println(string(text))
}
What we got
| Persona | Threads | document.cookie | localStorage owner | Language | Timezone | 12:00 UTC shown as | Exit IP |
|---|---|---|---|---|---|---|---|
| alice | 1 | session=alice-token | alice | en-US | America/New_York | 8:00:00 AM | 203.0.113.1 |
| bruno | 1 | session=bruno-token | bruno | pt-BR | America/Sao_Paulo | 09:00:00 | 203.0.113.2 |
| chie | 1 | session=chie-token | chie | ja-JP | Asia/Tokyo | 21:00:00 | 203.0.113.3 |
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)
[
{
"persona": "alice",
"threads": 1,
"cookie": "session=alice-token",
"storage_owner": "alice",
"language": "en-US",
"timezone": "America/New_York",
"clock": "8:00:00 AM",
"exit_ip": "203.0.113.1"
},
{
"persona": "bruno",
"threads": 1,
"cookie": "session=bruno-token",
"storage_owner": "bruno",
"language": "pt-BR",
"timezone": "America/Sao_Paulo",
"clock": "09:00:00",
"exit_ip": "203.0.113.2"
},
{
"persona": "chie",
"threads": 1,
"cookie": "session=chie-token",
"storage_owner": "chie",
"language": "ja-JP",
"timezone": "Asia/Tokyo",
"clock": "21:00:00",
"exit_ip": "203.0.113.3"
}
]Takeaways
- Isolation holds over the remote connection: each persona saw only its own cookie and its own storage value — no leaks between contexts, exactly as in a local browser.
- Locale and clock are per context: the same instant read as 8:00 AM in New York, 09:00 in São Paulo and 21:00 in Tokyo, with matching
navigator.language— three regional visitors from one browser. - It cost one thread. Three sessions would have cost three (six, headful). Contexts are the cheap way to multiply identities when they can share a browser build and a proxy.
- What they share: the browser and its proxy. All contexts go out through the session's proxy — and because ours is a rotating residential proxy, each new connection got a different exit IP (three personas, three IPs). If a persona needs a stable IP, use a sticky proxy port; if two personas must exit from different countries, that is where sessions (with different proxies, or
proxy_rules) come in. - Also shared: the fingerprint. Contexts can change user agent, viewport, locale and timezone, but not the engine, its TLS stack or the GPU string. For identities that must differ at that level, see Camoufox.