Examples & benchmarks / #80 · 2026-11-03 · Benchmark · Easy · for web scraping, ai agents
How many tabs fit in one session, by plan, browser and "memory"
A long Wikipedia article opened in one new tab after another until the session ends: on Community (2 GB), a shared plan (4 GB) and a dedicated thread (6 GB), in Chromium, Firefox and WebKit, and with `"memory": 2`. How many tabs fit, what the client sees when the memory runs out, what a 51st tab gets, and what each run is billed.
Run on production on 2026-11-04: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
A benchmark: the results below are what this setup measured on the dates shown, on our own test runs. Sites, networks and browsers change, so rerun the code before you rely on the numbers.
The problem
A crawler that keeps tabs open, an agent that never closes what it opened, a test that runs many pages in one browser: sooner or later the session runs into its limits. Every session can have up to 50 tabs, and the browser's memory is capped by the plan — 2 GB on Community, 4 GB on shared threads, 6 GB on a dedicated thread — unless you launch with "memory": 2, 3 or 4 for that many times the memory (and threads). This case measures how far those limits actually go with a real, mid-weight page, and what your code sees when it hits one.
What we used, and why
| What | Why |
|---|---|
| A long Wikipedia article | A real, mid-weight page (text, images, a large DOM) that is fine to load repeatedly, one tab at a time. |
| One context, new tab per load | Each newPage() + goto() keeps the previous tabs open, as an agent or crawler that never closes tabs would. |
"memory": 2 | Twice the plan's memory for this session; the launch response shows the effective memory_mb and weight. |
| The error the client gets | When the memory cap is hit, the next call fails with the reason in its message. |
GET /v1/me/sessions | The session's end_reason, weight, peak memory and duration: what was billed. |
GET /v1/me | Which plan each key is on, so the runs are labelled without guessing. |
How it works
- For each run — Community Chromium; your plan with Chromium, Firefox, WebKit and Chromium with
"memory": 2; a dedicated thread with Chromium — launch a headless session and notememory_mbandweightfrom the launch response. - Open the article in a new tab, again and again, until a call fails or 50 tabs are open.
- At 50 tabs, try one more.
- Close the session and read its record: end reason, weight, peak memory, duration.
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)COMMUNITY_API_KEY— an API key of an account on the free Community plan (dashboard → API keys); its launches use the plan's included proxyCDPFLEET_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_API_KEY (your plan), PROXY_URL; optional COMMUNITY_API_KEY, CDPFLEET_DEDICATED_API_KEY to compare plans
import { chromium, firefox, webkit } from 'playwright';
const STARTER = 'https://starter.cdpfleet.com';
const API = 'https://cdpfleet.com';
const PAGE = 'https://en.wikipedia.org/wiki/World_War_II'; // a long article: a mid-weight page
const MAX_TABS = 50; // the per-session tab cap on every plan
const PROXY = process.env.PROXY_URL;
const TYPES = { chromium, firefox, webkit };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Each run: an account, an engine and a memory multiplier. Runs whose key isn't set are skipped.
const RUNS = [
['COMMUNITY_API_KEY', 'chromium', 1],
['CDPFLEET_API_KEY', 'chromium', 1],
['CDPFLEET_API_KEY', 'firefox', 1],
['CDPFLEET_API_KEY', 'webkit', 1],
['CDPFLEET_API_KEY', 'chromium', 2],
['CDPFLEET_DEDICATED_API_KEY', 'chromium', 1],
].filter(([env]) => process.env[env]);
const api = (key, path) => fetch(`${API}${path}`, { headers: { 'x-api-key': key } }).then((r) => r.json());
async function run([env, engine, memory]) {
const key = process.env[env];
const sub = (await api(key, '/v1/me')).subscription;
const plan = sub.features?.community ? 'Community' : sub.kind === 'dedicated' ? 'Dedicated' : 'Shared';
const t0 = Date.now();
// Community launches use the plan's included proxy; paid plans bring their own.
const body = { headless: true, ...(memory > 1 ? { memory } : {}), ...(plan === 'Community' ? {} : { proxy: PROXY }) };
const res = await fetch(`${STARTER}/${engine}/session`, { method: 'POST', headers: { 'x-api-key': key, 'content-type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) return { plan, engine, memory, launch_error: `${res.status} ${await res.text()}` };
const launch = await res.json();
const browser = await TYPES[engine].connect(launch.wsUrl, { headers: { 'x-api-key': key } });
const context = await browser.newContext();
// Open the article in one new tab after another until the session ends or the tab cap is reached.
let tabs = 0;
let clientError = null;
while (tabs < MAX_TABS) {
try {
const page = await context.newPage();
// A slow load through a shared proxy gets one more try; a closed session doesn't.
await page.goto(PAGE, { timeout: 60000 }).catch((err) => { if (/Timeout/.test(err.message)) return page.goto(PAGE, { timeout: 60000 }); throw err; });
tabs++;
} catch (err) {
clientError = err.message.split('\n')[0];
break;
}
}
// At the cap with memory to spare: what does a 51st tab get?
let tab51 = null;
if (tabs === MAX_TABS) {
try { await context.newPage(); tab51 = 'opened'; } catch (err) { tab51 = err.message.split('\n')[0]; }
}
await browser.close().catch(() => {});
// The session's record: why it ended, its weight, peak memory and time.
let record = null;
for (let i = 0; i < 20 && !record?.ended_at; i++) {
await sleep(1500);
record = (await api(key, '/v1/me/sessions?limit=20')).sessions.find((s) => s.session_id === launch.sessionId);
}
return {
plan, engine, memory, weight: launch.weight, memory_mb: launch.memory_mb, tabs,
client_error: clientError?.replace(/^.*?(Session closed: .*)$/, '$1') ?? null, tab_51: tab51,
end_reason: record?.end_reason ?? null, memory_peak_mb: record?.memory_peak_mb ?? null,
seconds: record?.duration_seconds ?? Math.round((Date.now() - t0) / 1000),
thread_seconds: record ? record.duration_seconds * record.weight : null,
};
}
const runs = [];
for (const r of RUNS) runs.push(await run(r)); // one at a time: one site, one session at a time
console.log(JSON.stringify({ language: 'node', page: PAGE, max_tabs: MAX_TABS, measured_at: new Date().toISOString().slice(0, 10), runs }, null, 2));
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY (your plan), PROXY_URL; optional COMMUNITY_API_KEY, CDPFLEET_DEDICATED_API_KEY to compare plans
import json
import os
import re
import time
from datetime import date
import requests
from playwright.sync_api import sync_playwright
STARTER = "https://starter.cdpfleet.com"
API = "https://cdpfleet.com"
PAGE = "https://en.wikipedia.org/wiki/World_War_II" # a long article: a mid-weight page
MAX_TABS = 50 # the per-session tab cap on every plan
PROXY = os.environ.get("PROXY_URL")
# Each run: an account, an engine and a memory multiplier. Runs whose key isn't set are skipped.
RUNS = [r for r in [
("COMMUNITY_API_KEY", "chromium", 1),
("CDPFLEET_API_KEY", "chromium", 1),
("CDPFLEET_API_KEY", "firefox", 1),
("CDPFLEET_API_KEY", "webkit", 1),
("CDPFLEET_API_KEY", "chromium", 2),
("CDPFLEET_DEDICATED_API_KEY", "chromium", 1),
] if os.environ.get(r[0])]
def api(key, path):
return requests.get(f"{API}{path}", headers={"x-api-key": key}, timeout=30).json()
def first_line(err):
return str(err).split("\n")[0]
def run(p, env, engine, memory):
key = os.environ[env]
sub = api(key, "/v1/me")["subscription"]
plan = "Community" if (sub.get("features") or {}).get("community") else "Dedicated" if sub.get("kind") == "dedicated" else "Shared"
t0 = time.time()
# Community launches use the plan's included proxy; paid plans bring their own.
body = {"headless": True}
if memory > 1:
body["memory"] = memory
if plan != "Community":
body["proxy"] = PROXY
res = requests.post(f"{STARTER}/{engine}/session", headers={"x-api-key": key}, json=body, timeout=120)
if not res.ok:
return {"plan": plan, "engine": engine, "memory": memory, "launch_error": f"{res.status_code} {res.text}"}
launch = res.json()
browser = getattr(p, engine).connect(launch["wsUrl"], headers={"x-api-key": key})
context = browser.new_context()
# Open the article in one new tab after another until the session ends or the tab cap is reached.
tabs = 0
client_error = None
while tabs < MAX_TABS:
try:
page = context.new_page()
try:
page.goto(PAGE, timeout=60000)
except Exception as err: # a slow load through a shared proxy gets one more try; a closed session doesn't
if "Timeout" not in str(err):
raise
page.goto(PAGE, timeout=60000)
tabs += 1
except Exception as err:
client_error = first_line(err)
break
# At the cap with memory to spare: what does a 51st tab get?
tab51 = None
if tabs == MAX_TABS:
try:
context.new_page()
tab51 = "opened"
except Exception as err:
tab51 = first_line(err)
try:
browser.close()
except Exception:
pass
# The session's record: why it ended, its weight, peak memory and time.
record = None
for _ in range(20):
if record and record.get("ended_at"):
break
time.sleep(1.5)
record = next((s for s in api(key, "/v1/me/sessions?limit=20")["sessions"] if s["session_id"] == launch["sessionId"]), None)
if client_error:
m = re.search(r"(Session closed: .*)$", client_error)
client_error = m.group(1) if m else client_error
return {
"plan": plan, "engine": engine, "memory": memory, "weight": launch["weight"], "memory_mb": launch["memory_mb"], "tabs": tabs,
"client_error": client_error, "tab_51": tab51,
"end_reason": record.get("end_reason") if record else None,
"memory_peak_mb": record.get("memory_peak_mb") if record else None,
"seconds": record["duration_seconds"] if record else round(time.time() - t0),
"thread_seconds": record["duration_seconds"] * record["weight"] if record else None,
}
with sync_playwright() as p:
runs = [run(p, *r) for r in RUNS] # one at a time: one site, one session at a time
print(json.dumps({"language": "python", "page": PAGE, "max_tabs": MAX_TABS, "measured_at": date.today().isoformat(), "runs": runs}, 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 (your plan), PROXY_URL; optional COMMUNITY_API_KEY, CDPFLEET_DEDICATED_API_KEY to compare plans
import com.google.gson.*;
import com.microsoft.playwright.*;
import java.net.URI;
import java.net.http.*;
import java.time.LocalDate;
import java.util.*;
import java.util.regex.*;
public class Main {
static final String STARTER = "https://starter.cdpfleet.com";
static final String API = "https://cdpfleet.com";
static final String PAGE = "https://en.wikipedia.org/wiki/World_War_II"; // a long article: a mid-weight page
static final int MAX_TABS = 50; // the per-session tab cap on every plan
static final String PROXY = System.getenv("PROXY_URL");
static final HttpClient HTTP = HttpClient.newHttpClient();
static final Gson GSON = new GsonBuilder().serializeNulls().setPrettyPrinting().disableHtmlEscaping().create();
// Each run: an account, an engine and a memory multiplier. Runs whose key isn't set are skipped.
static final Object[][] RUNS = {
{ "COMMUNITY_API_KEY", "chromium", 1 },
{ "CDPFLEET_API_KEY", "chromium", 1 },
{ "CDPFLEET_API_KEY", "firefox", 1 },
{ "CDPFLEET_API_KEY", "webkit", 1 },
{ "CDPFLEET_API_KEY", "chromium", 2 },
{ "CDPFLEET_DEDICATED_API_KEY", "chromium", 1 },
};
static JsonObject api(String key, 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();
}
// Playwright for Java wraps the message as "Error {\n message='…'\n name=…": take the message line.
static String firstLine(Exception e) {
String m = String.valueOf(e.getMessage());
java.util.regex.Matcher x = java.util.regex.Pattern.compile("message='([^\n]*)").matcher(m);
return x.find() ? x.group(1).replaceAll("'$", "") : m.split("\n")[0];
}
static JsonObject run(Playwright pw, String env, String engine, int memory) throws Exception {
String key = System.getenv(env);
JsonObject sub = api(key, "/v1/me").getAsJsonObject("subscription");
JsonElement features = sub.get("features");
boolean community = features != null && features.isJsonObject() && features.getAsJsonObject().has("community") && features.getAsJsonObject().get("community").getAsBoolean();
String plan = community ? "Community" : "dedicated".equals(sub.get("kind").getAsString()) ? "Dedicated" : "Shared";
long t0 = System.currentTimeMillis();
// Community launches use the plan's included proxy; paid plans bring their own.
JsonObject body = new JsonObject();
body.addProperty("headless", true);
if (memory > 1) body.addProperty("memory", memory);
if (!community) body.addProperty("proxy", PROXY);
HttpRequest launchReq = HttpRequest.newBuilder(URI.create(STARTER + "/" + engine + "/session"))
.header("x-api-key", key).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toString())).build();
HttpResponse<String> res = HTTP.send(launchReq, HttpResponse.BodyHandlers.ofString());
JsonObject out = new JsonObject();
out.addProperty("plan", plan);
out.addProperty("engine", engine);
out.addProperty("memory", memory);
if (res.statusCode() != 200) { out.addProperty("launch_error", res.statusCode() + " " + res.body()); return out; }
JsonObject launch = JsonParser.parseString(res.body()).getAsJsonObject();
BrowserType type = engine.equals("firefox") ? pw.firefox() : engine.equals("webkit") ? pw.webkit() : pw.chromium();
Browser browser = type.connect(launch.get("wsUrl").getAsString(), new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", key)));
BrowserContext context = browser.newContext();
// Open the article in one new tab after another until the session ends or the tab cap is reached.
int tabs = 0;
String clientError = null;
while (tabs < MAX_TABS) {
try {
Page page = context.newPage();
try {
page.navigate(PAGE, new Page.NavigateOptions().setTimeout(60000));
} catch (TimeoutError e) { // a slow load through a shared proxy gets one more try; a closed session doesn't
page.navigate(PAGE, new Page.NavigateOptions().setTimeout(60000));
}
tabs++;
} catch (Exception e) {
clientError = firstLine(e);
break;
}
}
// At the cap with memory to spare: what does a 51st tab get?
String tab51 = null;
if (tabs == MAX_TABS) {
try { context.newPage(); tab51 = "opened"; } catch (Exception e) { tab51 = firstLine(e); }
}
try { browser.close(); } catch (Exception ignored) { }
// The session's record: why it ended, its weight, peak memory and time.
JsonObject record = null;
for (int i = 0; i < 20 && (record == null || record.get("ended_at").isJsonNull()); i++) {
Thread.sleep(1500);
for (JsonElement s : api(key, "/v1/me/sessions?limit=20").getAsJsonArray("sessions"))
if (s.getAsJsonObject().get("session_id").getAsString().equals(launch.get("sessionId").getAsString())) record = s.getAsJsonObject();
}
if (clientError != null) {
Matcher m = Pattern.compile("(Session closed: .*)$").matcher(clientError);
if (m.find()) clientError = m.group(1);
}
out.add("weight", launch.get("weight"));
out.add("memory_mb", launch.get("memory_mb"));
out.addProperty("tabs", tabs);
out.addProperty("client_error", clientError);
out.addProperty("tab_51", tab51);
out.add("end_reason", record != null ? record.get("end_reason") : JsonNull.INSTANCE);
out.add("memory_peak_mb", record != null ? record.get("memory_peak_mb") : JsonNull.INSTANCE);
out.addProperty("seconds", record != null ? record.get("duration_seconds").getAsInt() : (int) ((System.currentTimeMillis() - t0) / 1000));
if (record != null) out.addProperty("thread_seconds", record.get("duration_seconds").getAsInt() * record.get("weight").getAsInt());
else out.add("thread_seconds", JsonNull.INSTANCE);
return out;
}
public static void main(String[] args) throws Exception {
JsonArray runs = new JsonArray();
try (Playwright pw = Playwright.create()) {
for (Object[] r : RUNS) // one at a time: one site, one session at a time
if (System.getenv((String) r[0]) != null) runs.add(run(pw, (String) r[0], (String) r[1], (Integer) r[2]));
}
JsonObject out = new JsonObject();
out.addProperty("language", "java");
out.addProperty("page", PAGE);
out.addProperty("max_tabs", MAX_TABS);
out.addProperty("measured_at", LocalDate.now().toString());
out.add("runs", runs);
System.out.println(GSON.toJson(out));
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY (your plan), PROXY_URL; optional COMMUNITY_API_KEY, CDPFLEET_DEDICATED_API_KEY to compare plans
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Playwright;
const string Starter = "https://starter.cdpfleet.com";
const string Api = "https://cdpfleet.com";
const string PageUrl = "https://en.wikipedia.org/wiki/World_War_II"; // a long article: a mid-weight page
const int MaxTabs = 50; // the per-session tab cap on every plan
var proxy = Environment.GetEnvironmentVariable("PROXY_URL");
var http = new HttpClient();
// Each run: an account, an engine and a memory multiplier. Runs whose key isn't set are skipped.
var runs = new (string Env, string Engine, int Memory)[]
{
("COMMUNITY_API_KEY", "chromium", 1),
("CDPFLEET_API_KEY", "chromium", 1),
("CDPFLEET_API_KEY", "firefox", 1),
("CDPFLEET_API_KEY", "webkit", 1),
("CDPFLEET_API_KEY", "chromium", 2),
("CDPFLEET_DEDICATED_API_KEY", "chromium", 1),
}.Where(r => Environment.GetEnvironmentVariable(r.Env) != null).ToArray();
async Task<JsonObject> ApiGet(string key, string path)
{
using var req = new HttpRequestMessage(HttpMethod.Get, $"{Api}{path}");
req.Headers.Add("x-api-key", key);
using var res = await http.SendAsync(req);
return JsonNode.Parse(await res.Content.ReadAsStringAsync())!.AsObject();
}
string FirstLine(Exception e) => e.Message.Split('\n')[0];
async Task<JsonObject> Run(IPlaywright pw, string env, string engine, int memory)
{
var key = Environment.GetEnvironmentVariable(env)!;
var sub = (await ApiGet(key, "/v1/me"))["subscription"]!.AsObject();
var community = sub["features"] is JsonObject f && f["community"] is JsonNode c && (bool)c;
var plan = community ? "Community" : (string?)sub["kind"] == "dedicated" ? "Dedicated" : "Shared";
var t0 = DateTime.UtcNow;
// Community launches use the plan's included proxy; paid plans bring their own.
var body = new JsonObject { ["headless"] = true };
if (memory > 1) body["memory"] = memory;
if (!community) body["proxy"] = proxy;
using var req = new HttpRequestMessage(HttpMethod.Post, $"{Starter}/{engine}/session") { Content = JsonContent.Create(body) };
req.Headers.Add("x-api-key", key);
using var res = await http.SendAsync(req);
var output = new JsonObject { ["plan"] = plan, ["engine"] = engine, ["memory"] = memory };
if (!res.IsSuccessStatusCode) { output["launch_error"] = $"{(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}"; return output; }
var launch = JsonNode.Parse(await res.Content.ReadAsStringAsync())!.AsObject();
var type = engine == "firefox" ? pw.Firefox : engine == "webkit" ? pw.Webkit : pw.Chromium;
var browser = await type.ConnectAsync((string)launch["wsUrl"]!, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
var context = await browser.NewContextAsync();
// Open the article in one new tab after another until the session ends or the tab cap is reached.
var tabs = 0;
string? clientError = null;
while (tabs < MaxTabs)
{
try
{
var page = await context.NewPageAsync();
// A slow load through a shared proxy gets one more try; a closed session doesn't.
try { await page.GotoAsync(PageUrl, new() { Timeout = 60000 }); }
catch (TimeoutException) { await page.GotoAsync(PageUrl, new() { Timeout = 60000 }); }
tabs++;
}
catch (Exception e) { clientError = FirstLine(e); break; }
}
// At the cap with memory to spare: what does a 51st tab get?
string? tab51 = null;
if (tabs == MaxTabs)
{
try { await context.NewPageAsync(); tab51 = "opened"; }
catch (Exception e) { tab51 = FirstLine(e); }
}
try { await browser.CloseAsync(); } catch { }
// The session's record: why it ended, its weight, peak memory and time.
JsonObject? record = null;
for (var i = 0; i < 20 && record?["ended_at"] is null; i++)
{
await Task.Delay(1500);
record = (await ApiGet(key, "/v1/me/sessions?limit=20"))["sessions"]!.AsArray().Select(x => x!.AsObject())
.FirstOrDefault(x => (string?)x["session_id"] == (string?)launch["sessionId"]);
}
if (clientError != null)
{
var m = Regex.Match(clientError, "(Session closed: .*)$");
if (m.Success) clientError = m.Groups[1].Value;
}
output["weight"] = launch["weight"]?.DeepClone();
output["memory_mb"] = launch["memory_mb"]?.DeepClone();
output["tabs"] = tabs;
output["client_error"] = clientError;
output["tab_51"] = tab51;
output["end_reason"] = record?["end_reason"]?.DeepClone();
output["memory_peak_mb"] = record?["memory_peak_mb"]?.DeepClone();
output["seconds"] = record != null ? (int)record["duration_seconds"]! : (int)(DateTime.UtcNow - t0).TotalSeconds;
output["thread_seconds"] = record != null ? (int)record["duration_seconds"]! * (int)record["weight"]! : null;
return output;
}
using var playwright = await Playwright.CreateAsync();
var results = new JsonArray();
foreach (var r in runs) results.Add(await Run(playwright, r.Env, r.Engine, r.Memory)); // one at a time: one site, one session at a time
var doc = new JsonObject
{
["language"] = "csharp",
["page"] = PageUrl,
["max_tabs"] = MaxTabs,
["measured_at"] = DateTime.UtcNow.ToString("yyyy-MM-dd"),
["runs"] = results,
};
Console.WriteLine(doc.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_API_KEY (your plan), PROXY_URL; optional COMMUNITY_API_KEY, CDPFLEET_DEDICATED_API_KEY to compare plans
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"regexp"
"strings"
"time"
"github.com/playwright-community/playwright-go"
)
const starter = "https://starter.cdpfleet.com"
const api = "https://cdpfleet.com"
const pageURL = "https://en.wikipedia.org/wiki/World_War_II" // a long article: a mid-weight page
const maxTabs = 50 // the per-session tab cap on every plan
var proxy = os.Getenv("PROXY_URL")
// Each run: an account, an engine and a memory multiplier. Runs whose key isn't set are skipped.
var runs = []struct {
env, engine string
memory int
}{
{"COMMUNITY_API_KEY", "chromium", 1},
{"CDPFLEET_API_KEY", "chromium", 1},
{"CDPFLEET_API_KEY", "firefox", 1},
{"CDPFLEET_API_KEY", "webkit", 1},
{"CDPFLEET_API_KEY", "chromium", 2},
{"CDPFLEET_DEDICATED_API_KEY", "chromium", 1},
}
func must[T any](v T, err error) T {
if err != nil {
log.Fatal(err)
}
return v
}
func getJSON(key, 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()
if err := json.NewDecoder(res.Body).Decode(out); err != nil {
log.Fatal(err)
}
}
func firstLine(err error) string { return strings.SplitN(err.Error(), "\n", 2)[0] }
type record struct {
SessionID string `json:"session_id"`
EndedAt *string `json:"ended_at"`
EndReason *string `json:"end_reason"`
Weight int `json:"weight"`
MemoryPeakMB *int `json:"memory_peak_mb"`
Duration int `json:"duration_seconds"`
}
type result struct {
Plan string `json:"plan"`
Engine string `json:"engine"`
Memory int `json:"memory"`
LaunchError string `json:"launch_error,omitempty"`
Weight int `json:"weight"`
MemoryMB int `json:"memory_mb"`
Tabs int `json:"tabs"`
ClientError *string `json:"client_error"`
Tab51 *string `json:"tab_51"`
EndReason *string `json:"end_reason"`
MemoryPeakMB *int `json:"memory_peak_mb"`
Seconds int `json:"seconds"`
ThreadSeconds *int `json:"thread_seconds"`
}
var sessionClosed = regexp.MustCompile(`(Session closed: .*)$`)
func run(pw *playwright.Playwright, env, engine string, memory int) result {
key := os.Getenv(env)
var me struct {
Subscription struct {
Kind string `json:"kind"`
Features struct {
Community bool `json:"community"`
} `json:"features"`
} `json:"subscription"`
}
getJSON(key, "/v1/me", &me)
plan := "Shared"
if me.Subscription.Features.Community {
plan = "Community"
} else if me.Subscription.Kind == "dedicated" {
plan = "Dedicated"
}
out := result{Plan: plan, Engine: engine, Memory: memory}
t0 := time.Now()
// Community launches use the plan's included proxy; paid plans bring their own.
body := map[string]any{"headless": true}
if memory > 1 {
body["memory"] = memory
}
if plan != "Community" {
body["proxy"] = proxy
}
payload, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", starter+"/"+engine+"/session", bytes.NewReader(payload))
req.Header.Set("x-api-key", key)
req.Header.Set("content-type", "application/json")
res := must(http.DefaultClient.Do(req))
defer res.Body.Close()
if res.StatusCode != 200 {
var b bytes.Buffer
b.ReadFrom(res.Body)
out.LaunchError = fmt.Sprintf("%d %s", res.StatusCode, b.String())
return out
}
var launch struct {
SessionID string `json:"sessionId"`
WsURL string `json:"wsUrl"`
Weight int `json:"weight"`
MemoryMB int `json:"memory_mb"`
}
if err := json.NewDecoder(res.Body).Decode(&launch); err != nil {
log.Fatal(err)
}
types := map[string]playwright.BrowserType{"chromium": pw.Chromium, "firefox": pw.Firefox, "webkit": pw.WebKit}
browser := must(types[engine].Connect(launch.WsURL, playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}}))
context := must(browser.NewContext())
// Open the article in one new tab after another until the session ends or the tab cap is reached.
tabs := 0
var clientError *string
for tabs < maxTabs {
page, err := context.NewPage()
if err == nil {
_, err = page.Goto(pageURL, playwright.PageGotoOptions{Timeout: playwright.Float(60000)})
// A slow load through a shared proxy gets one more try; a closed session doesn't.
if err != nil && strings.Contains(err.Error(), "Timeout") {
_, err = page.Goto(pageURL, playwright.PageGotoOptions{Timeout: playwright.Float(60000)})
}
}
if err != nil {
msg := firstLine(err)
if m := sessionClosed.FindStringSubmatch(msg); m != nil {
msg = m[1]
}
clientError = &msg
break
}
tabs++
}
// At the cap with memory to spare: what does a 51st tab get?
var tab51 *string
if tabs == maxTabs {
msg := "opened"
if _, err := context.NewPage(); err != nil {
msg = firstLine(err)
}
tab51 = &msg
}
browser.Close()
// The session's record: why it ended, its weight, peak memory and time.
var rec *record
for i := 0; i < 20 && (rec == nil || rec.EndedAt == nil); i++ {
time.Sleep(1500 * time.Millisecond)
var r struct {
Sessions []record `json:"sessions"`
}
getJSON(key, "/v1/me/sessions?limit=20", &r)
for _, s := range r.Sessions {
if s.SessionID == launch.SessionID {
s := s
rec = &s
}
}
}
out.Weight, out.MemoryMB, out.Tabs, out.ClientError, out.Tab51 = launch.Weight, launch.MemoryMB, tabs, clientError, tab51
out.Seconds = int(time.Since(t0).Seconds())
if rec != nil {
ts := rec.Duration * rec.Weight
out.EndReason, out.MemoryPeakMB, out.Seconds, out.ThreadSeconds = rec.EndReason, rec.MemoryPeakMB, rec.Duration, &ts
}
return out
}
func main() {
pw := must(playwright.Run())
defer pw.Stop()
results := []result{}
for _, r := range runs { // one at a time: one site, one session at a time
if os.Getenv(r.env) != "" {
results = append(results, run(pw, r.env, r.engine, r.memory))
}
}
doc := map[string]any{"language": "go", "page": pageURL, "max_tabs": maxTabs, "measured_at": time.Now().UTC().Format("2006-01-02"), "runs": results}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.SetEscapeHTML(false)
if err := enc.Encode(doc); err != nil {
log.Fatal(err)
}
}
What we got
| Plan · browser · memory | Memory cap · weight | Tabs opened | What ended it | Billed | Measured |
|---|---|---|---|---|---|
| Community · chromium | 2048 MB · weight 1 | 20 | session_memory_limit — the client saw "Session closed: session_memory_limit (browser ran out of memory: plan limit 2048 MB)" | 90 s × 1 = 90 thread-seconds | 2026-11-04 |
| Shared · chromium | 4096 MB · weight 1 | 37 | session_memory_limit — the client saw "Session closed: session_memory_limit (browser ran out of memory: plan limit 4096 MB)" | 40 s × 1 = 40 thread-seconds | 2026-11-04 |
| Shared · firefox | 4096 MB · weight 1 | 26 | session_memory_limit — the client saw "Session closed: session_memory_limit (browser ran out of memory: plan limit 4096 MB)" | 40 s × 1 = 40 thread-seconds | 2026-11-04 |
| Shared · webkit | 4096 MB · weight 1 | 18 | session_memory_limit — the client saw "Session closed: session_memory_limit (browser ran out of memory: plan limit 4096 MB)" | 127 s × 1 = 127 thread-seconds | 2026-11-04 |
| Shared · chromium · "memory": 2 | 8192 MB · weight 2 | 50 (the cap; 51st: refused) | client_closed (closed by the client), peak 5298 MB | 53 s × 2 = 106 thread-seconds | 2026-11-04 |
| Dedicated · chromium | 6144 MB · weight 1 | 50 (the cap; 51st: refused) | client_closed (closed by the client), peak 5294 MB | 51 s × 1 = 51 thread-seconds | 2026-11-04 |
From the Node.js run on 2026-11-04. 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",
"page": "https://en.wikipedia.org/wiki/World_War_II",
"max_tabs": 50,
"measured_at": "2026-11-04",
"runs": [
{
"plan": "Community",
"engine": "chromium",
"memory": 1,
"weight": 1,
"memory_mb": 2048,
"tabs": 20,
"client_error": "Session closed: session_memory_limit (browser ran out of memory: plan limit 2048 MB)",
"tab_51": null,
"end_reason": "session_memory_limit",
"memory_peak_mb": 2048,
"seconds": 90,
"thread_seconds": 90
},
{
"plan": "Shared",
"engine": "chromium",
"memory": 1,
"weight": 1,
"memory_mb": 4096,
"tabs": 37,
"client_error": "Session closed: session_memory_limit (browser ran out of memory: plan limit 4096 MB)",
"tab_51": null,
"end_reason": "session_memory_limit",
"memory_peak_mb": 4096,
"seconds": 40,
"thread_seconds": 40
},
{
"plan": "Shared",
"engine": "firefox",
"memory": 1,
"weight": 1,
"memory_mb": 4096,
"tabs": 26,
"client_error": "Session closed: session_memory_limit (browser ran out of memory: plan limit 4096 MB)",
"tab_51": null,
"end_reason": "session_memory_limit",
"memory_peak_mb": 4096,
"seconds": 40,
"thread_seconds": 40
},
{
"plan": "Shared",
"engine": "webkit",
"memory": 1,
"weight": 1,
"memory_mb": 4096,
"tabs": 18,
"client_error": "Session closed: session_memory_limit (browser ran out of memory: plan limit 4096 MB)",
"tab_51": null,
"end_reason": "session_memory_limit",
"memory_peak_mb": 4096,
"seconds": 127,
"thread_seconds": 127
},
{
"plan": "Shared",
"engine": "chromium",
"memory": 2,
"weight": 2,
"memory_mb": 8192,
"tabs": 50,
"client_error": null,
"tab_51": "browserContext.newPage: page_limit: this session already has 50 pages open (plan limit), so the new page was closed; the session continues",
"end_reason": "client_closed",
"memory_peak_mb": 5298,
"seconds": 53,
"thread_seconds": 106
},
{
"plan": "Dedicated",
"engine": "chromium",
"memory": 1,
"weight": 1,
"memory_mb": 6144,
"tabs": 50,
"client_error": null,
"tab_51": "browserContext.newPage: page_limit: this session already has 50 pages open (plan limit), so the new page was closed; the session continues",
"end_reason": "client_closed",
"memory_peak_mb": 5294,
"seconds": 51,
"thread_seconds": 51
}
]
}Takeaways
- Memory ends a session long before the tab cap — unless the plan has room to spare. On 2026-11-03, with a long Wikipedia article in each tab, headless Chromium fit 16–18 tabs in Community's 2 GB and 35–39 in a shared plan's 4 GB; a dedicated thread's 6 GB and a shared session with
"memory": 2(8 GB) both reached the 50-tab cap, peaking at about 5.4 GB. - Each engine fits a different number. In the same 4 GB, Chromium fit 35–39 tabs, Firefox 26–28 and WebKit 18 — and WebKit was much the slowest, about 2 minutes for those 18 loads, so give it longer timeouts.
- The client is told why. In Node.js, Python, Java and C#, the call that crossed the limit failed with
Session closed: session_memory_limit (browser ran out of memory: plan limit 4096 MB), and the session's record sayssession_memory_limit. Everything in the session is gone at that point; open fewer tabs at once, close finished ones, or launch with"memory". - Go isn't told the reason. Node.js, Python, Java and C# got the
Session closed: session_memory_limit …message; playwright-go 0.6000 only reportedConnection closed. In Go, read the reason from the session's record (GET /v1/me/sessions,end_reason). - A 51st tab is refused by name, and the session goes on. At 50 tabs,
newPage()failed in every language withpage_limit: this session already has 50 pages open (plan limit), so the new page was closed; the session continues, and the session stayed connected and usable (re-run 2026-11-04; on 2026-11-03 the error was a genericTarget page, context or browser has been closed). Count your open tabs: 50 is the cap on every plan, whatever the memory. "memory": 2is billed as two threads. Thememory: 2session reportedweight: 2at launch and in its record, and its thread-seconds were twice its running time. Use it for the sessions that need it, not by default.