Examples & benchmarks / #78 · 2026-10-30 · Example · Medium · for ai agents
keep_alive: an agent run that survives its client going away
An agent works through a catalogue in a headless Chrome session with `keep_alive`, then its client disconnects. A new client reconnects to the same session and carries on. What survives, how fast the reconnect is, how the session looks while nobody is attached, when it ends, and what is billed.
Run on production on 2026-10-30: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
An agent that thinks between steps, a worker that gets redeployed, a laptop that loses Wi-Fi: each drops its connection, and without keep_alive a dropped connection ends the browser and everything in it. With keep_alive (dedicated threads) the session waits for a client to come back. This case runs a small agent workflow through a disconnect and a reconnect and measures what a second client finds, then lets the session run out to see when it ends and what the account is billed.
What we used, and why
| What | Why |
|---|---|
"keep_alive": true | A disconnect detaches the client instead of ending the session; browser.close() only disconnects. |
"inactivity_timeout": "30s" | How long the session waits without a client (or any other traffic) before it ends. Short here so the run stays brief; up to 24 h on dedicated threads. |
| Two contexts, three pages | The "agent" context holds a catalogue page it paged through, a book page, a cookie and a localStorage entry; a second context holds one more page. |
browser.contexts() after reconnecting | Over wsUrl the contexts a client created are listed again, with their pages. |
GET /v1/me/threads | Whether the session is listed while nobody is attached, and when it stops being listed. |
GET /v1/me/sessions and /v1/me/usage | The session's record (end reason, duration, weight) and the account's thread-seconds before, during the wait and after. |
How it works
- Launch headless Chrome with
keep_aliveand a 30-secondinactivity_timeouton a dedicated key. - Connect, page from catalogue page 1 to page 2 in one context, set a cookie and a localStorage entry, open a book page in the same context and a category page in a second context.
- Disconnect. 3 s later, look the session up in
/v1/me/threads; reconnect 15 s after the disconnect. - Count the contexts and pages the new client sees, read the cookie and localStorage, then click on to page 3.
- Disconnect for good. Poll
/v1/me/threadsuntil the session is gone, try to reconnect, and read the session's record and the account's usage.
The code
The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:
CDPFLEET_DEDICATED_API_KEY— an API key of an account with dedicated threads (dashboard → API keys)PROXY_URL— your proxy, e.g.http://user:[email protected]:8000
// npm install [email protected]
// env: CDPFLEET_DEDICATED_API_KEY (keep_alive needs dedicated threads), PROXY_URL
import { chromium } from 'playwright';
const KEY = process.env.CDPFLEET_DEDICATED_API_KEY;
const PROXY = process.env.PROXY_URL;
const STARTER = 'https://starter.cdpfleet.com';
const API = 'https://cdpfleet.com';
const IDLE_S = 30; // inactivity_timeout: how long the session waits for a client
const SITE = 'https://books.toscrape.com';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const secs = (a, b) => Math.round((b - a) / 100) / 10;
const headers = { 'x-api-key': KEY };
const api = (path) => fetch(`${API}${path}`, { headers }).then((r) => r.json());
// The session as the account API sees it while it runs: listed in /v1/me/threads or not.
const listed = async (id) => (await api('/v1/me/threads')).threads.find((t) => t.session_id === id) || null;
// A shared proxy drops the odd connection: one retry.
const goto = (page, url) => page.goto(url, { timeout: 45000 }).catch(() => page.goto(url, { timeout: 45000 }));
// Today's thread-seconds on the account, closed and still running.
const usage = async () => { const u = await api('/v1/me/usage'); return { closed: u.closed_thread_seconds, live: u.live_thread_seconds }; };
const usageBefore = await usage();
// 1. Launch with keep_alive: a disconnect no longer ends the session.
const t0 = Date.now();
const res = await fetch(`${STARTER}/chromium/session`, {
method: 'POST',
headers: { ...headers, 'content-type': 'application/json' },
body: JSON.stringify({ proxy: PROXY, headless: 'new', keep_alive: true, inactivity_timeout: `${IDLE_S}s` }),
});
if (!res.ok) throw new Error(`launch failed: ${res.status} ${await res.text()}`);
const { sessionId, wsUrl } = await res.json();
// 2. First client: an agent works through the catalogue in one context, a second context on the side.
let browser = await chromium.connect(wsUrl, { headers });
const work = await browser.newContext();
const page = await work.newPage();
await goto(page, `${SITE}/catalogue/page-1.html`);
await page.click('li.next a'); // step 2 of the run: page 2
await work.addCookies([{ name: 'agent_step', value: '2', domain: 'books.toscrape.com', path: '/' }]);
await page.evaluate(() => localStorage.setItem('agent_state', JSON.stringify({ seen: 40, next: 'page-3' })));
const detail = await work.newPage();
await goto(detail, `${SITE}/catalogue/a-light-in-the-attic_1000/index.html`);
const side = await browser.newContext();
await goto(await side.newPage(), `${SITE}/catalogue/category/books/poetry_23/index.html`);
const before = { contexts: browser.contexts().length, pages: browser.contexts().reduce((n, c) => n + c.pages().length, 0), url: page.url() };
// 3. The client goes away. With keep_alive, close() only disconnects.
await browser.close();
const tDetach = Date.now();
await sleep(3000);
const whileDetached = await listed(sessionId);
await sleep(12000);
// 4. A new client reconnects to the same wsUrl and carries on where the first one stopped.
const tReconnect = Date.now();
browser = await chromium.connect(wsUrl, { headers });
const reconnectMs = Date.now() - tReconnect;
const detachedS = secs(tDetach, tReconnect);
const contexts = browser.contexts();
const all = contexts.flatMap((c) => c.pages().map((p) => ({ c, p })));
const back = all.find(({ p }) => p.url().includes('page-2.html'));
const after = {
contexts: contexts.length,
pages: all.length,
urls: all.map(({ p }) => p.url().replace(SITE, '')).sort(),
cookie: back ? (await back.c.cookies(SITE)).find((c) => c.name === 'agent_step')?.value ?? null : null,
local_storage: back ? await back.p.evaluate(() => localStorage.getItem('agent_state')) : null,
};
let continued = null;
if (back) {
await back.p.click('li.next a');
await back.p.waitForURL('**/page-3.html');
continued = back.p.url().replace(SITE, '');
}
// 5. The client leaves for good: the session waits inactivity_timeout, then ends.
await browser.close();
const tLeave = Date.now();
let goneAfterS = null;
let usageWaiting = null;
while (Date.now() - tLeave < (IDLE_S + 90) * 1000) {
await sleep(2000);
if (!usageWaiting && Date.now() - tLeave >= 20000) usageWaiting = await usage();
if (!(await listed(sessionId))) { goneAfterS = secs(tLeave, Date.now()); break; }
}
let lateReconnect;
try {
const b = await chromium.connect(wsUrl, { headers, timeout: 20000 });
lateReconnect = { connected: true, contexts: b.contexts().length };
await b.close();
} catch (err) {
lateReconnect = { connected: false, error: err.message.split('\n')[0].slice(0, 160) };
}
// 6. What was billed: the session's record in the account history.
await sleep(5000);
const { sessions } = await api('/v1/me/sessions?limit=20');
const s = sessions.find((x) => x.session_id === sessionId);
const usageAfter = await usage();
console.log(JSON.stringify({
language: 'node',
inactivity_timeout_s: IDLE_S,
before,
detached: {
listed: Boolean(whileDetached),
running_seconds: whileDetached?.running_seconds ?? null,
seconds: detachedS,
},
reconnect: { ms: reconnectMs, ...after, continued },
end: {
gone_after_s: goneAfterS,
late_reconnect: lateReconnect,
end_reason: s?.end_reason ?? null,
duration_seconds: s?.duration_seconds ?? null,
weight: s?.weight ?? null,
client_seconds: secs(t0, tLeave),
},
usage: {
live_while_waiting: usageWaiting ? usageWaiting.live : null,
billed: usageAfter.closed - usageBefore.closed,
},
measured_at: new Date().toISOString().slice(0, 10),
}, null, 2));
# pip install playwright==1.60.0 requests
# env: CDPFLEET_DEDICATED_API_KEY (keep_alive needs dedicated threads), PROXY_URL
import json
import os
import time
from datetime import date
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_DEDICATED_API_KEY"]
PROXY = os.environ["PROXY_URL"]
STARTER = "https://starter.cdpfleet.com"
API = "https://cdpfleet.com"
IDLE_S = 30 # inactivity_timeout: how long the session waits for a client
SITE = "https://books.toscrape.com"
HEADERS = {"x-api-key": KEY}
def api(path):
return requests.get(f"{API}{path}", headers=HEADERS, timeout=30).json()
def listed(sid):
# The session as the account API sees it while it runs: listed in /v1/me/threads or not.
return next((t for t in api("/v1/me/threads")["threads"] if t["session_id"] == sid), None)
def usage():
# Today's thread-seconds on the account, closed and still running.
u = api("/v1/me/usage")
return {"closed": u["closed_thread_seconds"], "live": u["live_thread_seconds"]}
def goto(page, url):
try:
page.goto(url, timeout=45000)
except Exception: # a shared proxy drops the odd connection: one retry
page.goto(url, timeout=45000)
def secs(a, b):
return round(b - a, 1)
usage_before = usage()
# 1. Launch with keep_alive: a disconnect no longer ends the session.
t0 = time.time()
res = requests.post(f"{STARTER}/chromium/session", headers=HEADERS, timeout=90,
json={"proxy": PROXY, "headless": "new", "keep_alive": True, "inactivity_timeout": f"{IDLE_S}s"})
if not res.ok:
raise SystemExit(f"launch failed: {res.status_code} {res.text}")
session = res.json()
sid, ws_url = session["sessionId"], session["wsUrl"]
with sync_playwright() as pw:
# 2. First client: an agent works through the catalogue in one context, a second context on the side.
browser = pw.chromium.connect(ws_url, headers=HEADERS)
work = browser.new_context()
page = work.new_page()
goto(page, f"{SITE}/catalogue/page-1.html")
page.click("li.next a") # step 2 of the run: page 2
work.add_cookies([{"name": "agent_step", "value": "2", "domain": "books.toscrape.com", "path": "/"}])
page.evaluate("() => localStorage.setItem('agent_state', JSON.stringify({ seen: 40, next: 'page-3' }))")
detail = work.new_page()
goto(detail, f"{SITE}/catalogue/a-light-in-the-attic_1000/index.html")
side = browser.new_context()
goto(side.new_page(), f"{SITE}/catalogue/category/books/poetry_23/index.html")
before = {"contexts": len(browser.contexts), "pages": sum(len(c.pages) for c in browser.contexts), "url": page.url}
# 3. The client goes away. With keep_alive, close() only disconnects.
browser.close()
t_detach = time.time()
time.sleep(3)
while_detached = listed(sid)
time.sleep(12)
# 4. A new client reconnects to the same wsUrl and carries on where the first one stopped.
t_reconnect = time.time()
browser = pw.chromium.connect(ws_url, headers=HEADERS)
reconnect_ms = round((time.time() - t_reconnect) * 1000)
detached_s = secs(t_detach, t_reconnect)
contexts = browser.contexts
pages = [(c, p) for c in contexts for p in c.pages]
back = next(((c, p) for c, p in pages if "page-2.html" in p.url), None)
after = {
"contexts": len(contexts),
"pages": len(pages),
"urls": sorted(p.url.replace(SITE, "") for _, p in pages),
"cookie": next((c["value"] for c in back[0].cookies(SITE) if c["name"] == "agent_step"), None) if back else None,
"local_storage": back[1].evaluate("() => localStorage.getItem('agent_state')") if back else None,
}
continued = None
if back:
back[1].click("li.next a")
back[1].wait_for_url("**/page-3.html")
continued = back[1].url.replace(SITE, "")
# 5. The client leaves for good: the session waits inactivity_timeout, then ends.
browser.close()
t_leave = time.time()
gone_after_s = None
usage_waiting = None
while time.time() - t_leave < IDLE_S + 90:
time.sleep(2)
if usage_waiting is None and time.time() - t_leave >= 20:
usage_waiting = usage()
if not listed(sid):
gone_after_s = secs(t_leave, time.time())
break
try:
b = pw.chromium.connect(ws_url, headers=HEADERS, timeout=20000)
late_reconnect = {"connected": True, "contexts": len(b.contexts)}
b.close()
except Exception as err:
late_reconnect = {"connected": False, "error": str(err).split("\n")[0][:160]}
# 6. What was billed: the session's record in the account history.
time.sleep(5)
s = next((x for x in api("/v1/me/sessions?limit=20")["sessions"] if x["session_id"] == sid), {})
usage_after = usage()
print(json.dumps({
"language": "python",
"inactivity_timeout_s": IDLE_S,
"before": before,
"detached": {"listed": bool(while_detached), "running_seconds": (while_detached or {}).get("running_seconds"), "seconds": detached_s},
"reconnect": {"ms": reconnect_ms, **after, "continued": continued},
"end": {
"gone_after_s": gone_after_s,
"late_reconnect": late_reconnect,
"end_reason": s.get("end_reason"),
"duration_seconds": s.get("duration_seconds"),
"weight": s.get("weight"),
"client_seconds": secs(t0, t_leave),
},
"usage": {"live_while_waiting": usage_waiting["live"] if usage_waiting else None, "billed": usage_after["closed"] - usage_before["closed"]},
"measured_at": date.today().isoformat(),
}, indent=2))
// 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_DEDICATED_API_KEY (keep_alive needs dedicated threads), 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.time.LocalDate;
import java.util.*;
public class Main {
static final String KEY = System.getenv("CDPFLEET_DEDICATED_API_KEY");
static final String PROXY = System.getenv("PROXY_URL");
static final String STARTER = "https://starter.cdpfleet.com";
static final String API = "https://cdpfleet.com";
static final int IDLE_S = 30; // inactivity_timeout: how long the session waits for a client
static final String SITE = "https://books.toscrape.com";
static final HttpClient HTTP = HttpClient.newHttpClient();
static final Gson GSON = new GsonBuilder().serializeNulls().setPrettyPrinting().disableHtmlEscaping().create();
static final Map<String, String> HEADERS = Map.of("x-api-key", KEY);
static JsonObject api(String path) throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(API + path)).header("x-api-key", KEY).build();
return JsonParser.parseString(HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body()).getAsJsonObject();
}
// The session as the account API sees it while it runs: listed in /v1/me/threads or not.
static JsonObject listed(String sid) throws Exception {
for (JsonElement t : api("/v1/me/threads").getAsJsonArray("threads"))
if (t.getAsJsonObject().get("session_id").getAsString().equals(sid)) return t.getAsJsonObject();
return null;
}
// Today's thread-seconds on the account: { closed, live }.
static int[] usage() throws Exception {
JsonObject u = api("/v1/me/usage");
return new int[] { u.get("closed_thread_seconds").getAsInt(), u.get("live_thread_seconds").getAsInt() };
}
// A shared proxy drops the odd connection: one retry.
static void go(Page page, String url) {
try { page.navigate(url, new Page.NavigateOptions().setTimeout(45000)); }
catch (PlaywrightException e) { page.navigate(url, new Page.NavigateOptions().setTimeout(45000)); }
}
static double secs(long a, long b) { return Math.round((b - a) / 100.0) / 10.0; }
public static void main(String[] args) throws Exception {
int closedBefore = usage()[0];
// 1. Launch with keep_alive: a disconnect no longer ends the session.
long t0 = System.currentTimeMillis();
JsonObject body = new JsonObject();
body.addProperty("proxy", PROXY);
body.addProperty("headless", "new");
body.addProperty("keep_alive", true);
body.addProperty("inactivity_timeout", IDLE_S + "s");
HttpRequest launch = HttpRequest.newBuilder(URI.create(STARTER + "/chromium/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toString())).build();
HttpResponse<String> res = HTTP.send(launch, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new RuntimeException("launch failed: " + res.statusCode() + " " + res.body());
JsonObject session = JsonParser.parseString(res.body()).getAsJsonObject();
String sid = session.get("sessionId").getAsString();
String wsUrl = session.get("wsUrl").getAsString();
JsonObject out = new JsonObject();
out.addProperty("language", "java");
out.addProperty("inactivity_timeout_s", IDLE_S);
try (Playwright pw = Playwright.create()) {
BrowserType.ConnectOptions opts = new BrowserType.ConnectOptions().setHeaders(HEADERS);
// 2. First client: an agent works through the catalogue in one context, a second context on the side.
Browser browser = pw.chromium().connect(wsUrl, opts);
BrowserContext work = browser.newContext();
Page page = work.newPage();
go(page, SITE + "/catalogue/page-1.html");
page.click("li.next a"); // step 2 of the run: page 2
work.addCookies(List.of(new Cookie("agent_step", "2").setDomain("books.toscrape.com").setPath("/")));
page.evaluate("() => localStorage.setItem('agent_state', JSON.stringify({ seen: 40, next: 'page-3' }))");
go(work.newPage(), SITE + "/catalogue/a-light-in-the-attic_1000/index.html");
BrowserContext side = browser.newContext();
go(side.newPage(), SITE + "/catalogue/category/books/poetry_23/index.html");
JsonObject before = new JsonObject();
before.addProperty("contexts", browser.contexts().size());
before.addProperty("pages", browser.contexts().stream().mapToInt(c -> c.pages().size()).sum());
before.addProperty("url", page.url());
out.add("before", before);
// 3. The client goes away. With keep_alive, close() only disconnects.
browser.close();
long tDetach = System.currentTimeMillis();
Thread.sleep(3000);
JsonObject whileDetached = listed(sid);
Thread.sleep(12000);
// 4. A new client reconnects to the same wsUrl and carries on where the first one stopped.
long tReconnect = System.currentTimeMillis();
browser = pw.chromium().connect(wsUrl, opts);
long reconnectMs = System.currentTimeMillis() - tReconnect;
JsonObject detached = new JsonObject();
detached.addProperty("listed", whileDetached != null);
detached.add("running_seconds", whileDetached == null ? JsonNull.INSTANCE : whileDetached.get("running_seconds"));
detached.addProperty("seconds", secs(tDetach, tReconnect));
out.add("detached", detached);
List<BrowserContext> contexts = browser.contexts();
List<String> urls = new ArrayList<>();
BrowserContext backCtx = null;
Page back = null;
int pages = 0;
for (BrowserContext c : contexts) for (Page p : c.pages()) {
pages++;
urls.add(p.url().replace(SITE, ""));
if (p.url().contains("page-2.html")) { backCtx = c; back = p; }
}
Collections.sort(urls);
JsonObject reconnect = new JsonObject();
reconnect.addProperty("ms", reconnectMs);
reconnect.addProperty("contexts", contexts.size());
reconnect.addProperty("pages", pages);
reconnect.add("urls", GSON.toJsonTree(urls));
String cookie = null, local = null, continued = null;
if (back != null) {
for (Cookie c : backCtx.cookies(SITE)) if (c.name.equals("agent_step")) cookie = c.value;
local = (String) back.evaluate("() => localStorage.getItem('agent_state')");
back.click("li.next a");
back.waitForURL("**/page-3.html");
continued = back.url().replace(SITE, "");
}
reconnect.addProperty("cookie", cookie);
reconnect.addProperty("local_storage", local);
reconnect.addProperty("continued", continued);
out.add("reconnect", reconnect);
// 5. The client leaves for good: the session waits inactivity_timeout, then ends.
browser.close();
long tLeave = System.currentTimeMillis();
Double goneAfter = null;
Integer liveWaiting = null;
while (System.currentTimeMillis() - tLeave < (IDLE_S + 90) * 1000L) {
Thread.sleep(2000);
if (liveWaiting == null && System.currentTimeMillis() - tLeave >= 20000) liveWaiting = usage()[1];
if (listed(sid) == null) { goneAfter = secs(tLeave, System.currentTimeMillis()); break; }
}
JsonObject late = new JsonObject();
try {
Browser b = pw.chromium().connect(wsUrl, new BrowserType.ConnectOptions().setHeaders(HEADERS).setTimeout(20000));
late.addProperty("connected", true);
late.addProperty("contexts", b.contexts().size());
b.close();
} catch (PlaywrightException e) {
String msg = e.getMessage().split("\n")[0];
late.addProperty("connected", false);
late.addProperty("error", msg.substring(0, Math.min(160, msg.length())));
}
// 6. What was billed: the session's record in the account history.
Thread.sleep(5000);
JsonObject s = new JsonObject();
for (JsonElement x : api("/v1/me/sessions?limit=20").getAsJsonArray("sessions"))
if (x.getAsJsonObject().get("session_id").getAsString().equals(sid)) s = x.getAsJsonObject();
int closedAfter = usage()[0];
JsonObject end = new JsonObject();
end.addProperty("gone_after_s", goneAfter);
end.add("late_reconnect", late);
end.add("end_reason", s.has("end_reason") ? s.get("end_reason") : JsonNull.INSTANCE);
end.add("duration_seconds", s.has("duration_seconds") ? s.get("duration_seconds") : JsonNull.INSTANCE);
end.add("weight", s.has("weight") ? s.get("weight") : JsonNull.INSTANCE);
end.addProperty("client_seconds", secs(t0, tLeave));
out.add("end", end);
JsonObject use = new JsonObject();
use.addProperty("live_while_waiting", liveWaiting);
use.addProperty("billed", closedAfter - closedBefore);
out.add("usage", use);
}
out.addProperty("measured_at", LocalDate.now().toString());
System.out.println(GSON.toJson(out));
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_DEDICATED_API_KEY (keep_alive needs dedicated threads), PROXY_URL
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;
var key = Environment.GetEnvironmentVariable("CDPFLEET_DEDICATED_API_KEY")!;
var proxy = Environment.GetEnvironmentVariable("PROXY_URL")!;
const string Starter = "https://starter.cdpfleet.com";
const string Api = "https://cdpfleet.com";
const int IdleS = 30; // inactivity_timeout: how long the session waits for a client
const string Site = "https://books.toscrape.com";
var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
var headers = new Dictionary<string, string> { ["x-api-key"] = key };
async Task<JsonObject> ApiGet(string path) => JsonNode.Parse(await http.GetStringAsync($"{Api}{path}"))!.AsObject();
// The session as the account API sees it while it runs: listed in /v1/me/threads or not.
async Task<JsonObject?> Listed(string sid) =>
(await ApiGet("/v1/me/threads"))["threads"]!.AsArray().Select(t => t!.AsObject()).FirstOrDefault(t => (string?)t["session_id"] == sid);
// Today's thread-seconds on the account, closed and still running.
async Task<(int Closed, int Live)> Usage()
{
var u = await ApiGet("/v1/me/usage");
return ((int)u["closed_thread_seconds"]!, (int)u["live_thread_seconds"]!);
}
// A shared proxy drops the odd connection: one retry.
async Task Go(IPage page, string url)
{
try { await page.GotoAsync(url, new() { Timeout = 45000 }); }
catch (PlaywrightException) { await page.GotoAsync(url, new() { Timeout = 45000 }); }
}
double Secs(DateTime a, DateTime b) => Math.Round((b - a).TotalSeconds, 1);
var closedBefore = (await Usage()).Closed;
// 1. Launch with keep_alive: a disconnect no longer ends the session.
var t0 = DateTime.UtcNow;
using var res = await http.PostAsJsonAsync($"{Starter}/chromium/session",
new { proxy, headless = "new", keep_alive = true, inactivity_timeout = $"{IdleS}s" });
var resBody = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode) throw new Exception($"launch failed: {(int)res.StatusCode} {resBody}");
var session = JsonNode.Parse(resBody)!.AsObject();
var sid = (string)session["sessionId"]!;
var wsUrl = (string)session["wsUrl"]!;
using var pw = await Playwright.CreateAsync();
var opts = new BrowserTypeConnectOptions { Headers = headers };
// 2. First client: an agent works through the catalogue in one context, a second context on the side.
var browser = await pw.Chromium.ConnectAsync(wsUrl, opts);
var work = await browser.NewContextAsync();
var page = await work.NewPageAsync();
await Go(page, $"{Site}/catalogue/page-1.html");
await page.ClickAsync("li.next a"); // step 2 of the run: page 2
await work.AddCookiesAsync(new[] { new Cookie { Name = "agent_step", Value = "2", Domain = "books.toscrape.com", Path = "/" } });
await page.EvaluateAsync("() => localStorage.setItem('agent_state', JSON.stringify({ seen: 40, next: 'page-3' }))");
await Go(await work.NewPageAsync(), $"{Site}/catalogue/a-light-in-the-attic_1000/index.html");
var side = await browser.NewContextAsync();
await Go(await side.NewPageAsync(), $"{Site}/catalogue/category/books/poetry_23/index.html");
var before = new JsonObject { ["contexts"] = browser.Contexts.Count, ["pages"] = browser.Contexts.Sum(c => c.Pages.Count), ["url"] = page.Url };
// 3. The client goes away. With keep_alive, CloseAsync() only disconnects.
await browser.CloseAsync();
var tDetach = DateTime.UtcNow;
await Task.Delay(3000);
var whileDetached = await Listed(sid);
await Task.Delay(12000);
// 4. A new client reconnects to the same wsUrl and carries on where the first one stopped.
var tReconnect = DateTime.UtcNow;
browser = await pw.Chromium.ConnectAsync(wsUrl, opts);
var reconnectMs = (long)(DateTime.UtcNow - tReconnect).TotalMilliseconds;
var detachedS = Secs(tDetach, tReconnect);
var contexts = browser.Contexts;
var all = contexts.SelectMany(c => c.Pages.Select(p => (c, p))).ToList();
var back = all.FirstOrDefault(x => x.p.Url.Contains("page-2.html"));
var urls = all.Select(x => x.p.Url.Replace(Site, "")).OrderBy(u => u, StringComparer.Ordinal).ToList();
string? cookie = null, local = null, continued = null;
if (back.p != null)
{
cookie = (await back.c.CookiesAsync(new[] { Site })).FirstOrDefault(c => c.Name == "agent_step")?.Value;
local = await back.p.EvaluateAsync<string?>("() => localStorage.getItem('agent_state')");
await back.p.ClickAsync("li.next a");
await back.p.WaitForURLAsync("**/page-3.html");
continued = back.p.Url.Replace(Site, "");
}
var reconnect = new JsonObject
{
["ms"] = reconnectMs,
["contexts"] = contexts.Count,
["pages"] = all.Count,
["urls"] = new JsonArray(urls.Select(u => (JsonNode)u).ToArray()),
["cookie"] = cookie,
["local_storage"] = local,
["continued"] = continued,
};
// 5. The client leaves for good: the session waits inactivity_timeout, then ends.
await browser.CloseAsync();
var tLeave = DateTime.UtcNow;
double? goneAfter = null;
int? liveWaiting = null;
while ((DateTime.UtcNow - tLeave).TotalSeconds < IdleS + 90)
{
await Task.Delay(2000);
if (liveWaiting == null && (DateTime.UtcNow - tLeave).TotalSeconds >= 20) liveWaiting = (await Usage()).Live;
if (await Listed(sid) == null) { goneAfter = Secs(tLeave, DateTime.UtcNow); break; }
}
JsonObject late;
try
{
var b = await pw.Chromium.ConnectAsync(wsUrl, new BrowserTypeConnectOptions { Headers = headers, Timeout = 20000 });
late = new JsonObject { ["connected"] = true, ["contexts"] = b.Contexts.Count };
await b.CloseAsync();
}
catch (PlaywrightException e)
{
var msg = e.Message.Split('\n')[0];
late = new JsonObject { ["connected"] = false, ["error"] = msg[..Math.Min(160, msg.Length)] };
}
// 6. What was billed: the session's record in the account history.
await Task.Delay(5000);
var s = (await ApiGet("/v1/me/sessions?limit=20"))["sessions"]!.AsArray().Select(x => x!.AsObject()).FirstOrDefault(x => (string?)x["session_id"] == sid) ?? new JsonObject();
var closedAfter = (await Usage()).Closed;
var output = new JsonObject
{
["language"] = "csharp",
["inactivity_timeout_s"] = IdleS,
["before"] = before,
["detached"] = new JsonObject { ["listed"] = whileDetached != null, ["running_seconds"] = whileDetached?["running_seconds"]?.DeepClone(), ["seconds"] = detachedS },
["reconnect"] = reconnect,
["end"] = new JsonObject
{
["gone_after_s"] = goneAfter,
["late_reconnect"] = late,
["end_reason"] = s["end_reason"]?.DeepClone(),
["duration_seconds"] = s["duration_seconds"]?.DeepClone(),
["weight"] = s["weight"]?.DeepClone(),
["client_seconds"] = Secs(t0, tLeave),
},
["usage"] = new JsonObject { ["live_while_waiting"] = liveWaiting, ["billed"] = closedAfter - closedBefore },
["measured_at"] = DateTime.UtcNow.ToString("yyyy-MM-dd"),
};
Console.WriteLine(output.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
// 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_DEDICATED_API_KEY (keep_alive needs dedicated threads), PROXY_URL
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"os"
"sort"
"strings"
"time"
"github.com/playwright-community/playwright-go"
)
const starter = "https://starter.cdpfleet.com"
const api = "https://cdpfleet.com"
const idleS = 30 // inactivity_timeout: how long the session waits for a client
const site = "https://books.toscrape.com"
var key = os.Getenv("CDPFLEET_DEDICATED_API_KEY")
var proxy = os.Getenv("PROXY_URL")
var headers = map[string]string{"x-api-key": key}
func must[T any](v T, err error) T {
if err != nil {
log.Fatal(err)
}
return v
}
func check(err error) {
if err != nil {
log.Fatal(err)
}
}
func getJSON(path string, out any) {
req, _ := http.NewRequest("GET", api+path, nil)
req.Header.Set("x-api-key", key)
res := must(http.DefaultClient.Do(req))
defer res.Body.Close()
check(json.NewDecoder(res.Body).Decode(out))
}
type thread struct {
SessionID string `json:"session_id"`
RunningSeconds *int `json:"running_seconds"`
}
// The session as the account API sees it while it runs: listed in /v1/me/threads or not.
func listed(sid string) *thread {
var r struct {
Threads []thread `json:"threads"`
}
getJSON("/v1/me/threads", &r)
for _, t := range r.Threads {
if t.SessionID == sid {
return &t
}
}
return nil
}
// Today's thread-seconds on the account, closed and still running.
func usage() (closed, live int) {
var u struct {
Closed int `json:"closed_thread_seconds"`
Live int `json:"live_thread_seconds"`
}
getJSON("/v1/me/usage", &u)
return u.Closed, u.Live
}
// A shared proxy drops the odd connection: one retry.
func gotoURL(page playwright.Page, url string) {
if _, err := page.Goto(url, playwright.PageGotoOptions{Timeout: playwright.Float(45000)}); err != nil {
must(page.Goto(url, playwright.PageGotoOptions{Timeout: playwright.Float(45000)}))
}
}
func secs(a, b time.Time) float64 { return math.Round(b.Sub(a).Seconds()*10) / 10 }
func main() {
pw := must(playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true}))
defer pw.Stop()
closedBefore, _ := usage()
// 1. Launch with keep_alive: a disconnect no longer ends the session.
t0 := time.Now()
body, _ := json.Marshal(map[string]any{"proxy": proxy, "headless": "new", "keep_alive": true, "inactivity_timeout": fmt.Sprintf("%ds", idleS)})
req, _ := http.NewRequest("POST", starter+"/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))
var session struct {
SessionID string `json:"sessionId"`
WsURL string `json:"wsUrl"`
}
check(json.NewDecoder(res.Body).Decode(&session))
res.Body.Close()
if res.StatusCode != 200 {
log.Fatalf("launch failed: %d", res.StatusCode)
}
sid, wsURL := session.SessionID, session.WsURL
opts := playwright.BrowserTypeConnectOptions{Headers: headers}
// 2. First client: an agent works through the catalogue in one context, a second context on the side.
browser := must(pw.Chromium.Connect(wsURL, opts))
work := must(browser.NewContext())
page := must(work.NewPage())
gotoURL(page, site+"/catalogue/page-1.html")
check(page.Locator("li.next a").Click()) // step 2 of the run: page 2
check(work.AddCookies([]playwright.OptionalCookie{{Name: "agent_step", Value: "2", Domain: playwright.String("books.toscrape.com"), Path: playwright.String("/")}}))
must(page.Evaluate("() => localStorage.setItem('agent_state', JSON.stringify({ seen: 40, next: 'page-3' }))"))
gotoURL(must(work.NewPage()), site+"/catalogue/a-light-in-the-attic_1000/index.html")
side := must(browser.NewContext())
gotoURL(must(side.NewPage()), site+"/catalogue/category/books/poetry_23/index.html")
pagesBefore := 0
for _, c := range browser.Contexts() {
pagesBefore += len(c.Pages())
}
before := map[string]any{"contexts": len(browser.Contexts()), "pages": pagesBefore, "url": page.URL()}
// 3. The client goes away. With keep_alive, Close() only disconnects.
check(browser.Close())
tDetach := time.Now()
time.Sleep(3 * time.Second)
whileDetached := listed(sid)
time.Sleep(12 * time.Second)
// 4. A new client reconnects to the same wsUrl and carries on where the first one stopped.
tReconnect := time.Now()
browser = must(pw.Chromium.Connect(wsURL, opts))
reconnectMs := time.Since(tReconnect).Milliseconds()
detachedS := secs(tDetach, tReconnect)
contexts := browser.Contexts()
var urls []string
var backCtx playwright.BrowserContext
var back playwright.Page
pages := 0
for _, c := range contexts {
for _, p := range c.Pages() {
pages++
urls = append(urls, strings.Replace(p.URL(), site, "", 1))
if strings.Contains(p.URL(), "page-2.html") {
backCtx, back = c, p
}
}
}
sort.Strings(urls)
var cookie, local, continued any
if back != nil {
for _, c := range must(backCtx.Cookies(site)) {
if c.Name == "agent_step" {
cookie = c.Value
}
}
local = must(back.Evaluate("() => localStorage.getItem('agent_state')"))
check(back.Locator("li.next a").Click())
// playwright-go 0.6000's WaitForURL panics on a context this client didn't create (it reads the
// context's launch options, which a reconnected client doesn't have), so poll the URL instead.
for i := 0; i < 150 && !strings.HasSuffix(back.URL(), "page-3.html"); i++ {
time.Sleep(100 * time.Millisecond)
}
continued = strings.Replace(back.URL(), site, "", 1)
}
// 5. The client leaves for good: the session waits inactivity_timeout, then ends.
check(browser.Close())
tLeave := time.Now()
var goneAfterS, liveWaiting any
for time.Since(tLeave) < (idleS+90)*time.Second {
time.Sleep(2 * time.Second)
if liveWaiting == nil && time.Since(tLeave) >= 20*time.Second {
_, live := usage()
liveWaiting = live
}
if listed(sid) == nil {
goneAfterS = secs(tLeave, time.Now())
break
}
}
late := map[string]any{}
if b, err := pw.Chromium.Connect(wsURL, playwright.BrowserTypeConnectOptions{Headers: headers, Timeout: playwright.Float(20000)}); err != nil {
msg := strings.Split(err.Error(), "\n")[0]
if len(msg) > 160 {
msg = msg[:160]
}
late = map[string]any{"connected": false, "error": msg}
} else {
late = map[string]any{"connected": true, "contexts": len(b.Contexts())}
b.Close()
}
// 6. What was billed: the session's record in the account history.
time.Sleep(5 * time.Second)
var hist struct {
Sessions []map[string]any `json:"sessions"`
}
getJSON("/v1/me/sessions?limit=20", &hist)
s := map[string]any{}
for _, x := range hist.Sessions {
if x["session_id"] == sid {
s = x
}
}
closedAfter, _ := usage()
var running any
if whileDetached != nil {
running = whileDetached.RunningSeconds
}
out := map[string]any{
"language": "go",
"inactivity_timeout_s": idleS,
"before": before,
"detached": map[string]any{"listed": whileDetached != nil, "running_seconds": running, "seconds": detachedS},
"reconnect": map[string]any{"ms": reconnectMs, "contexts": len(contexts), "pages": pages, "urls": urls,
"cookie": cookie, "local_storage": local, "continued": continued},
"end": map[string]any{"gone_after_s": goneAfterS, "late_reconnect": late, "end_reason": s["end_reason"],
"duration_seconds": s["duration_seconds"], "weight": s["weight"], "client_seconds": secs(t0, tLeave)},
"usage": map[string]any{"live_while_waiting": liveWaiting, "billed": closedAfter - closedBefore},
"measured_at": time.Now().UTC().Format("2006-01-02"),
}
b, _ := json.MarshalIndent(out, "", " ")
fmt.Println(string(b))
}
What we got
| Language | After the reconnect | Reconnect | While detached | After the client left | Billed | Measured |
|---|---|---|---|---|---|---|
| node | 2 contexts, 3 pages · cookie kept · localStorage kept · went on to page-3.html | 22 ms after 15.1 s away | listed in /v1/me/threads (running 13 s) | inactivity_timeout; unlisted after 61.1 s; reconnect then: 404 Not Found | 30 s × weight 1 (client connected or away for 29 s; live meter 49 s while it waited) | 2026-10-30 |
From the Node.js run on 2026-10-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)
{
"language": "node",
"inactivity_timeout_s": 30,
"before": {
"contexts": 2,
"pages": 3,
"url": "https://books.toscrape.com/catalogue/page-2.html"
},
"detached": {
"listed": true,
"running_seconds": 13,
"seconds": 15.1
},
"reconnect": {
"ms": 22,
"contexts": 2,
"pages": 3,
"urls": [
"/catalogue/a-light-in-the-attic_1000/index.html",
"/catalogue/category/books/poetry_23/index.html",
"/catalogue/page-2.html"
],
"cookie": "2",
"local_storage": "{\"seen\":40,\"next\":\"page-3\"}",
"continued": "/catalogue/page-3.html"
},
"end": {
"gone_after_s": 61.1,
"late_reconnect": {
"connected": false,
"error": "browserType.connect: WebSocket error: wss://router01.cdpfleet.com/session/a17fefc7-b8e3-4565-9958-6da37e567d03 404 Not Found"
},
"end_reason": "inactivity_timeout",
"duration_seconds": 30,
"weight": 1,
"client_seconds": 29
},
"usage": {
"live_while_waiting": 49,
"billed": 30
},
"measured_at": "2026-10-30"
}Takeaways
- The second client found everything the first one left. On 2026-10-30, in all five runs (one per language), the client that reconnected 15 s after the disconnect saw both contexts and all three pages at the URLs they were on, the cookie and the localStorage entry, and clicked on from catalogue page 2 to page 3 as if nothing had happened. Over
wsUrlthe second context survived too; overcdpUrlonly the default context does (Edge keep-alive recipe). - Reconnecting is fast.
connect()to the samewsUrlreturned in 19–194 ms — the browser is already running, so there is no launch to wait for. - A detached session is still a running session. 3 s after the disconnect it was listed in
/v1/me/threads, its running time counting on (11–18 s), and it held its thread the whole time. - Without a client it ends by
inactivity_timeout, and then it is gone for good. After the last disconnect the session ended withinactivity_timeout(30 s here) and left/v1/me/threads61–63 s after the client left: the timeout plus up to a minute before the router's next check of a detached session. A reconnect after that got404 Not Foundon thewsUrl; nothing of the browser is left to resume. End a session you are done with at once withDELETE /v1/me/threads/{sessionId}. - What was billed: launch to the last disconnect. Each session's record ran 29–39 s, within a second of the time from launch to the client's final disconnect, and the account's closed thread-seconds rose by exactly that. The 15 s away in the middle were billed; the final wait for the timeout was not, although
/v1/me/usagecounted it as live time (48–59 s) until the session ended. Measured with a 30 s timeout; setinactivity_timeoutto how long your agent may really be away. - In Go, don't call
WaitForURLon a reconnected page. playwright-go 0.6000 panics there (it reads launch options that only the creating client has); the Go program pollspage.URL()instead. Node, Python, Java and C# had no such issue.