[Cases](https://cdpfleet.com/docs/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.

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

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

1.  Read the plan's caps from `/v1/me`.
2.  In one session, open tabs without closing them until the server refuses one; check the session still works.
3.  Close them, then open a pool of 4 tabs and load 12 catalogue pages through it, four at a time.
4.  Read the JavaScript heap of each pooled tab.
5.  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](https://github.com/cdpfleet/cdpfleet/tree/main/cases/72-stay-under-the-caps), with the raw output). Set these environment variables first:

-   `CDPFLEET_API_KEY` — your API key (dashboard → API keys)
-   `PROXY_URL` — your proxy, e.g. `http://user:pass@proxy.example.com:8000`

 

```node
// npm install playwright@1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
import { 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));
```

```python
# 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))
```

```java
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1. env: CDPFLEET_API_KEY, PROXY_URL
import com.google.gson.*;
import com.microsoft.playwright.*;
import 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));
  }
}
```

```csharp
// 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
// go get github.com/playwright-community/playwright-go@v0.6000.0
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, PROXY_URL
package main

import (
	"bytes"
	"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 `goto` is the pattern that never meets the cap, and four at a time keeps most of the speed of parallel tabs ([case 30](https://cdpfleet.com/docs/cases/parallel-pages)).
-   **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](https://cdpfleet.com/docs/cases/worker-pool). The caps per plan are on [Sessions & billing](https://cdpfleet.com/docs/sessions).

---
Source: https://cdpfleet.com/docs/cases/stay-under-the-caps · Markdown: https://cdpfleet.com/docs/cases/stay-under-the-caps.md · All docs for AI tools: https://cdpfleet.com/llms.txt
