cdpfleet Docs Sign up free

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.

Chromium

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

WhatWhy
"keep_alive": trueA 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 pagesThe "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 reconnectingOver wsUrl the contexts a client created are listed again, with their pages.
GET /v1/me/threadsWhether the session is listed while nobody is attached, and when it stops being listed.
GET /v1/me/sessions and /v1/me/usageThe session's record (end reason, duration, weight) and the account's thread-seconds before, during the wait and after.

How it works

  1. Launch headless Chrome with keep_alive and a 30-second inactivity_timeout on a dedicated key.
  2. 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.
  3. Disconnect. 3 s later, look the session up in /v1/me/threads; reconnect 15 s after the disconnect.
  4. Count the contexts and pages the new client sees, read the cookie and localStorage, then click on to page 3.
  5. Disconnect for good. Poll /v1/me/threads until 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:

// 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));

What we got

LanguageAfter the reconnectReconnectWhile detachedAfter the client leftBilledMeasured
node2 contexts, 3 pages · cookie kept · localStorage kept · went on to page-3.html22 ms after 15.1 s awaylisted in /v1/me/threads (running 13 s)inactivity_timeout; unlisted after 61.1 s; reconnect then: 404 Not Found30 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