[Examples & benchmarks](https://cdpfleet.com/docs/cases) / #40 · 2026-10-12 · Example · Easy · for [ai agents](https://cdpfleet.com/ai-agents), [web scraping](https://cdpfleet.com/web-scraping)

# Persistent profiles: what a saved browser profile keeps, and what it doesn't

Save a cdpfleet browser profile and restore it: which cookies and storage come back, the newContext() trap, read-only sharing, one writer at a time.

[Chromium](https://cdpfleet.com/docs/browsers/chromium)

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

## The problem

Logging in on every run is slow, and on sites with a second factor it can't be automated without the secret. A [persistent profile](https://cdpfleet.com/docs/profiles) keeps the browser's state on cdpfleet between sessions: launch with `"profile": "<name>"`, and the next launch with the same name starts where the last one ended. But does "where it ended" include everything a page had — session cookies, sessionStorage? We write every kind of browser storage in one session, log into a site that keeps its login in a session cookie, and look at what the next session gets back.

## What we used, and why

| What | Why |
| --- | --- |
| `"profile": "case40-…"` | The launch option. The first launch with a new name creates the profile; it's saved when the session ends, after the browser has exited. Each run uses a fresh name and deletes the profile at the end (`DELETE /v1/me/profiles/{name}`). |
| `browser.contexts()[0]` | The profile is the browser's existing context. The programs open their pages there, not in `browser.newContext()`. |
| [authenticationtest.com](https://authenticationtest.com/totpChallenge/) | A public practice login with a TOTP second factor (the code is generated as in the [TOTP login case](https://cdpfleet.com/docs/cases/totp-login)); the account, password and secret are the demo values the site publishes. |
| Five kinds of storage on httpbin.org | A cookie with an expiry (what "Remember me" sets), a cookie without one (a session cookie), localStorage, sessionStorage and an IndexedDB record. |
| `{ "id": …, "mode": "readonly" }` | Read-only launches of the same profile while another session holds it. |

## How it works

1.  Session A launches with a new profile name: log into the practice site (with the TOTP code) and write the five kinds of storage. Close the browser, which ends the session, and wait until `GET /v1/me/profiles` shows version 1.
2.  Session B launches with the same name: read the storage back in `contexts()[0]`, open the practice site, and read the storage in a `newContext()` too.
3.  While B is open, launch the profile again normally, then three times read-only.
4.  Close B, wait for version 2, delete the profile.

## The code

The same program in five languages (also [on GitHub](https://github.com/cdpfleet/cdpfleet/tree/main/cases/40-persistent-profiles), 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 { createHmac, randomBytes } from 'node:crypto';
import { chromium } from 'playwright';

const KEY = process.env.CDPFLEET_API_KEY;
const NAME = `case40-node-${randomBytes(4).toString('hex')}`; // one profile per run
const PRACTICE = 'https://authenticationtest.com';
const SITE = 'https://httpbin.org/html';
// The practice site publishes its test account and TOTP secret on the challenge page.
const LOGIN = { email: 'totp@authenticationtest.com', password: 'pa$$w0rd', secret: 'I65VU7K5ZQL7WB4E' };

// RFC 6238 TOTP (SHA-1, 6 digits, 30 s steps), as in the TOTP login case.
function totp(secret, unixSeconds) {
  let bits = '';
  for (const c of secret) bits += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'.indexOf(c).toString(2).padStart(5, '0');
  const key = Buffer.from(bits.match(/.{8}/g).map((b) => parseInt(b, 2)));
  const counter = Buffer.alloc(8);
  counter.writeBigUInt64BE(BigInt(Math.floor(unixSeconds / 30)));
  const h = createHmac('sha1', key).update(counter).digest();
  const o = h[h.length - 1] & 15;
  return String((h.readUInt32BE(o) & 0x7fffffff) % 1000000).padStart(6, '0');
}

async function api(method, path) {
  const res = await fetch(`https://cdpfleet.com${path}`, { method, headers: { 'x-api-key': KEY } });
  return { status: res.status, body: await res.json().catch(() => ({})) };
}
const profileInfo = async () => (await api('GET', '/v1/me/profiles')).body.profiles?.find((p) => p.name === NAME);
// The node saves the profile after the session's browser has exited: wait for the new version.
async function waitForVersion(version) {
  for (let i = 0; i < 60; i++) {
    const p = await profileInfo();
    if (p?.version >= version && !p.in_use) return p;
    await new Promise((r) => setTimeout(r, 2000));
  }
  throw new Error(`profile ${NAME} never reached version ${version}`);
}

// Launch with the profile. Returns the launch status/error too, so a refused launch is data.
async function launch(profile) {
  const t = Date.now();
  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: true, profile }),
  });
  const body = await res.json();
  if (!res.ok) return { status: res.status, error: body.error };
  const browser = await chromium.connect(body.wsUrl, { headers: { 'x-api-key': KEY } });
  const context = browser.contexts()[0]; // the profile — a newContext() would not be saved
  const page = context.pages()[0] ?? await context.newPage();
  return { status: res.status, sessionId: body.sessionId, browser, context, page, launch_ms: Date.now() - t };
}

// Proxies drop a tunnel now and then: up to two retries.
async function go(page, url) {
  for (let attempt = 1; ; attempt++) {
    try {
      return await page.goto(url, { timeout: 60000 });
    } catch (err) {
      if (attempt === 3) throw err;
      await new Promise((r) => setTimeout(r, 2000));
    }
  }
}

// What the page sees of each kind of storage on httpbin.org.
async function storage(page) {
  await go(page, SITE);
  return page.evaluate(async () => {
    const cookies = Object.fromEntries(document.cookie.split('; ').filter(Boolean).map((c) => c.split('=')));
    const idb = await new Promise((resolve) => {
      const open = indexedDB.open('app', 1);
      open.onupgradeneeded = () => open.result.createObjectStore('auth');
      open.onsuccess = () => {
        const db = open.result;
        if (!db.objectStoreNames.contains('auth')) { db.close(); resolve(null); return; }
        const get = db.transaction('auth').objectStore('auth').get('user');
        get.onsuccess = () => { db.close(); resolve(get.result ?? null); };
        get.onerror = () => { db.close(); resolve(null); };
      };
      open.onerror = () => resolve(null);
    });
    return {
      persistent_cookie: cookies.remember_me === 'alice',
      session_cookie: cookies.session_only === 'yes',
      local_storage: localStorage.getItem('theme') === 'dark',
      session_storage: sessionStorage.getItem('draft') === 'unsaved',
      indexeddb: idb?.email === 'alice@example.com',
    };
  });
}

async function signedIntoPractice(page) {
  await go(page, `${PRACTICE}/`);
  return (await page.locator('text=Sign Out').count()) > 0;
}

const out = { profile_name_prefix: 'case40-', sessions: {}, first: {}, saved: {}, second: {}, new_context: {}, second_writer: {}, readonly: [], after: {} };
try {
  // Session A: a new profile. Log in (TOTP) and write every kind of storage.
  const a = await launch(NAME);
  if (!a.browser) throw new Error(`launch A ${a.status} ${a.error}`);
  out.sessions.a = a.sessionId;
  out.first.launch_ms = a.launch_ms;
  try {
    await go(a.page, `${PRACTICE}/totpChallenge/`);
    if (30 - (Date.now() / 1000) % 30 < 5) await new Promise((r) => setTimeout(r, 5200)); // fresh TOTP window
    await a.page.fill('#email', LOGIN.email);
    await a.page.fill('#password', LOGIN.password);
    await a.page.fill('#totpmfa', totp(LOGIN.secret, Date.now() / 1000));
    await Promise.all([a.page.waitForURL(/\/login(Success|Fail)\//, { timeout: 60000 }), a.page.click('input[type=submit]')]);
    out.first.practice_login = new URL(a.page.url()).pathname === '/loginSuccess/';
    out.first.practice_cookies = (await a.context.cookies(PRACTICE)).map((c) => ({ name: c.name, session: c.expires === -1 }));
    out.first.practice_signed_in = await signedIntoPractice(a.page); // the detector session B relies on
    await go(a.page, SITE);
    await a.page.evaluate(async () => {
      document.cookie = 'remember_me=alice; max-age=2592000; path=/'; // what "Remember me" sets
      document.cookie = 'session_only=yes; path=/'; // no expiry: a session cookie
      localStorage.setItem('theme', 'dark');
      sessionStorage.setItem('draft', 'unsaved');
      await new Promise((resolve, reject) => {
        const open = indexedDB.open('app', 1);
        open.onupgradeneeded = () => open.result.createObjectStore('auth');
        open.onsuccess = () => {
          const tx = open.result.transaction('auth', 'readwrite');
          tx.objectStore('auth').put({ email: 'alice@example.com', token: 'demo-token' }, 'user');
          tx.oncomplete = () => { open.result.close(); resolve(); };
          tx.onerror = () => reject(tx.error);
        };
      });
    });
    out.first.wrote = await storage(a.page);
  } finally {
    await a.browser.close(); // ends the session; the node saves the profile once the browser has exited
  }
  const saved = await waitForVersion(1);
  out.saved = { version: saved.version, bytes: saved.bytes };

  // Session B: the same profile, restored.
  const b = await launch(NAME);
  if (!b.browser) throw new Error(`launch B ${b.status} ${b.error}`);
  out.sessions.b = b.sessionId;
  out.second.launch_ms = b.launch_ms;
  try {
    out.second.restored = await storage(b.page);
    out.second.practice_signed_in = await signedIntoPractice(b.page);

    // The trap: a new context in the same session starts empty and is never saved.
    const scratch = await b.browser.newContext();
    out.new_context = await storage(await scratch.newPage());
    await scratch.close();

    // While B holds the profile: a second normal launch is refused, read-only ones work.
    const w = await launch(NAME);
    out.second_writer = { status: w.status, error: w.error ?? null };
    if (w.browser) await w.browser.close();
    // Readers one at a time, each closed before the next: none sits idle, none is left behind.
    for (let i = 0; i < 3; i++) {
      const r = await launch({ id: NAME, mode: 'readonly' });
      if (!r.browser) { out.readonly.push({ status: r.status, error: r.error }); continue; }
      try {
        const s = await storage(r.page);
        out.readonly.push({ status: r.status, persistent_cookie: s.persistent_cookie, local_storage: s.local_storage, indexeddb: s.indexeddb });
      } finally {
        await r.browser.close();
      }
      await b.page.evaluate(() => document.title); // keep B active (idle sessions end after 2 minutes by default)
    }
    // A read-only save would bump the version: it's still 1 here, and B's save makes it exactly 2.
    out.after.version_after_readonly = (await profileInfo())?.version;
  } finally {
    await b.browser.close();
  }
  out.after.version_after_b = (await waitForVersion(2)).version;
} finally {
  // Delete the profile once no session holds it.
  for (let i = 0; i < 60 && (await profileInfo())?.in_use; i++) await new Promise((r) => setTimeout(r, 2000));
  const del = await api('DELETE', `/v1/me/profiles/${NAME}`);
  out.after.deleted = del.status === 200 && (await profileInfo()) === undefined;
}
console.log(JSON.stringify(out, null, 2));
```

```python
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import base64
import hashlib
import hmac
import json
import os
import re
import secrets
import struct
import time
from urllib.parse import urlparse

import requests
from playwright.sync_api import sync_playwright

KEY = os.environ["CDPFLEET_API_KEY"]
NAME = f"case40-python-{secrets.token_hex(4)}"  # one profile per run
PRACTICE = "https://authenticationtest.com"
SITE = "https://httpbin.org/html"
# The practice site publishes its test account and TOTP secret on the challenge page.
LOGIN = {"email": "totp@authenticationtest.com", "password": "pa$$w0rd", "secret": "I65VU7K5ZQL7WB4E"}

# Writes every kind of storage on httpbin.org, and reads it back.
WRITE_JS = """async () => {
  document.cookie = 'remember_me=alice; max-age=2592000; path=/';  // what "Remember me" sets
  document.cookie = 'session_only=yes; path=/';                    // no expiry: a session cookie
  localStorage.setItem('theme', 'dark');
  sessionStorage.setItem('draft', 'unsaved');
  await new Promise((resolve, reject) => {
    const open = indexedDB.open('app', 1);
    open.onupgradeneeded = () => open.result.createObjectStore('auth');
    open.onsuccess = () => {
      const tx = open.result.transaction('auth', 'readwrite');
      tx.objectStore('auth').put({ email: 'alice@example.com', token: 'demo-token' }, 'user');
      tx.oncomplete = () => { open.result.close(); resolve(); };
      tx.onerror = () => reject(tx.error);
    };
  });
}"""
READ_JS = """async () => {
  const cookies = Object.fromEntries(document.cookie.split('; ').filter(Boolean).map((c) => c.split('=')));
  const idb = await new Promise((resolve) => {
    const open = indexedDB.open('app', 1);
    open.onupgradeneeded = () => open.result.createObjectStore('auth');
    open.onsuccess = () => {
      const db = open.result;
      if (!db.objectStoreNames.contains('auth')) { db.close(); resolve(null); return; }
      const get = db.transaction('auth').objectStore('auth').get('user');
      get.onsuccess = () => { db.close(); resolve(get.result ?? null); };
      get.onerror = () => { db.close(); resolve(null); };
    };
    open.onerror = () => resolve(null);
  });
  return {
    persistent_cookie: cookies.remember_me === 'alice',
    session_cookie: cookies.session_only === 'yes',
    local_storage: localStorage.getItem('theme') === 'dark',
    session_storage: sessionStorage.getItem('draft') === 'unsaved',
    indexeddb: idb?.email === 'alice@example.com',
  };
}"""

def totp(secret, unix_seconds):
    """RFC 6238 TOTP (SHA-1, 6 digits, 30 s steps), as in the TOTP login case."""
    key = base64.b32decode(secret + "=" * (-len(secret) % 8))
    h = hmac.new(key, struct.pack(">Q", int(unix_seconds // 30)), hashlib.sha1).digest()
    o = h[-1] & 15
    return "%06d" % ((struct.unpack(">I", h[o:o + 4])[0] & 0x7FFFFFFF) % 1000000)

def api(method, path):
    res = requests.request(method, f"https://cdpfleet.com{path}", headers={"x-api-key": KEY}, timeout=30)
    try:
        return res.status_code, res.json()
    except ValueError:
        return res.status_code, {}

def profile_info():
    return next((p for p in api("GET", "/v1/me/profiles")[1].get("profiles", []) if p["name"] == NAME), None)

def wait_for_version(version):
    """The node saves the profile after the session's browser has exited: wait for the new version."""
    for _ in range(60):
        p = profile_info()
        if p and p["version"] >= version and not p["in_use"]:
            return p
        time.sleep(2)
    raise SystemExit(f"profile {NAME} never reached version {version}")

def start(profile):
    """POST the launch with the profile (thread-safe: no Playwright here)."""
    t = time.time()
    res = requests.post("https://starter.cdpfleet.com/chromium/session", headers={"x-api-key": KEY},
                        json={"proxy": os.environ["PROXY_URL"], "headless": True, "profile": profile}, timeout=60)
    return res.status_code, res.json(), t

def attach(p, started):
    """Connect to a launched session and open a page in the profile's context."""
    status, body, t = started
    if status != 200:
        return {"status": status, "error": body.get("error")}
    browser = p.chromium.connect(body["wsUrl"], headers={"x-api-key": KEY})
    context = browser.contexts[0]  # the profile — a new_context() would not be saved
    page = context.pages[0] if context.pages else context.new_page()
    return {"status": status, "sessionId": body["sessionId"], "browser": browser, "context": context, "page": page,
            "launch_ms": round((time.time() - t) * 1000)}

def go(page, url):
    """Proxies drop a tunnel now and then: up to two retries."""
    for attempt in range(1, 4):
        try:
            return page.goto(url, timeout=60000)
        except Exception:
            if attempt == 3:
                raise
            time.sleep(2)

def storage(page):
    go(page, SITE)
    return page.evaluate(READ_JS)

def signed_into_practice(page):
    go(page, f"{PRACTICE}/")
    return page.locator("text=Sign Out").count() > 0

out = {"profile_name_prefix": "case40-", "sessions": {}, "first": {}, "saved": {}, "second": {}, "new_context": {},
       "second_writer": {}, "readonly": [], "after": {}}
with sync_playwright() as p:
    try:
        # Session A: a new profile. Log in (TOTP) and write every kind of storage.
        a = attach(p, start(NAME))
        if "browser" not in a:
            raise SystemExit(f"launch A {a['status']} {a['error']}")
        out["sessions"]["a"] = a["sessionId"]
        out["first"]["launch_ms"] = a["launch_ms"]
        try:
            go(a["page"], f"{PRACTICE}/totpChallenge/")
            if 30 - time.time() % 30 < 5:
                time.sleep(5.2)  # fresh TOTP window
            a["page"].fill("#email", LOGIN["email"])
            a["page"].fill("#password", LOGIN["password"])
            a["page"].fill("#totpmfa", totp(LOGIN["secret"], time.time()))
            a["page"].click("input[type=submit]")
            a["page"].wait_for_url(re.compile(r"/login(Success|Fail)/"), timeout=60000)
            out["first"]["practice_login"] = urlparse(a["page"].url).path == "/loginSuccess/"
            out["first"]["practice_cookies"] = [{"name": c["name"], "session": c["expires"] == -1} for c in a["context"].cookies(PRACTICE)]
            out["first"]["practice_signed_in"] = signed_into_practice(a["page"])  # the detector session B relies on
            go(a["page"], SITE)
            a["page"].evaluate(WRITE_JS)
            out["first"]["wrote"] = storage(a["page"])
        finally:
            a["browser"].close()  # ends the session; the node saves the profile once the browser has exited
        saved = wait_for_version(1)
        out["saved"] = {"version": saved["version"], "bytes": saved["bytes"]}

        # Session B: the same profile, restored.
        b = attach(p, start(NAME))
        if "browser" not in b:
            raise SystemExit(f"launch B {b['status']} {b['error']}")
        out["sessions"]["b"] = b["sessionId"]
        out["second"]["launch_ms"] = b["launch_ms"]
        try:
            out["second"]["restored"] = storage(b["page"])
            out["second"]["practice_signed_in"] = signed_into_practice(b["page"])

            # The trap: a new context in the same session starts empty and is never saved.
            scratch = b["browser"].new_context()
            out["new_context"] = storage(scratch.new_page())
            scratch.close()

            # While B holds the profile: a second normal launch is refused, read-only ones work.
            w = attach(p, start(NAME))
            out["second_writer"] = {"status": w["status"], "error": w.get("error")}
            if "browser" in w:
                w["browser"].close()
            # Readers one at a time, each closed before the next: none sits idle, none is left behind.
            for _ in range(3):
                r = attach(p, start({"id": NAME, "mode": "readonly"}))
                if "browser" not in r:
                    out["readonly"].append({"status": r["status"], "error": r["error"]})
                    continue
                try:
                    s = storage(r["page"])
                    out["readonly"].append({"status": r["status"], "persistent_cookie": s["persistent_cookie"],
                                            "local_storage": s["local_storage"], "indexeddb": s["indexeddb"]})
                finally:
                    r["browser"].close()
                b["page"].evaluate("() => document.title")  # keep B active (idle sessions end after 2 minutes by default)
            # A read-only save would bump the version: it's still 1 here, and B's save makes it exactly 2.
            out["after"]["version_after_readonly"] = (profile_info() or {}).get("version")
        finally:
            b["browser"].close()
        out["after"]["version_after_b"] = wait_for_version(2)["version"]
    finally:
        # Delete the profile once no session holds it.
        for _ in range(60):
            info = profile_info()
            if not info or not info["in_use"]:
                break
            time.sleep(2)
        status, _ = api("DELETE", f"/v1/me/profiles/{NAME}")
        out["after"]["deleted"] = status == 200 and profile_info() is None
print(json.dumps(out, indent=2))
```

```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.nio.ByteBuffer;
import java.security.SecureRandom;
import java.util.*;
import java.util.regex.Pattern;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public class Main {
  static final String KEY = System.getenv("CDPFLEET_API_KEY");
  static final String NAME = "case40-java-" + HexFormat.of().formatHex(SecureRandom.getSeed(4)); // one profile per run
  static final String PRACTICE = "https://authenticationtest.com";
  static final String SITE = "https://httpbin.org/html";
  // The practice site publishes its test account and TOTP secret on the challenge page.
  static final String EMAIL = "totp@authenticationtest.com", PASSWORD = "pa$$w0rd", SECRET = "I65VU7K5ZQL7WB4E";
  static final HttpClient HTTP = HttpClient.newHttpClient();
  static final Gson GSON = new Gson();

  // Writes every kind of storage on httpbin.org, and reads it back.
  static final String WRITE_JS = """
async () => {
  document.cookie = 'remember_me=alice; max-age=2592000; path=/';  // what "Remember me" sets
  document.cookie = 'session_only=yes; path=/';                    // no expiry: a session cookie
  localStorage.setItem('theme', 'dark');
  sessionStorage.setItem('draft', 'unsaved');
  await new Promise((resolve, reject) => {
    const open = indexedDB.open('app', 1);
    open.onupgradeneeded = () => open.result.createObjectStore('auth');
    open.onsuccess = () => {
      const tx = open.result.transaction('auth', 'readwrite');
      tx.objectStore('auth').put({ email: 'alice@example.com', token: 'demo-token' }, 'user');
      tx.oncomplete = () => { open.result.close(); resolve(); };
      tx.onerror = () => reject(tx.error);
    };
  });
}
      """;
  static final String READ_JS = """
async () => {
  const cookies = Object.fromEntries(document.cookie.split('; ').filter(Boolean).map((c) => c.split('=')));
  const idb = await new Promise((resolve) => {
    const open = indexedDB.open('app', 1);
    open.onupgradeneeded = () => open.result.createObjectStore('auth');
    open.onsuccess = () => {
      const db = open.result;
      if (!db.objectStoreNames.contains('auth')) { db.close(); resolve(null); return; }
      const get = db.transaction('auth').objectStore('auth').get('user');
      get.onsuccess = () => { db.close(); resolve(get.result ?? null); };
      get.onerror = () => { db.close(); resolve(null); };
    };
    open.onerror = () => resolve(null);
  });
  return {
    persistent_cookie: cookies.remember_me === 'alice',
    session_cookie: cookies.session_only === 'yes',
    local_storage: localStorage.getItem('theme') === 'dark',
    session_storage: sessionStorage.getItem('draft') === 'unsaved',
    indexeddb: idb?.email === 'alice@example.com',
  };
}
      """;

  // RFC 6238 TOTP (SHA-1, 6 digits, 30 s steps), as in the TOTP login case.
  static String totp(String secret, double unixSeconds) throws Exception {
    String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
    ByteBuffer key = ByteBuffer.allocate(secret.length() * 5 / 8);
    int buffer = 0, bits = 0;
    for (char c : secret.toCharArray()) {
      buffer = (buffer << 5) | alphabet.indexOf(c);
      bits += 5;
      if (bits >= 8) { key.put((byte) (buffer >> (bits - 8))); bits -= 8; }
    }
    Mac mac = Mac.getInstance("HmacSHA1");
    mac.init(new SecretKeySpec(key.array(), "HmacSHA1"));
    byte[] h = mac.doFinal(ByteBuffer.allocate(8).putLong((long) Math.floor(unixSeconds / 30)).array());
    int o = h[h.length - 1] & 15;
    return String.format("%06d", (ByteBuffer.wrap(h, o, 4).getInt() & 0x7fffffff) % 1000000);
  }

  static double now() { return System.currentTimeMillis() / 1000.0; }

  static HttpResponse<String> api(String method, String path) throws Exception {
    return HTTP.send(HttpRequest.newBuilder(URI.create("https://cdpfleet.com" + path)).header("x-api-key", KEY)
        .method(method, HttpRequest.BodyPublishers.noBody()).build(), HttpResponse.BodyHandlers.ofString());
  }

  static JsonObject profileInfo() throws Exception {
    for (JsonElement p : JsonParser.parseString(api("GET", "/v1/me/profiles").body()).getAsJsonObject().getAsJsonArray("profiles")) {
      if (p.getAsJsonObject().get("name").getAsString().equals(NAME)) return p.getAsJsonObject();
    }
    return null;
  }

  // The node saves the profile after the session's browser has exited: wait for the new version.
  static JsonObject waitForVersion(int version) throws Exception {
    for (int i = 0; i < 60; i++) {
      JsonObject p = profileInfo();
      if (p != null && p.get("version").getAsInt() >= version && !p.get("in_use").getAsBoolean()) return p;
      Thread.sleep(2000);
    }
    throw new RuntimeException("profile " + NAME + " never reached version " + version);
  }

  record Started(int status, JsonObject body, long t) {}

  // POST the launch with the profile (thread-safe: no Playwright here).
  static Started start(JsonElement profile) {
    try {
      JsonObject req = new JsonObject();
      req.addProperty("proxy", System.getenv("PROXY_URL"));
      req.addProperty("headless", true);
      req.add("profile", profile);
      long t = System.currentTimeMillis();
      HttpResponse<String> res = HTTP.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(req.toString())).build(), HttpResponse.BodyHandlers.ofString());
      return new Started(res.statusCode(), JsonParser.parseString(res.body()).getAsJsonObject(), t);
    } catch (Exception e) {
      throw new RuntimeException(e);
    }
  }

  record Attached(int status, String error, String sessionId, Browser browser, BrowserContext context, Page page, long launchMs) {}

  // Connect to a launched session and open a page in the profile's context.
  static Attached attach(Playwright playwright, Started s) {
    if (s.status() != 200) {
      JsonElement err = s.body().get("error");
      return new Attached(s.status(), err == null || err.isJsonNull() ? null : err.getAsString(), null, null, null, null, 0);
    }
    Browser browser = playwright.chromium().connect(s.body().get("wsUrl").getAsString(),
        new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
    BrowserContext context = browser.contexts().get(0); // the profile — a newContext() would not be saved
    Page page = context.pages().isEmpty() ? context.newPage() : context.pages().get(0);
    return new Attached(s.status(), null, s.body().get("sessionId").getAsString(), browser, context, page, System.currentTimeMillis() - s.t());
  }

  // Proxies drop a tunnel now and then: up to two retries.
  static void go(Page page, String url) throws InterruptedException {
    for (int attempt = 1; ; attempt++) {
      try {
        page.navigate(url, new Page.NavigateOptions().setTimeout(60000));
        return;
      } catch (PlaywrightException e) {
        if (attempt == 3) throw e;
        Thread.sleep(2000);
      }
    }
  }

  static JsonObject storage(Page page) throws InterruptedException {
    go(page, SITE);
    return GSON.toJsonTree(page.evaluate(READ_JS)).getAsJsonObject();
  }

  static boolean signedIntoPractice(Page page) throws InterruptedException {
    go(page, PRACTICE + "/");
    return page.locator("text=Sign Out").count() > 0;
  }

  static JsonObject statusError(Attached a) {
    JsonObject o = new JsonObject();
    o.addProperty("status", a.status());
    if (a.error() == null) o.add("error", JsonNull.INSTANCE); else o.addProperty("error", a.error());
    return o;
  }

  public static void main(String[] args) throws Exception {
    JsonObject out = new JsonObject();
    out.addProperty("profile_name_prefix", "case40-");
    JsonObject sessions = new JsonObject(), first = new JsonObject(), saved = new JsonObject(), second = new JsonObject(), after = new JsonObject();
    JsonArray readonly = new JsonArray();
    out.add("sessions", sessions);
    out.add("first", first);
    out.add("saved", saved);
    out.add("second", second);
    out.add("new_context", new JsonObject());
    out.add("second_writer", new JsonObject());
    out.add("readonly", readonly);
    out.add("after", after);
    try (Playwright playwright = Playwright.create()) {
      try {
        // Session A: a new profile. Log in (TOTP) and write every kind of storage.
        Attached a = attach(playwright, start(new JsonPrimitive(NAME)));
        if (a.browser() == null) throw new RuntimeException("launch A " + a.status() + " " + a.error());
        sessions.addProperty("a", a.sessionId());
        first.addProperty("launch_ms", a.launchMs());
        try {
          go(a.page(), PRACTICE + "/totpChallenge/");
          if (30 - now() % 30 < 5) Thread.sleep(5200); // fresh TOTP window
          a.page().fill("#email", EMAIL);
          a.page().fill("#password", PASSWORD);
          a.page().fill("#totpmfa", totp(SECRET, now()));
          a.page().click("input[type=submit]");
          a.page().waitForURL(Pattern.compile("/login(Success|Fail)/"), new Page.WaitForURLOptions().setTimeout(60000));
          first.addProperty("practice_login", URI.create(a.page().url()).getPath().equals("/loginSuccess/"));
          JsonArray cookies = new JsonArray();
          for (var c : a.context().cookies(PRACTICE)) {
            JsonObject entry = new JsonObject();
            entry.addProperty("name", c.name);
            entry.addProperty("session", c.expires == null || c.expires == -1);
            cookies.add(entry);
          }
          first.add("practice_cookies", cookies);
          first.addProperty("practice_signed_in", signedIntoPractice(a.page())); // the detector session B relies on
          go(a.page(), SITE);
          a.page().evaluate(WRITE_JS);
          first.add("wrote", storage(a.page()));
        } finally {
          a.browser().close(); // ends the session; the node saves the profile once the browser has exited
        }
        JsonObject s1 = waitForVersion(1);
        saved.add("version", s1.get("version"));
        saved.add("bytes", s1.get("bytes"));

        // Session B: the same profile, restored.
        Attached b = attach(playwright, start(new JsonPrimitive(NAME)));
        if (b.browser() == null) throw new RuntimeException("launch B " + b.status() + " " + b.error());
        sessions.addProperty("b", b.sessionId());
        second.addProperty("launch_ms", b.launchMs());
        try {
          second.add("restored", storage(b.page()));
          second.addProperty("practice_signed_in", signedIntoPractice(b.page()));

          // The trap: a new context in the same session starts empty and is never saved.
          BrowserContext scratch = b.browser().newContext();
          out.add("new_context", storage(scratch.newPage()));
          scratch.close();

          // While B holds the profile: a second normal launch is refused, read-only ones work.
          Attached w = attach(playwright, start(new JsonPrimitive(NAME)));
          out.add("second_writer", statusError(w));
          if (w.browser() != null) w.browser().close();
          JsonObject ro = new JsonObject();
          ro.addProperty("id", NAME);
          ro.addProperty("mode", "readonly");
          // Readers one at a time, each closed before the next: none sits idle, none is left behind.
          for (int i = 0; i < 3; i++) {
            Attached r = attach(playwright, start(ro));
            if (r.browser() == null) { readonly.add(statusError(r)); continue; }
            try {
              JsonObject s = storage(r.page());
              JsonObject entry = new JsonObject();
              entry.addProperty("status", r.status());
              entry.add("persistent_cookie", s.get("persistent_cookie"));
              entry.add("local_storage", s.get("local_storage"));
              entry.add("indexeddb", s.get("indexeddb"));
              readonly.add(entry);
            } finally {
              r.browser().close();
            }
            b.page().evaluate("() => document.title"); // keep B active (idle sessions end after 2 minutes by default)
          }
          // A read-only save would bump the version: it's still 1 here, and B's save makes it exactly 2.
          JsonObject info = profileInfo();
          after.add("version_after_readonly", info == null ? JsonNull.INSTANCE : info.get("version"));
        } finally {
          b.browser().close();
        }
        after.add("version_after_b", waitForVersion(2).get("version"));
      } finally {
        // Delete the profile once no session holds it.
        for (int i = 0; i < 60; i++) {
          JsonObject info = profileInfo();
          if (info == null || !info.get("in_use").getAsBoolean()) break;
          Thread.sleep(2000);
        }
        int status = api("DELETE", "/v1/me/profiles/" + NAME).statusCode();
        after.addProperty("deleted", status == 200 && profileInfo() == null);
      }
    }
    System.out.println(new GsonBuilder().setPrettyPrinting().create().toJson(out));
  }
}
```

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

var name = $"case40-csharp-{Convert.ToHexString(RandomNumberGenerator.GetBytes(4)).ToLowerInvariant()}"; // one profile per run
const string Practice = "https://authenticationtest.com";
const string Site = "https://httpbin.org/html";
// The practice site publishes its test account and TOTP secret on the challenge page.
const string Email = "totp@authenticationtest.com", Password = "pa$$w0rd", Secret = "I65VU7K5ZQL7WB4E";
string[] storageKeys = ["persistent_cookie", "session_cookie", "local_storage", "session_storage", "indexeddb"];

// Writes every kind of storage on httpbin.org, and reads it back.
const string WriteJs = """
async () => {
  document.cookie = 'remember_me=alice; max-age=2592000; path=/';  // what "Remember me" sets
  document.cookie = 'session_only=yes; path=/';                    // no expiry: a session cookie
  localStorage.setItem('theme', 'dark');
  sessionStorage.setItem('draft', 'unsaved');
  await new Promise((resolve, reject) => {
    const open = indexedDB.open('app', 1);
    open.onupgradeneeded = () => open.result.createObjectStore('auth');
    open.onsuccess = () => {
      const tx = open.result.transaction('auth', 'readwrite');
      tx.objectStore('auth').put({ email: 'alice@example.com', token: 'demo-token' }, 'user');
      tx.oncomplete = () => { open.result.close(); resolve(); };
      tx.onerror = () => reject(tx.error);
    };
  });
}
""";
const string ReadJs = """
async () => {
  const cookies = Object.fromEntries(document.cookie.split('; ').filter(Boolean).map((c) => c.split('=')));
  const idb = await new Promise((resolve) => {
    const open = indexedDB.open('app', 1);
    open.onupgradeneeded = () => open.result.createObjectStore('auth');
    open.onsuccess = () => {
      const db = open.result;
      if (!db.objectStoreNames.contains('auth')) { db.close(); resolve(null); return; }
      const get = db.transaction('auth').objectStore('auth').get('user');
      get.onsuccess = () => { db.close(); resolve(get.result ?? null); };
      get.onerror = () => { db.close(); resolve(null); };
    };
    open.onerror = () => resolve(null);
  });
  return {
    persistent_cookie: cookies.remember_me === 'alice',
    session_cookie: cookies.session_only === 'yes',
    local_storage: localStorage.getItem('theme') === 'dark',
    session_storage: sessionStorage.getItem('draft') === 'unsaved',
    indexeddb: idb?.email === 'alice@example.com',
  };
}
""";

var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();

var output = new JsonObject
{
    ["profile_name_prefix"] = "case40-", ["sessions"] = new JsonObject(), ["first"] = new JsonObject(), ["saved"] = new JsonObject(),
    ["second"] = new JsonObject(), ["new_context"] = new JsonObject(), ["second_writer"] = new JsonObject(), ["readonly"] = new JsonArray(), ["after"] = new JsonObject(),
};
var first = output["first"]!.AsObject();
var second = output["second"]!.AsObject();
var after = output["after"]!.AsObject();
var readonlyList = output["readonly"]!.AsArray();
try
{
    // Session A: a new profile. Log in (TOTP) and write every kind of storage.
    var a = await Attach(await Start(name));
    if (a.Browser is null) throw new Exception($"launch A {a.Status} {a.Error}");
    output["sessions"]!["a"] = a.SessionId;
    first["launch_ms"] = a.LaunchMs;
    try
    {
        await Go(a.Page!, $"{Practice}/totpChallenge/");
        if (30 - Now() % 30 < 5) await Task.Delay(5200); // fresh TOTP window
        await a.Page!.FillAsync("#email", Email);
        await a.Page.FillAsync("#password", Password);
        await a.Page.FillAsync("#totpmfa", Totp(Secret, Now()));
        await a.Page.ClickAsync("input[type=submit]");
        await a.Page.WaitForURLAsync(new Regex("/login(Success|Fail)/"), new() { Timeout = 60000 });
        first["practice_login"] = new Uri(a.Page.Url).AbsolutePath == "/loginSuccess/";
        first["practice_cookies"] = new JsonArray((await a.Context!.CookiesAsync([Practice]))
            .Select(c => (JsonNode)new JsonObject { ["name"] = c.Name, ["session"] = c.Expires == -1 }).ToArray());
        first["practice_signed_in"] = await SignedIntoPractice(a.Page); // the detector session B relies on
        await Go(a.Page, Site);
        await a.Page.EvaluateAsync(WriteJs);
        first["wrote"] = await Storage(a.Page);
    }
    finally
    {
        await a.Browser.CloseAsync(); // ends the session; the node saves the profile once the browser has exited
    }
    var saved = await WaitForVersion(1);
    output["saved"] = new JsonObject { ["version"] = saved["version"]!.GetValue<int>(), ["bytes"] = saved["bytes"]!.GetValue<long>() };

    // Session B: the same profile, restored.
    var b = await Attach(await Start(name));
    if (b.Browser is null) throw new Exception($"launch B {b.Status} {b.Error}");
    output["sessions"]!["b"] = b.SessionId;
    second["launch_ms"] = b.LaunchMs;
    try
    {
        second["restored"] = await Storage(b.Page!);
        second["practice_signed_in"] = await SignedIntoPractice(b.Page!);

        // The trap: a new context in the same session starts empty and is never saved.
        var scratch = await b.Browser.NewContextAsync();
        output["new_context"] = await Storage(await scratch.NewPageAsync());
        await scratch.CloseAsync();

        // While B holds the profile: a second normal launch is refused, read-only ones work.
        var w = await Attach(await Start(name));
        output["second_writer"] = new JsonObject { ["status"] = w.Status, ["error"] = w.Error };
        if (w.Browser is not null) await w.Browser.CloseAsync();
        // Readers one at a time, each closed before the next: none sits idle, none is left behind.
        for (var i = 0; i < 3; i++)
        {
            var r = await Attach(await Start(new JsonObject { ["id"] = name, ["mode"] = "readonly" }));
            if (r.Browser is null) { readonlyList.Add(new JsonObject { ["status"] = r.Status, ["error"] = r.Error }); continue; }
            try
            {
                var s = await Storage(r.Page!);
                readonlyList.Add(new JsonObject
                {
                    ["status"] = r.Status, ["persistent_cookie"] = s["persistent_cookie"]!.GetValue<bool>(),
                    ["local_storage"] = s["local_storage"]!.GetValue<bool>(), ["indexeddb"] = s["indexeddb"]!.GetValue<bool>(),
                });
            }
            finally
            {
                await r.Browser.CloseAsync();
            }
            await b.Page!.EvaluateAsync("() => document.title"); // keep B active (idle sessions end after 2 minutes by default)
        }
        // A read-only save would bump the version: it's still 1 here, and B's save makes it exactly 2.
        after["version_after_readonly"] = (await ProfileInfo())?["version"]?.GetValue<int>();
    }
    finally
    {
        await b.Browser.CloseAsync();
    }
    after["version_after_b"] = (await WaitForVersion(2))["version"]!.GetValue<int>();
}
finally
{
    // Delete the profile once no session holds it.
    for (var i = 0; i < 60 && (await ProfileInfo())?["in_use"]?.GetValue<bool>() == true; i++) await Task.Delay(2000);
    var del = await http.DeleteAsync($"https://cdpfleet.com/v1/me/profiles/{name}");
    after["deleted"] = (int)del.StatusCode == 200 && await ProfileInfo() is null;
}
Console.WriteLine(output.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));

// RFC 6238 TOTP (SHA-1, 6 digits, 30 s steps), as in the TOTP login case.
static string Totp(string secret, double unixSeconds)
{
    const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
    var key = new List<byte>();
    int buffer = 0, bits = 0;
    foreach (var c in secret)
    {
        buffer = (buffer << 5) | alphabet.IndexOf(c);
        bits += 5;
        if (bits >= 8) { key.Add((byte)(buffer >> (bits - 8))); bits -= 8; }
    }
    var counter = new byte[8];
    BinaryPrimitives.WriteInt64BigEndian(counter, (long)Math.Floor(unixSeconds / 30));
    var h = HMACSHA1.HashData(key.ToArray(), counter);
    var o = h[^1] & 15;
    return ((BinaryPrimitives.ReadInt32BigEndian(h.AsSpan(o, 4)) & 0x7fffffff) % 1000000).ToString("D6");
}

static double Now() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0;

async Task<JsonNode?> ProfileInfo()
{
    var list = await http.GetFromJsonAsync<JsonObject>("https://cdpfleet.com/v1/me/profiles");
    return list!["profiles"]!.AsArray().FirstOrDefault(p => p!["name"]!.GetValue<string>() == name);
}

// The node saves the profile after the session's browser has exited: wait for the new version.
async Task<JsonNode> WaitForVersion(int version)
{
    for (var i = 0; i < 60; i++)
    {
        var p = await ProfileInfo();
        if (p is not null && p["version"]!.GetValue<int>() >= version && !p["in_use"]!.GetValue<bool>()) return p;
        await Task.Delay(2000);
    }
    throw new Exception($"profile {name} never reached version {version}");
}

// POST the launch with the profile.
async Task<(int Status, JsonObject Body, Stopwatch Clock)> Start(JsonNode profile)
{
    var clock = Stopwatch.StartNew();
    var res = await http.PostAsJsonAsync("https://starter.cdpfleet.com/chromium/session",
        new JsonObject { ["proxy"] = Environment.GetEnvironmentVariable("PROXY_URL"), ["headless"] = true, ["profile"] = profile });
    return ((int)res.StatusCode, (await res.Content.ReadFromJsonAsync<JsonObject>())!, clock);
}

// Connect to a launched session and open a page in the profile's context.
async Task<Attached> Attach((int Status, JsonObject Body, Stopwatch Clock) s)
{
    if (s.Status != 200) return new Attached(s.Status, s.Body["error"]?.GetValue<string>(), null, null, null, null, 0);
    var browser = await playwright.Chromium.ConnectAsync(s.Body["wsUrl"]!.GetValue<string>(),
        new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
    var context = browser.Contexts[0]; // the profile — a NewContextAsync() would not be saved
    var page = context.Pages.Count > 0 ? context.Pages[0] : await context.NewPageAsync();
    return new Attached(s.Status, null, s.Body["sessionId"]!.GetValue<string>(), browser, context, page, s.Clock.ElapsedMilliseconds);
}

// Proxies drop a tunnel now and then: up to two retries.
static async Task Go(IPage page, string url)
{
    for (var attempt = 1; ; attempt++)
    {
        try { await page.GotoAsync(url, new() { Timeout = 60000 }); return; }
        catch (Exception e) when (attempt < 3 && e is PlaywrightException or TimeoutException) { await Task.Delay(2000); }
    }
}

// Read by key: Playwright .NET adds "$id" metadata to objects it returns.
async Task<JsonObject> Storage(IPage page)
{
    await Go(page, Site);
    var el = await page.EvaluateAsync<JsonElement>(ReadJs);
    var o = new JsonObject();
    foreach (var k in storageKeys) o[k] = el.GetProperty(k).GetBoolean();
    return o;
}

static async Task<bool> SignedIntoPractice(IPage page)
{
    await Go(page, $"{Practice}/");
    return await page.Locator("text=Sign Out").CountAsync() > 0;
}

record Attached(int Status, string? Error, string? SessionId, IBrowser? Browser, IBrowserContext? Context, IPage? Page, long LaunchMs);
```

```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"
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha1"
	"encoding/base32"
	"encoding/binary"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"log"
	"math"
	"net/http"
	"net/url"
	"os"
	"regexp"
	"time"

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

const practice = "https://authenticationtest.com"
const site = "https://httpbin.org/html"

// The practice site publishes its test account and TOTP secret on the challenge page.
const email, password, secret = "totp@authenticationtest.com", "pa$$w0rd", "I65VU7K5ZQL7WB4E"

// Writes every kind of storage on httpbin.org, and reads it back.
const writeJS = `async () => {
  document.cookie = 'remember_me=alice; max-age=2592000; path=/';  // what "Remember me" sets
  document.cookie = 'session_only=yes; path=/';                    // no expiry: a session cookie
  localStorage.setItem('theme', 'dark');
  sessionStorage.setItem('draft', 'unsaved');
  await new Promise((resolve, reject) => {
    const open = indexedDB.open('app', 1);
    open.onupgradeneeded = () => open.result.createObjectStore('auth');
    open.onsuccess = () => {
      const tx = open.result.transaction('auth', 'readwrite');
      tx.objectStore('auth').put({ email: 'alice@example.com', token: 'demo-token' }, 'user');
      tx.oncomplete = () => { open.result.close(); resolve(); };
      tx.onerror = () => reject(tx.error);
    };
  });
}`

const readJS = `async () => {
  const cookies = Object.fromEntries(document.cookie.split('; ').filter(Boolean).map((c) => c.split('=')));
  const idb = await new Promise((resolve) => {
    const open = indexedDB.open('app', 1);
    open.onupgradeneeded = () => open.result.createObjectStore('auth');
    open.onsuccess = () => {
      const db = open.result;
      if (!db.objectStoreNames.contains('auth')) { db.close(); resolve(null); return; }
      const get = db.transaction('auth').objectStore('auth').get('user');
      get.onsuccess = () => { db.close(); resolve(get.result ?? null); };
      get.onerror = () => { db.close(); resolve(null); };
    };
    open.onerror = () => resolve(null);
  });
  return {
    persistent_cookie: cookies.remember_me === 'alice',
    session_cookie: cookies.session_only === 'yes',
    local_storage: localStorage.getItem('theme') === 'dark',
    session_storage: sessionStorage.getItem('draft') === 'unsaved',
    indexeddb: idb?.email === 'alice@example.com',
  };
}`

var key = os.Getenv("CDPFLEET_API_KEY")
var name = func() string { b := make([]byte, 4); rand.Read(b); return "case40-go-" + hex.EncodeToString(b) }() // one profile per run

type Storage struct {
	PersistentCookie bool `json:"persistent_cookie"`
	SessionCookie    bool `json:"session_cookie"`
	LocalStorage     bool `json:"local_storage"`
	SessionStorage   bool `json:"session_storage"`
	IndexedDB        bool `json:"indexeddb"`
}

type ReadonlyEntry struct {
	Status           int    `json:"status"`
	Error            string `json:"error,omitempty"`
	PersistentCookie *bool  `json:"persistent_cookie,omitempty"`
	LocalStorage     *bool  `json:"local_storage,omitempty"`
	IndexedDB        *bool  `json:"indexeddb,omitempty"`
}

type PracticeCookie struct {
	Name    string `json:"name"`
	Session bool   `json:"session"`
}

type Output struct {
	ProfileNamePrefix string `json:"profile_name_prefix"`
	Sessions          struct {
		A string `json:"a,omitempty"`
		B string `json:"b,omitempty"`
	} `json:"sessions"`
	First struct {
		LaunchMs         int64            `json:"launch_ms"`
		PracticeLogin    bool             `json:"practice_login"`
		PracticeCookies  []PracticeCookie `json:"practice_cookies"`
		PracticeSignedIn bool             `json:"practice_signed_in"`
		Wrote            *Storage         `json:"wrote,omitempty"`
	} `json:"first"`
	Saved struct {
		Version int   `json:"version"`
		Bytes   int64 `json:"bytes"`
	} `json:"saved"`
	Second struct {
		LaunchMs         int64    `json:"launch_ms"`
		Restored         *Storage `json:"restored,omitempty"`
		PracticeSignedIn bool     `json:"practice_signed_in"`
	} `json:"second"`
	NewContext   *Storage `json:"new_context"`
	SecondWriter struct {
		Status int     `json:"status"`
		Error  *string `json:"error"`
	} `json:"second_writer"`
	Readonly []ReadonlyEntry `json:"readonly"`
	After    struct {
		VersionAfterReadonly *int `json:"version_after_readonly,omitempty"`
		VersionAfterB        *int `json:"version_after_b,omitempty"`
		Deleted              bool `json:"deleted"`
	} `json:"after"`
}

type Profile struct {
	Name    string `json:"name"`
	Version int    `json:"version"`
	Bytes   int64  `json:"bytes"`
	InUse   bool   `json:"in_use"`
}

// RFC 6238 TOTP (SHA-1, 6 digits, 30 s steps), as in the TOTP login case.
func totp(k []byte, unixSeconds float64) string {
	counter := make([]byte, 8)
	binary.BigEndian.PutUint64(counter, uint64(math.Floor(unixSeconds/30)))
	mac := hmac.New(sha1.New, k)
	mac.Write(counter)
	h := mac.Sum(nil)
	o := h[len(h)-1] & 15
	return fmt.Sprintf("%06d", (binary.BigEndian.Uint32(h[o:o+4])&0x7fffffff)%1000000)
}

func now() float64 { return float64(time.Now().UnixMilli()) / 1000 }

func profileInfo() (*Profile, error) {
	req, _ := http.NewRequest("GET", "https://cdpfleet.com/v1/me/profiles", nil)
	req.Header.Set("x-api-key", key)
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	var list struct {
		Profiles []Profile `json:"profiles"`
	}
	if err := json.NewDecoder(res.Body).Decode(&list); err != nil {
		return nil, err
	}
	for _, p := range list.Profiles {
		if p.Name == name {
			return &p, nil
		}
	}
	return nil, nil
}

// The node saves the profile after the session's browser has exited: wait for the new version.
func waitForVersion(version int) (*Profile, error) {
	for i := 0; i < 60; i++ {
		p, err := profileInfo()
		if err == nil && p != nil && p.Version >= version && !p.InUse {
			return p, nil
		}
		time.Sleep(2 * time.Second)
	}
	return nil, fmt.Errorf("profile %s never reached version %d", name, version)
}

type Started struct {
	Status int
	Body   map[string]any
	T      time.Time
}

// POST the launch with the profile (safe from goroutines: no Playwright here).
func start(profile any) Started {
	t := time.Now()
	body, _ := json.Marshal(map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": true, "profile": profile})
	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, err := http.DefaultClient.Do(req)
	if err != nil {
		return Started{Status: 0, Body: map[string]any{"error": err.Error()}, T: t}
	}
	defer res.Body.Close()
	var b map[string]any
	json.NewDecoder(res.Body).Decode(&b)
	return Started{Status: res.StatusCode, Body: b, T: t}
}

type Attached struct {
	Status    int
	Error     string
	SessionID string
	Browser   playwright.Browser
	Context   playwright.BrowserContext
	Page      playwright.Page
	LaunchMs  int64
}

// Connect to a launched session and open a page in the profile's context.
func attach(pw *playwright.Playwright, s Started) (*Attached, error) {
	if s.Status != 200 {
		e, _ := s.Body["error"].(string)
		return &Attached{Status: s.Status, Error: e}, nil
	}
	browser, err := pw.Chromium.Connect(s.Body["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
	if err != nil {
		return nil, err
	}
	context := browser.Contexts()[0] // the profile — a NewContext() would not be saved
	var page playwright.Page
	if pages := context.Pages(); len(pages) > 0 {
		page = pages[0]
	} else if page, err = context.NewPage(); err != nil {
		browser.Close()
		return nil, err
	}
	return &Attached{Status: s.Status, SessionID: s.Body["sessionId"].(string), Browser: browser, Context: context, Page: page, LaunchMs: time.Since(s.T).Milliseconds()}, nil
}

// Proxies drop a tunnel now and then: up to two retries.
func gotoURL(page playwright.Page, u string) error {
	var err error
	for attempt := 1; attempt <= 3; attempt++ {
		if _, err = page.Goto(u, playwright.PageGotoOptions{Timeout: playwright.Float(60000)}); err == nil {
			return nil
		}
		time.Sleep(2 * time.Second)
	}
	return err
}

func storage(page playwright.Page) (*Storage, error) {
	if err := gotoURL(page, site); err != nil {
		return nil, err
	}
	v, err := page.Evaluate(readJS)
	if err != nil {
		return nil, err
	}
	raw, _ := json.Marshal(v)
	var s Storage
	return &s, json.Unmarshal(raw, &s)
}

func signedIntoPractice(page playwright.Page) (bool, error) {
	if err := gotoURL(page, practice+"/"); err != nil {
		return false, err
	}
	n, err := page.Locator("text=Sign Out").Count()
	return n > 0, err
}

func sessionA(pw *playwright.Playwright, out *Output, secretKey []byte) error {
	a, err := attach(pw, start(name))
	if err != nil {
		return err
	}
	if a.Browser == nil {
		return fmt.Errorf("launch A %d %s", a.Status, a.Error)
	}
	defer a.Browser.Close() // ends the session; the node saves the profile once the browser has exited
	out.Sessions.A = a.SessionID
	out.First.LaunchMs = a.LaunchMs
	if err := gotoURL(a.Page, practice+"/totpChallenge/"); err != nil {
		return err
	}
	if 30-math.Mod(now(), 30) < 5 {
		time.Sleep(5200 * time.Millisecond) // fresh TOTP window
	}
	for sel, v := range map[string]string{"#email": email, "#password": password, "#totpmfa": totp(secretKey, now())} {
		if err := a.Page.Fill(sel, v); err != nil {
			return err
		}
	}
	if err := a.Page.Click("input[type=submit]"); err != nil {
		return err
	}
	// playwright-go 0.6000's WaitForURL panics in a context the client didn't create (it reads
	// the context's options, which are nil for contexts()[0]), so poll the URL instead.
	landedRe := regexp.MustCompile(`/login(Success|Fail)/`)
	for deadline := time.Now().Add(60 * time.Second); !landedRe.MatchString(a.Page.URL()); time.Sleep(200 * time.Millisecond) {
		if time.Now().After(deadline) {
			return fmt.Errorf("login result page never loaded (at %s)", a.Page.URL())
		}
	}
	landed, _ := url.Parse(a.Page.URL())
	out.First.PracticeLogin = landed.Path == "/loginSuccess/"
	cookies, err := a.Context.Cookies(practice)
	if err != nil {
		return err
	}
	out.First.PracticeCookies = []PracticeCookie{}
	for _, c := range cookies {
		out.First.PracticeCookies = append(out.First.PracticeCookies, PracticeCookie{Name: c.Name, Session: c.Expires == -1})
	}
	// The detector session B relies on.
	if out.First.PracticeSignedIn, err = signedIntoPractice(a.Page); err != nil {
		return err
	}
	if err := gotoURL(a.Page, site); err != nil {
		return err
	}
	if _, err := a.Page.Evaluate(writeJS); err != nil {
		return err
	}
	out.First.Wrote, err = storage(a.Page)
	return err
}

func sessionB(pw *playwright.Playwright, out *Output) error {
	b, err := attach(pw, start(name))
	if err != nil {
		return err
	}
	if b.Browser == nil {
		return fmt.Errorf("launch B %d %s", b.Status, b.Error)
	}
	defer b.Browser.Close()
	out.Sessions.B = b.SessionID
	out.Second.LaunchMs = b.LaunchMs
	if out.Second.Restored, err = storage(b.Page); err != nil {
		return err
	}
	if out.Second.PracticeSignedIn, err = signedIntoPractice(b.Page); err != nil {
		return err
	}

	// The trap: a new context in the same session starts empty and is never saved.
	scratch, err := b.Browser.NewContext()
	if err != nil {
		return err
	}
	page, err := scratch.NewPage()
	if err != nil {
		return err
	}
	if out.NewContext, err = storage(page); err != nil {
		return err
	}
	scratch.Close()

	// While B holds the profile: a second normal launch is refused, read-only ones work.
	w, err := attach(pw, start(name))
	if err != nil {
		return err
	}
	out.SecondWriter.Status = w.Status
	if w.Error != "" {
		out.SecondWriter.Error = &w.Error
	}
	if w.Browser != nil {
		w.Browser.Close()
	}
	// Readers one at a time, each closed before the next: none sits idle, none is left behind.
	out.Readonly = []ReadonlyEntry{}
	for i := 0; i < 3; i++ {
		r, err := attach(pw, start(map[string]string{"id": name, "mode": "readonly"}))
		if err != nil {
			return err
		}
		if r.Browser == nil {
			out.Readonly = append(out.Readonly, ReadonlyEntry{Status: r.Status, Error: r.Error})
			continue
		}
		st, err := storage(r.Page)
		r.Browser.Close()
		if err != nil {
			return err
		}
		out.Readonly = append(out.Readonly, ReadonlyEntry{Status: r.Status, PersistentCookie: &st.PersistentCookie, LocalStorage: &st.LocalStorage, IndexedDB: &st.IndexedDB})
		if _, err := b.Page.Evaluate("() => document.title"); err != nil { // keep B active (idle sessions end after 2 minutes by default)
			return err
		}
	}
	// A read-only save would bump the version: it's still 1 here, and B's save makes it exactly 2.
	if p, err := profileInfo(); err == nil && p != nil {
		out.After.VersionAfterReadonly = &p.Version
	}
	return nil
}

func run(out *Output) error {
	secretKey, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
	if err != nil {
		return fmt.Errorf("TOTP secret: %w", err)
	}
	pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
	if err != nil {
		return err
	}
	defer pw.Stop()
	defer func() {
		// Delete the profile once no session holds it.
		for i := 0; i < 60; i++ {
			if p, err := profileInfo(); err != nil || p == nil || !p.InUse {
				break
			}
			time.Sleep(2 * time.Second)
		}
		req, _ := http.NewRequest("DELETE", "https://cdpfleet.com/v1/me/profiles/"+name, nil)
		req.Header.Set("x-api-key", key)
		if res, err := http.DefaultClient.Do(req); err == nil {
			res.Body.Close()
			p, perr := profileInfo()
			out.After.Deleted = res.StatusCode == 200 && perr == nil && p == nil
		}
	}()
	if err := sessionA(pw, out, secretKey); err != nil {
		return err
	}
	saved, err := waitForVersion(1)
	if err != nil {
		return err
	}
	out.Saved.Version, out.Saved.Bytes = saved.Version, saved.Bytes
	if err := sessionB(pw, out); err != nil { // B's browser is closed when it returns
		return err
	}
	after, err := waitForVersion(2)
	if err != nil {
		return err
	}
	out.After.VersionAfterB = &after.Version
	return nil
}

func main() {
	out := Output{ProfileNamePrefix: "case40-"}
	if err := run(&out); err != nil {
		log.Fatal(err)
	}
	b, _ := json.MarshalIndent(out, "", "  ")
	fmt.Println(string(b))
}
```

## What we got

| Storage | Session A wrote | Session B (profile) | Session B, newContext() |
| --- | --- | --- | --- |
| Cookie with an expiry ("Remember me") | yes | kept | empty |
| Session cookie (no expiry) | yes | kept | empty |
| localStorage | yes | kept | empty |
| sessionStorage | yes | gone | empty |
| IndexedDB | yes | kept | empty |
| Practice-site login (session cookie) | signed in | signed in |  |

From the Node.js run on 2026-10-12. 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)

```
{
  "profile_name_prefix": "case40-",
  "sessions": {
    "a": "0e01f4ee-9ba5-40ae-a96c-3e7452a7032c",
    "b": "00aa7cd4-661c-456a-89ae-8115ee41ace7"
  },
  "first": {
    "launch_ms": 890,
    "practice_login": true,
    "practice_cookies": [
      {
        "name": "PHPSESSID",
        "session": true
      }
    ],
    "practice_signed_in": true,
    "wrote": {
      "persistent_cookie": true,
      "session_cookie": true,
      "local_storage": true,
      "session_storage": true,
      "indexeddb": true
    }
  },
  "saved": {
    "version": 1,
    "bytes": 4922
  },
  "second": {
    "launch_ms": 646,
    "restored": {
      "persistent_cookie": true,
      "session_cookie": true,
      "local_storage": true,
      "session_storage": false,
      "indexeddb": true
    },
    "practice_signed_in": true
  },
  "new_context": {
    "persistent_cookie": false,
    "session_cookie": false,
    "local_storage": false,
    "session_storage": false,
    "indexeddb": false
  },
  "second_writer": {
    "status": 409,
    "error": "profile_in_use"
  },
  "readonly": [
    {
      "status": 200,
      "persistent_cookie": true,
      "local_storage": true,
      "indexeddb": true
    },
    {
      "status": 200,
      "persistent_cookie": true,
      "local_storage": true,
      "indexeddb": true
    },
    {
      "status": 200,
      "persistent_cookie": true,
      "local_storage": true,
      "indexeddb": true
    }
  ],
  "after": {
    "version_after_readonly": 1,
    "version_after_b": 2,
    "deleted": true
  }
}
```

## Takeaways

-   **What came back:** the cookie with an expiry, the session cookie, localStorage and the IndexedDB record — in every run and every language. Session cookies are kept too: the profile captures them before the browser closes and puts them back on restore, which a plain browser restart wouldn't do.
-   **Logins in session cookies survive.** The practice site keeps its login in `PHPSESSID`, a cookie with no expiry; session B opened the site already signed in, past the TOTP second factor — in all five runs. The login lasts as long as the site keeps the server-side session; when it ends it, you log in again and the next save keeps the new one.
-   **What didn't come back: sessionStorage.** It belongs to a tab, not to the browser, and isn't part of a profile. Keep anything that must outlive the session in localStorage, cookies or IndexedDB.
-   **Work in `browser.contexts()[0]`.** A `newContext()` in the same session started empty — no cookies, no localStorage, no IndexedDB — and, per [the docs](https://cdpfleet.com/docs/profiles), nothing in it is saved. This is the mistake to avoid when moving existing code to profiles. (Go: playwright-go 0.6000's `WaitForURL` panics on pages in `contexts()[0]`, a context the client didn't create, so the Go program polls `page.URL()` instead.)
-   **One writer, many readers.** While session B held the profile, a second normal launch got `409 profile_in_use`; three read-only launches all got the saved state, and the profile stayed at version 1 until B ended and saved version 2.
-   **Small and quick:** the saved profile was 4.9–5.0 KB across the five runs ([caches are left out](https://cdpfleet.com/docs/profiles)), and a launch with a profile — new or restored — took 0.6–1.1 s from the launch call to a connected page (one outlier: 3.8 s).
-   Profiles or `storageState()`? Both now keep session cookies. Profiles keep the state on cdpfleet and need no file handling; `storageState()` ([TOTP login](https://cdpfleet.com/docs/cases/totp-login), [persisting logins](https://cdpfleet.com/docs/cases/persist-login)) keeps it in your hands and works on every browser.

---
Source: https://cdpfleet.com/docs/cases/persistent-profiles · Markdown: https://cdpfleet.com/docs/cases/persistent-profiles.md · All docs for AI tools: https://cdpfleet.com/llms.txt
