Cases / #72 · 2026-10-27 · Easy
Stay under the tab and memory caps: reuse a few pages, close what you're done with
Every session has a tab cap and a memory cap. A new tab per URL runs into the first; one page that grows runs into the second. A small pool of reused tabs works through the same list with room to spare.
Run on production on 2026-10-27: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
A crawler that opens a new tab for every URL and never closes them works fine on a laptop until it doesn't. On cdpfleet each session has a tab cap and a memory cap that depend on the plan, so the same habit hits a wall sooner: what happens at the wall, and what pattern never gets near it?
What we used, and why
| What | Why |
|---|---|
GET /v1/me | Reads the plan's caps: subscription.features.max_pages and memory_mb (the shared plan used here: 10 tabs, 4,096 MB per session). |
context.newPage() in a loop | Opens tabs without closing any, until one is refused. |
| A pool of 4 pages | Works through 12 catalogue pages reusing the same four tabs: four at a time in Node, C# and Go; one at a time through the same tabs with Python's sync API and Java. |
performance.memory.usedJSHeapSize | Chromium's JavaScript heap per tab, after a page has loaded. |
GET /v1/me/sessions | The session's end_reason when the memory cap ends it. |
How it works
- Read the plan's caps from
/v1/me. - In one session, open tabs without closing them until the server refuses one; check the session still works.
- Close them, then open a pool of 4 tabs and load 12 catalogue pages through it, four at a time.
- Read the JavaScript heap of each pooled tab.
- In a second session, grow one page's memory in 128 MB steps: half the cap, then past it, and read how the session ended.
The code
The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:
CDPFLEET_API_KEY— your API key (dashboard → API keys)PROXY_URL— your proxy, e.g.http://user:[email protected]:8000
// npm install [email protected]
// env: CDPFLEET_API_KEY, PROXY_URL
import { chromium } from 'playwright';
const KEY = process.env.CDPFLEET_API_KEY;
const API = 'https://cdpfleet.com';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Twelve light, public catalogue pages: what a crawler works through.
const URLS = Array.from({ length: 12 }, (_, i) => `https://books.toscrape.com/catalogue/page-${i + 1}.html`);
async function me() {
const r = await fetch(`${API}/v1/me`, { headers: { 'x-api-key': KEY } });
return (await r.json()).subscription;
}
async function launch() {
const res = await fetch('https://starter.cdpfleet.com/chromium/session', {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify({ proxy: process.env.PROXY_URL, headless: 'new', record: false }),
});
if (!res.ok) throw new Error(`launch ${res.status} ${await res.text()}`);
const { sessionId, wsUrl } = await res.json();
return { sessionId, browser: await chromium.connect(wsUrl, { headers: { 'x-api-key': KEY } }) };
}
// How a session ended, from the account API (it appears a few seconds after the end).
async function endReason(sessionId) {
for (let i = 0; i < 30; i++) {
const r = await fetch(`${API}/v1/me/sessions?limit=50`, { headers: { 'x-api-key': KEY } });
const s = (await r.json()).sessions?.find((x) => x.session_id === sessionId);
if (s?.ended_at) return s.end_reason;
await sleep(3000);
}
return null;
}
async function load(page, url) {
for (let attempt = 1; ; attempt++) {
try { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 }); return true; } catch { if (attempt === 2) return false; }
}
}
const sub = await me();
const plan = { kind: sub.kind, max_pages: sub.features.max_pages, memory_mb: sub.features.memory_mb };
// 1 + 2: tabs. A new tab per URL, never closed, runs into the tab cap; a small pool of reused
// tabs works through the same list without ever getting near it.
const tabs = await launch();
let tabCap, pool, heapMb;
try {
const ctx = await tabs.browser.newContext();
const opened = [];
let closedAt = null;
for (let i = 0; i <= plan.max_pages; i++) {
// Over the cap the node closes the new tab at once: newPage() fails, or the tab is already closed.
const p = await ctx.newPage().catch(() => null);
if (p) await sleep(1500);
if (!p || p.isClosed()) { closedAt = i + 1; break; }
await p.setContent(`<title>tab ${i + 1}</title>`);
opened.push(p);
}
const alive = tabs.browser.isConnected() && (await opened[0].title()) === 'tab 1';
tabCap = { opened_before_cap: opened.length, closed_at_tab: closedAt, session_alive: alive };
for (const p of opened) await p.close();
const POOL = 4;
const pages = await Promise.all(Array.from({ length: POOL }, () => ctx.newPage()));
let loaded = 0;
for (let i = 0; i < URLS.length; i += POOL) {
const batch = await Promise.all(URLS.slice(i, i + POOL).map((u, j) => load(pages[j], u)));
loaded += batch.filter(Boolean).length;
}
// JS heap of a loaded catalogue page (Chromium's performance.memory), averaged over the pool.
const heaps = await Promise.all(pages.map((p) => p.evaluate(() => performance.memory?.usedJSHeapSize || 0)));
heapMb = Math.round(heaps.reduce((a, b) => a + b, 0) / heaps.length / 1048576 * 10) / 10;
pool = { pool_size: POOL, urls: URLS.length, loaded, open_tabs: ctx.pages().length };
for (const p of pages) await p.close();
} finally {
await tabs.browser.close();
}
// 3: memory. Half the cap is fine; growing a page past the cap ends the session.
const mem = await launch();
let memory;
try {
const page = await mem.browser.newPage();
await page.evaluate(() => { window.hog = []; });
let mb = 0, halfOk = false, cut = false;
try {
while (mb < plan.memory_mb + 1024) {
await page.evaluate(() => { const a = new Uint8Array(128 << 20); for (let i = 0; i < a.length; i += 4096) a[i] = 1; window.hog.push(a); });
mb += 128;
if (mb >= plan.memory_mb / 2 && !halfOk) halfOk = mem.browser.isConnected();
}
} catch { cut = true; }
memory = { half_cap_ok: halfOk, cut, allocated_mb_at_cut: mb };
} finally {
await mem.browser.close().catch(() => {});
}
memory.end_reason = await endReason(mem.sessionId);
console.log(JSON.stringify({ measured_at: new Date().toISOString().slice(0, 10), plan, tab_cap: tabCap, pool, js_heap_mb_per_tab: heapMb, memory }, null, 2));
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, 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_API_KEY"]
API = "https://cdpfleet.com"
# Twelve light, public catalogue pages: what a crawler works through.
URLS = [f"https://books.toscrape.com/catalogue/page-{i + 1}.html" for i in range(12)]
POOL = 4
def launch(p):
res = requests.post("https://starter.cdpfleet.com/chromium/session",
headers={"x-api-key": KEY},
json={"proxy": os.environ["PROXY_URL"], "headless": "new", "record": False}, timeout=60)
if not res.ok:
raise SystemExit(f"launch {res.status_code} {res.text}")
s = res.json()
return s["sessionId"], p.chromium.connect(s["wsUrl"], headers={"x-api-key": KEY})
def end_reason(session_id):
# How a session ended, from the account API (it appears a few seconds after the end).
for _ in range(30):
sessions = requests.get(f"{API}/v1/me/sessions?limit=50", headers={"x-api-key": KEY}, timeout=30).json().get("sessions", [])
s = next((x for x in sessions if x["session_id"] == session_id), None)
if s and s.get("ended_at"):
return s["end_reason"]
time.sleep(3)
return None
def load(page, url):
for attempt in (1, 2):
try:
page.goto(url, wait_until="domcontentloaded", timeout=45000)
return True
except Exception:
if attempt == 2:
return False
sub = requests.get(f"{API}/v1/me", headers={"x-api-key": KEY}, timeout=30).json()["subscription"]
plan = {"kind": sub["kind"], "max_pages": sub["features"]["max_pages"], "memory_mb": sub["features"]["memory_mb"]}
with sync_playwright() as p:
# 1 + 2: tabs. A new tab per URL, never closed, runs into the tab cap; a small pool of reused
# tabs works through the same list without ever getting near it.
_, browser = launch(p)
try:
ctx = browser.new_context()
opened, closed_at = [], None
for i in range(plan["max_pages"] + 1):
# Over the cap the node closes the new tab at once: new_page() fails, or the tab is already closed.
try:
pg = ctx.new_page()
time.sleep(1.5)
except Exception:
pg = None
if pg is None or pg.is_closed():
closed_at = i + 1
break
pg.set_content(f"<title>tab {i + 1}</title>")
opened.append(pg)
alive = browser.is_connected() and opened[0].title() == "tab 1"
tab_cap = {"opened_before_cap": len(opened), "closed_at_tab": closed_at, "session_alive": alive}
for pg in opened:
pg.close()
pages = [ctx.new_page() for _ in range(POOL)]
loaded = 0
# Sync Playwright loads one page at a time; the pool is still what keeps the tab count fixed.
for i, url in enumerate(URLS):
loaded += 1 if load(pages[i % POOL], url) else 0
heaps = [pg.evaluate("() => (performance.memory && performance.memory.usedJSHeapSize) || 0") for pg in pages]
heap_mb = round(sum(heaps) / len(heaps) / 1048576, 1)
pool = {"pool_size": POOL, "urls": len(URLS), "loaded": loaded, "open_tabs": len(ctx.pages)}
for pg in pages:
pg.close()
finally:
browser.close()
# 3: memory. Half the cap is fine; growing a page past the cap ends the session.
session_id, browser = launch(p)
mb, half_ok, cut = 0, False, False
try:
page = browser.new_page()
page.evaluate("() => { window.hog = []; }")
try:
while mb < plan["memory_mb"] + 1024:
page.evaluate("() => { const a = new Uint8Array(128 << 20); for (let i = 0; i < a.length; i += 4096) a[i] = 1; window.hog.push(a); }")
mb += 128
if mb >= plan["memory_mb"] / 2 and not half_ok:
half_ok = browser.is_connected()
except Exception:
cut = True
finally:
try:
browser.close()
except Exception:
pass
memory = {"half_cap_ok": half_ok, "cut": cut, "allocated_mb_at_cut": mb, "end_reason": end_reason(session_id)}
print(json.dumps({"measured_at": date.today().isoformat(), "plan": plan, "tab_cap": tab_cap, "pool": pool,
"js_heap_mb_per_tab": heap_mb, "memory": memory}, 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_API_KEY, PROXY_URL
import com.google.gson.*;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.WaitUntilState;
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_API_KEY");
static final String API = "https://cdpfleet.com";
static final HttpClient HTTP = HttpClient.newHttpClient();
static final Gson GSON = new GsonBuilder().serializeNulls().setPrettyPrinting().disableHtmlEscaping().create();
static final int POOL = 4;
static JsonObject get(String path) throws Exception {
HttpResponse<String> r = HTTP.send(HttpRequest.newBuilder(URI.create(API + path)).header("x-api-key", KEY).build(), HttpResponse.BodyHandlers.ofString());
return JsonParser.parseString(r.body()).getAsJsonObject();
}
static String[] launch() throws Exception {
String body = "{\"proxy\": " + GSON.toJson(System.getenv("PROXY_URL")) + ", \"headless\": \"new\", \"record\": false}";
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(body)).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new RuntimeException("launch: " + res.statusCode() + " " + res.body());
JsonObject s = JsonParser.parseString(res.body()).getAsJsonObject();
return new String[] { s.get("sessionId").getAsString(), s.get("wsUrl").getAsString() };
}
// How a session ended, from the account API (it appears a few seconds after the end).
static String endReason(String id) throws Exception {
for (int i = 0; i < 30; i++) {
for (JsonElement e : get("/v1/me/sessions?limit=50").getAsJsonArray("sessions")) {
JsonObject s = e.getAsJsonObject();
if (s.get("session_id").getAsString().equals(id) && s.has("ended_at") && !s.get("ended_at").isJsonNull()) return s.get("end_reason").getAsString();
}
Thread.sleep(3000);
}
return null;
}
static boolean load(Page page, String url) {
for (int attempt = 1; ; attempt++) {
try { page.navigate(url, new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED).setTimeout(45000)); return true; }
catch (PlaywrightException e) { if (attempt == 2) return false; }
}
}
public static void main(String[] args) throws Exception {
// Twelve light, public catalogue pages: what a crawler works through.
List<String> urls = new ArrayList<>();
for (int i = 1; i <= 12; i++) urls.add("https://books.toscrape.com/catalogue/page-" + i + ".html");
JsonObject sub = get("/v1/me").getAsJsonObject("subscription");
int maxPages = sub.getAsJsonObject("features").get("max_pages").getAsInt();
int memoryMb = sub.getAsJsonObject("features").get("memory_mb").getAsInt();
JsonObject plan = new JsonObject();
plan.addProperty("kind", sub.get("kind").getAsString()); plan.addProperty("max_pages", maxPages); plan.addProperty("memory_mb", memoryMb);
JsonObject tabCap = new JsonObject(), pool = new JsonObject(), memory = new JsonObject();
double heapMb;
try (Playwright pw = Playwright.create()) {
Map<String, String> headers = Map.of("x-api-key", KEY);
// 1 + 2: tabs. A new tab per URL, never closed, runs into the tab cap; a small pool of reused
// tabs works through the same list without ever getting near it.
String[] t = launch();
Browser tabs = pw.chromium().connect(t[1], new BrowserType.ConnectOptions().setHeaders(headers));
try {
BrowserContext ctx = tabs.newContext();
List<Page> opened = new ArrayList<>();
Integer closedAt = null;
for (int i = 0; i <= maxPages; i++) {
// Over the cap the node closes the new tab at once: newPage() fails, or the tab is already closed.
Page p = null;
try { p = ctx.newPage(); Thread.sleep(1500); } catch (PlaywrightException e) { }
if (p == null || p.isClosed()) { closedAt = i + 1; break; }
p.setContent("<title>tab " + (i + 1) + "</title>");
opened.add(p);
}
boolean alive = tabs.isConnected() && opened.get(0).title().equals("tab 1");
tabCap.addProperty("opened_before_cap", opened.size()); tabCap.addProperty("closed_at_tab", closedAt); tabCap.addProperty("session_alive", alive);
for (Page p : opened) p.close();
List<Page> pages = new ArrayList<>();
for (int i = 0; i < POOL; i++) pages.add(ctx.newPage());
int loaded = 0;
// Java's Playwright is single-threaded: the pool loads one page at a time and keeps the tab count fixed.
for (int i = 0; i < urls.size(); i++) if (load(pages.get(i % POOL), urls.get(i))) loaded++;
double sum = 0;
for (Page p : pages) sum += ((Number) p.evaluate("() => (performance.memory && performance.memory.usedJSHeapSize) || 0")).doubleValue();
heapMb = Math.round(sum / pages.size() / 1048576 * 10) / 10.0;
pool.addProperty("pool_size", POOL); pool.addProperty("urls", urls.size()); pool.addProperty("loaded", loaded); pool.addProperty("open_tabs", ctx.pages().size());
for (Page p : pages) p.close();
} finally { tabs.close(); }
// 3: memory. Half the cap is fine; growing a page past the cap ends the session.
String[] m = launch();
Browser mem = pw.chromium().connect(m[1], new BrowserType.ConnectOptions().setHeaders(headers));
int mb = 0; boolean halfOk = false, cut = false;
try {
Page page = mem.newPage();
page.evaluate("() => { window.hog = []; }");
try {
while (mb < memoryMb + 1024) {
page.evaluate("() => { const a = new Uint8Array(128 << 20); for (let i = 0; i < a.length; i += 4096) a[i] = 1; window.hog.push(a); }");
mb += 128;
if (mb >= memoryMb / 2 && !halfOk) halfOk = mem.isConnected();
}
} catch (PlaywrightException e) { cut = true; }
} finally { try { mem.close(); } catch (PlaywrightException e) { } }
memory.addProperty("half_cap_ok", halfOk); memory.addProperty("cut", cut); memory.addProperty("allocated_mb_at_cut", mb);
memory.addProperty("end_reason", endReason(m[0]));
}
JsonObject out = new JsonObject();
out.addProperty("measured_at", LocalDate.now().toString());
out.add("plan", plan); out.add("tab_cap", tabCap); out.add("pool", pool);
out.addProperty("js_heap_mb_per_tab", heapMb); out.add("memory", memory);
System.out.println(GSON.toJson(out));
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
using System.Net.Http.Json;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
const string Api = "https://cdpfleet.com";
// Twelve light, public catalogue pages: what a crawler works through.
var urls = Enumerable.Range(1, 12).Select(i => $"https://books.toscrape.com/catalogue/page-{i}.html").ToArray();
const int Pool = 4;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();
async Task<(string id, IBrowser browser)> Launch()
{
var res = await http.PostAsJsonAsync("https://starter.cdpfleet.com/chromium/session",
new { proxy = Environment.GetEnvironmentVariable("PROXY_URL"), headless = "new", record = false });
if (!res.IsSuccessStatusCode) throw new Exception($"launch: {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
var s = await res.Content.ReadFromJsonAsync<JsonElement>();
var b = await playwright.Chromium.ConnectAsync(s.GetProperty("wsUrl").GetString()!, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
return (s.GetProperty("sessionId").GetString()!, b);
}
// How a session ended, from the account API (it appears a few seconds after the end).
async Task<string?> EndReason(string id)
{
for (var i = 0; i < 30; i++)
{
var j = await http.GetFromJsonAsync<JsonElement>($"{Api}/v1/me/sessions?limit=50");
foreach (var s in j.GetProperty("sessions").EnumerateArray())
if (s.GetProperty("session_id").GetString() == id && s.TryGetProperty("ended_at", out var e) && e.ValueKind != JsonValueKind.Null)
return s.GetProperty("end_reason").GetString();
await Task.Delay(3000);
}
return null;
}
async Task<bool> Load(IPage page, string url)
{
for (var attempt = 1; ; attempt++)
{
try { await page.GotoAsync(url, new() { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 45000 }); return true; }
catch (PlaywrightException) { if (attempt == 2) return false; }
}
}
var sub = (await http.GetFromJsonAsync<JsonElement>($"{Api}/v1/me")).GetProperty("subscription");
var maxPages = sub.GetProperty("features").GetProperty("max_pages").GetInt32();
var memoryMb = sub.GetProperty("features").GetProperty("memory_mb").GetInt32();
var plan = new JsonObject { ["kind"] = sub.GetProperty("kind").GetString(), ["max_pages"] = maxPages, ["memory_mb"] = memoryMb };
// 1 + 2: tabs. A new tab per URL, never closed, runs into the tab cap; a small pool of reused
// tabs works through the same list without ever getting near it.
JsonObject tabCap, pool;
double heapMb;
var (_, tabs) = await Launch();
try
{
var ctx = await tabs.NewContextAsync();
var opened = new List<IPage>();
int? closedAt = null;
for (var i = 0; i <= maxPages; i++)
{
// Over the cap the node closes the new tab at once: NewPageAsync() fails, or the tab is already closed.
IPage? p = null;
try { p = await ctx.NewPageAsync(); await Task.Delay(1500); } catch (PlaywrightException) { }
if (p == null || p.IsClosed) { closedAt = i + 1; break; }
await p.SetContentAsync($"<title>tab {i + 1}</title>");
opened.Add(p);
}
var alive = tabs.IsConnected && await opened[0].TitleAsync() == "tab 1";
tabCap = new JsonObject { ["opened_before_cap"] = opened.Count, ["closed_at_tab"] = closedAt, ["session_alive"] = alive };
foreach (var p in opened) await p.CloseAsync();
var pages = new List<IPage>();
for (var i = 0; i < Pool; i++) pages.Add(await ctx.NewPageAsync());
var loaded = 0;
for (var i = 0; i < urls.Length; i += Pool)
{
var batch = await Task.WhenAll(urls.Skip(i).Take(Pool).Select((u, j) => Load(pages[j], u)));
loaded += batch.Count(x => x);
}
// JS heap of a loaded catalogue page (Chromium's performance.memory), averaged over the pool.
var heaps = new List<double>();
foreach (var p in pages) heaps.Add(await p.EvaluateAsync<double>("() => (performance.memory && performance.memory.usedJSHeapSize) || 0"));
heapMb = Math.Round(heaps.Average() / 1048576, 1);
pool = new JsonObject { ["pool_size"] = Pool, ["urls"] = urls.Length, ["loaded"] = loaded, ["open_tabs"] = ctx.Pages.Count };
foreach (var p in pages) await p.CloseAsync();
}
finally { await tabs.CloseAsync(); }
// 3: memory. Half the cap is fine; growing a page past the cap ends the session.
var (memId, mem) = await Launch();
int mb = 0; bool halfOk = false, cut = false;
try
{
var page = await mem.NewPageAsync();
await page.EvaluateAsync("() => { window.hog = []; }");
try
{
while (mb < memoryMb + 1024)
{
await page.EvaluateAsync("() => { const a = new Uint8Array(128 << 20); for (let i = 0; i < a.length; i += 4096) a[i] = 1; window.hog.push(a); }");
mb += 128;
if (mb >= memoryMb / 2 && !halfOk) halfOk = mem.IsConnected;
}
}
catch (Exception) { cut = true; }
}
finally { try { await mem.CloseAsync(); } catch (Exception) { } }
var memory = new JsonObject { ["half_cap_ok"] = halfOk, ["cut"] = cut, ["allocated_mb_at_cut"] = mb, ["end_reason"] = await EndReason(memId) };
Console.WriteLine(new JsonObject
{
["measured_at"] = DateTime.UtcNow.ToString("yyyy-MM-dd"), ["plan"] = plan, ["tab_cap"] = tabCap, ["pool"] = pool,
["js_heap_mb_per_tab"] = heapMb, ["memory"] = memory,
}.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
// go get github.com/playwright-community/[email protected]
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, PROXY_URL
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"sync"
"time"
"github.com/playwright-community/playwright-go"
)
const api = "https://cdpfleet.com"
const poolSize = 4
var key = os.Getenv("CDPFLEET_API_KEY")
func getJSON(path string, out any) error {
req, _ := http.NewRequest("GET", api+path, nil)
req.Header.Set("x-api-key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
return json.NewDecoder(res.Body).Decode(out)
}
func launch(pw *playwright.Playwright) (string, playwright.Browser, error) {
body, _ := json.Marshal(map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": "new", "record": false})
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 "", nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(res.Body)
return "", nil, fmt.Errorf("launch: %s %s", res.Status, msg)
}
var s struct {
SessionID string `json:"sessionId"`
WsURL string `json:"wsUrl"`
}
if err := json.NewDecoder(res.Body).Decode(&s); err != nil {
return "", nil, err
}
b, err := pw.Chromium.Connect(s.WsURL, playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
return s.SessionID, b, err
}
// How a session ended, from the account API (it appears a few seconds after the end).
func endReason(id string) any {
for i := 0; i < 30; i++ {
var j struct {
Sessions []struct {
SessionID string `json:"session_id"`
EndedAt *string `json:"ended_at"`
EndReason string `json:"end_reason"`
} `json:"sessions"`
}
if getJSON("/v1/me/sessions?limit=50", &j) == nil {
for _, s := range j.Sessions {
if s.SessionID == id && s.EndedAt != nil {
return s.EndReason
}
}
}
time.Sleep(3 * time.Second)
}
return nil
}
func load(page playwright.Page, url string) bool {
for attempt := 1; attempt <= 2; attempt++ {
if _, err := page.Goto(url, playwright.PageGotoOptions{WaitUntil: playwright.WaitUntilStateDomcontentloaded, Timeout: playwright.Float(45000)}); err == nil {
return true
}
}
return false
}
func main() {
// Twelve light, public catalogue pages: what a crawler works through.
urls := make([]string, 12)
for i := range urls {
urls[i] = fmt.Sprintf("https://books.toscrape.com/catalogue/page-%d.html", i+1)
}
var me struct {
Subscription struct {
Kind string `json:"kind"`
Features struct {
MaxPages int `json:"max_pages"`
MemoryMB int `json:"memory_mb"`
} `json:"features"`
} `json:"subscription"`
}
if err := getJSON("/v1/me", &me); err != nil {
log.Fatal(err)
}
maxPages, memoryMB := me.Subscription.Features.MaxPages, me.Subscription.Features.MemoryMB
plan := map[string]any{"kind": me.Subscription.Kind, "max_pages": maxPages, "memory_mb": memoryMB}
pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
log.Fatal(err)
}
defer pw.Stop()
// 1 + 2: tabs. A new tab per URL, never closed, runs into the tab cap; a small pool of reused
// tabs works through the same list without ever getting near it.
_, tabs, err := launch(pw)
if err != nil {
log.Fatal(err)
}
ctx, err := tabs.NewContext()
if err != nil {
log.Fatal(err)
}
var opened []playwright.Page
var closedAt any
for i := 0; i <= maxPages; i++ {
// Over the cap the node closes the new tab at once: NewPage() fails, or the tab is already closed.
p, err := ctx.NewPage()
if err == nil {
time.Sleep(1500 * time.Millisecond)
}
if err != nil || p.IsClosed() {
closedAt = i + 1
break
}
p.SetContent(fmt.Sprintf("<title>tab %d</title>", i+1))
opened = append(opened, p)
}
title, _ := opened[0].Title()
tabCap := map[string]any{"opened_before_cap": len(opened), "closed_at_tab": closedAt, "session_alive": tabs.IsConnected() && title == "tab 1"}
for _, p := range opened {
p.Close()
}
pages := make([]playwright.Page, poolSize)
for i := range pages {
if pages[i], err = ctx.NewPage(); err != nil {
log.Fatal(err)
}
}
loaded := 0
for i := 0; i < len(urls); i += poolSize {
var wg sync.WaitGroup
var mu sync.Mutex
for j := 0; j < poolSize && i+j < len(urls); j++ {
wg.Add(1)
go func(p playwright.Page, u string) {
defer wg.Done()
if load(p, u) {
mu.Lock()
loaded++
mu.Unlock()
}
}(pages[j], urls[i+j])
}
wg.Wait()
}
// JS heap of a loaded catalogue page (Chromium's performance.memory), averaged over the pool.
sum := 0.0
for _, p := range pages {
v, _ := p.Evaluate("() => (performance.memory && performance.memory.usedJSHeapSize) || 0")
switch n := v.(type) {
case int:
sum += float64(n)
case float64:
sum += n
}
}
heapMB := math.Round(sum/float64(len(pages))/1048576*10) / 10
pool := map[string]any{"pool_size": poolSize, "urls": len(urls), "loaded": loaded, "open_tabs": len(ctx.Pages())}
for _, p := range pages {
p.Close()
}
tabs.Close()
// 3: memory. Half the cap is fine; growing a page past the cap ends the session.
memID, mem, err := launch(pw)
if err != nil {
log.Fatal(err)
}
mb, halfOK, cut := 0, false, false
page, err := mem.NewPage()
if err != nil {
log.Fatal(err)
}
page.Evaluate("() => { window.hog = []; }")
for mb < memoryMB+1024 {
if _, err := page.Evaluate("() => { const a = new Uint8Array(128 << 20); for (let i = 0; i < a.length; i += 4096) a[i] = 1; window.hog.push(a); }"); err != nil {
cut = true
break
}
mb += 128
if mb >= memoryMB/2 && !halfOK {
halfOK = mem.IsConnected()
}
}
mem.Close()
memory := map[string]any{"half_cap_ok": halfOK, "cut": cut, "allocated_mb_at_cut": mb, "end_reason": endReason(memID)}
out, _ := json.MarshalIndent(map[string]any{
"measured_at": time.Now().UTC().Format("2006-01-02"), "plan": plan, "tab_cap": tabCap, "pool": pool,
"js_heap_mb_per_tab": heapMB, "memory": memory,
}, "", " ")
fmt.Println(string(out))
}
What we got
| Measured | Value |
|---|---|
| Plan caps | shared: 10 tabs, 4096 MB per session |
| Tabs open before one was refused | 10 |
| Refused tab | #11, session still working |
| Pool of reused tabs | 4 tabs, 12/12 pages loaded, 4 open at most |
| JS heap per loaded tab | 9.5 MB |
| Memory past the cap | cut at ~3968 MB, end_reason session_memory_limit |
From the Node.js run on 2026-10-27. 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)
{
"measured_at": "2026-10-27",
"plan": {
"kind": "shared",
"max_pages": 10,
"memory_mb": 4096
},
"tab_cap": {
"opened_before_cap": 10,
"closed_at_tab": 11,
"session_alive": true
},
"pool": {
"pool_size": 4,
"urls": 12,
"loaded": 12,
"open_tabs": 4
},
"js_heap_mb_per_tab": 9.5,
"memory": {
"half_cap_ok": true,
"cut": true,
"allocated_mb_at_cut": 3968,
"end_reason": "session_memory_limit"
}
}Takeaways
- The tab after the cap is refused, and the session carries on. On the shared plan (10 tabs) the 11th tab was closed at once while the first ten kept working, in every run on 2026-10-27. Your code sees
newPage()fail or the new tab already closed: treat that as "full", not as a crash. - A pool of 4 reused tabs loaded all 12 pages, and never more than 4 were open. Reusing a tab with
gotois the pattern that never meets the cap, and four at a time keeps most of the speed of parallel tabs (case 30). - A light page costs little; one heavy page is what reaches the memory cap. A loaded catalogue tab used about 10 MB of JavaScript heap. Growing one page passed half the cap without trouble, and going past 4,096 MB ended the session with
session_memory_limit(cut at about 4 GB) in every run. - Plan the work, not the tabs. For many URLs use a fixed pool sized to what you load at once (and below the cap), close tabs you're done with, and split very heavy pages across sessions; for many sessions in parallel see case 10. The caps per plan are on Sessions & billing.