Examples & benchmarks / #85 · 2026-11-06 · Benchmark · Easy · for web scraping, llm data
A Function or your own browser? Screenshot, PDF and Markdown of the same pages, timed and billed
Three pages, three outputs, two ways: one POST to a Function, or launch a browser yourself, connect Playwright, capture and close. Wall time, the thread-seconds each one is billed (read back from the account's history) and what you get — through the same proxy, in five languages.
Run on production on 2026-11-06: ✓ 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 screenshot, a PDF or the text of one page doesn't need a browser of your own: a Function is one HTTP request that returns the file. But you can do the same thing with a session — launch, connect Playwright, goto, capture, close — and then you control every step. Which one is faster, which costs more of your plan, and are the files the same? This case does both, for the same three pages and the same three outputs, and reads what each was billed from the account's own history.
What we used, and why
| What | Why |
|---|---|
POST https://cdpfleet.com/v1/functions/<name> | screenshot with full_page: true, pdf with format: "A4", markdown with nothing extra. The call runs in a fresh tab of a pooled Camoufox browser and returns the file. |
proxy in the Function body and in the launch | The same PROXY_URL for every call and every session, so the network path is the same on both sides. |
x-call-id | Every Function response carries it; it is the call's id in the session history. |
GET /v1/me/sessions?from=… | The account's history: Function calls show up as function:<name>, sessions as chromium. Billed thread-seconds = (ended_at − started_at) × weight. |
POST https://starter.cdpfleet.com/chromium/session + Playwright connect() | Your own headless Chromium: launch, connect with the key header, open a 1280×720 page, capture, browser.close() (which ends the session). Timed from the launch request to the close. |
page.screenshot({fullPage: true}), page.pdf({format: "A4"}) | Playwright's own captures, on the session side. |
page.content() and innerText | Playwright has no Markdown call. The session side measures the HTML you would hand to a converter (turndown, markdownify, flexmark, ReverseMarkdown, html-to-markdown) and the page's plain text — no converter is run. |
How it works
- For each page (example.com, books.toscrape.com, the Wikipedia article "Web scraping") and each output (full-page screenshot, A4 PDF, Markdown), call the Function and time it until the whole file has arrived.
- Then do the same with a session of your own: launch headless Chromium, connect, open the page, capture, close; time it from the launch request.
- Look up every call (by
x-call-id) and every session (bysessionId) inGET /v1/me/sessionsonce it has ended, and compute its billed thread-seconds. - Describe each file: PNG dimensions, whether the PDF has fonts (a text PDF) or is a picture, the Markdown's first line versus the page's HTML and text size.
- Finally, one session does all nine captures (three pages × three outputs) in a row, to compare with nine separate calls.
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 PROXY = process.env.PROXY_URL; // the same proxy for the Function calls and the sessions
const API = 'https://cdpfleet.com';
const STARTER = 'https://starter.cdpfleet.com';
const PAGES = [
['example.com', 'https://example.com/'],
['books.toscrape.com', 'https://books.toscrape.com/'],
['wikipedia', 'https://en.wikipedia.org/wiki/Web_scraping'],
];
const TASKS = ['screenshot', 'pdf', 'markdown'];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const since = new Date(Date.now() - 60000).toISOString();
// The record of a session or a Function call in the account's history (a call's x-call-id is its
// session id there). Billed thread-seconds = (ended_at - started_at) × weight.
async function billed(id) {
for (let i = 0; i < 40; i++) {
const r = await fetch(`${API}/v1/me/sessions?from=${encodeURIComponent(since)}&limit=200`, { headers: { 'x-api-key': KEY } });
const s = (await r.json()).sessions?.find((x) => x.session_id === id);
if (s?.ended_at) return { engine: s.engine, weight: s.weight, thread_seconds: Math.round((Date.parse(s.ended_at) - Date.parse(s.started_at)) * s.weight / 100) / 10 };
await sleep(2000);
}
return null;
}
// What a file is: PNG width × height; for a PDF, whether it has fonts (a text PDF), its page objects
// (0 when they sit in compressed object streams) and the tallest image in it.
function describe(task, buf) {
if (task === 'screenshot') return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
if (task === 'pdf') {
const s = buf.toString('latin1');
const heights = [...s.matchAll(/\/Height (\d+)/g)].map((m) => Number(m[1]));
return { text_pdf: /\/Font\b/.test(s), pages: (s.match(/\/Type\s*\/Page(?![s\w])/g) || []).length, image_height: heights.length ? Math.max(...heights) : 0 };
}
return {};
}
// (a) The Function: one POST, the file in the response. One retry if the call fails.
async function viaFunction(task, url) {
const body = { url, proxy: PROXY, ...(task === 'screenshot' ? { full_page: true } : task === 'pdf' ? { format: 'A4' } : {}) };
const t = Date.now();
const calls = [];
let res, buf;
for (let attempt = 1; attempt <= 2; attempt++) {
res = await fetch(`${API}/v1/functions/${task}`, {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(40000),
});
buf = Buffer.from(await res.arrayBuffer());
if (res.headers.get('x-call-id')) calls.push(res.headers.get('x-call-id'));
if (res.status === 200) break;
}
const ms = Date.now() - t;
const records = [];
for (const id of calls) records.push(await billed(id));
const out = {
status: res.status, attempts: calls.length, ms, bytes: buf.length,
content_type: res.headers.get('content-type'),
partial: res.headers.get('x-cdpfleet-partial'),
engine: records.at(-1)?.engine ?? null,
weight: records.at(-1)?.weight ?? null,
thread_seconds: records.every(Boolean) ? Math.round(records.reduce((a, r) => a + r.thread_seconds, 0) * 10) / 10 : null,
...(res.status === 200 ? describe(task, buf) : {}),
};
if (task === 'markdown' && res.status === 200) {
const md = buf.toString('utf8');
out.first_line = md.split('\n')[0].slice(0, 80);
out.has_source_line = /\nSource: /.test(md);
}
return out;
}
// (b) Your own session: launch, connect, open the page, capture, close.
async function launch() {
const res = await fetch(`${STARTER}/chromium/session`, {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify({ proxy: PROXY, headless: true }),
});
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 } }) };
}
async function open(browser, url) {
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
// A proxy drops the odd connection: one retry.
await page.goto(url, { timeout: 45000 }).catch(() => page.goto(url, { timeout: 45000 }));
return page;
}
// Markdown has no Playwright call: you'd add a converter (turndown, markdownify, …). Measured here:
// the HTML you would convert and the page's innerText.
async function capture(page, task) {
if (task === 'screenshot') { const buf = await page.screenshot({ fullPage: true }); return { bytes: buf.length, ...describe(task, buf) }; }
if (task === 'pdf') { const buf = await page.pdf({ format: 'A4' }); return { bytes: buf.length, ...describe(task, buf) }; }
const html = Buffer.byteLength(await page.content());
const text = Buffer.byteLength(await page.locator('body').innerText());
return { bytes: html, html_bytes: html, text_bytes: text };
}
async function viaSession(task, url) {
const t = Date.now();
const { sessionId, browser } = await launch();
const launchMs = Date.now() - t;
let out;
try {
out = await capture(await open(browser, url), task);
} finally {
await browser.close(); // closing the connection ends the session
}
const ms = Date.now() - t;
const rec = await billed(sessionId);
return { ms, launch_ms: launchMs, engine: rec?.engine ?? null, weight: rec?.weight ?? null, thread_seconds: rec?.thread_seconds ?? null, ...out };
}
const runs = [];
for (const [name, url] of PAGES) {
for (const task of TASKS) {
runs.push({ page: name, task, function: await viaFunction(task, url), session: await viaSession(task, url) });
}
}
// (c) One session for all nine captures: launch once, three pages, three outputs each.
const t = Date.now();
const { sessionId, browser } = await launch();
let captures = 0;
try {
for (const [, url] of PAGES) {
const page = await open(browser, url);
for (const task of TASKS) { await capture(page, task); captures++; }
await page.close();
}
} finally {
await browser.close();
}
const oneMs = Date.now() - t;
const one = await billed(sessionId);
console.log(JSON.stringify({
language: 'node',
proxy: 'the same PROXY_URL for every Function call and every session',
runs,
one_session: { captures, ms: oneMs, thread_seconds: one?.thread_seconds ?? null, weight: one?.weight ?? null },
measured_at: new Date().toISOString().slice(0, 10),
}, null, 2));
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import json
import os
import re
import struct
import time
from datetime import date, datetime, timedelta, timezone
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
PROXY = os.environ["PROXY_URL"] # the same proxy for the Function calls and the sessions
API = "https://cdpfleet.com"
STARTER = "https://starter.cdpfleet.com"
HEADERS = {"x-api-key": KEY}
PAGES = [
("example.com", "https://example.com/"),
("books.toscrape.com", "https://books.toscrape.com/"),
("wikipedia", "https://en.wikipedia.org/wiki/Web_scraping"),
]
TASKS = ["screenshot", "pdf", "markdown"]
SINCE = (datetime.now(timezone.utc) - timedelta(minutes=1)).isoformat()
def ts(s):
return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()
def billed(sid):
# The record of a session or a Function call in the account's history (a call's x-call-id is its
# session id there). Billed thread-seconds = (ended_at - started_at) × weight.
for _ in range(40):
r = requests.get(f"{API}/v1/me/sessions", headers=HEADERS, params={"from": SINCE, "limit": 200}, timeout=30)
s = next((x for x in r.json().get("sessions", []) if x["session_id"] == sid), None)
if s and s.get("ended_at"):
secs = (ts(s["ended_at"]) - ts(s["started_at"])) * s["weight"]
return {"engine": s["engine"], "weight": s["weight"], "thread_seconds": round(secs, 1)}
time.sleep(2)
return None
def describe(task, buf):
# What a file is: PNG width × height; for a PDF, whether it has fonts (a text PDF), its page objects
# (0 when they sit in compressed object streams) and the tallest image in it.
if task == "screenshot":
w, h = struct.unpack(">II", buf[16:24])
return {"width": w, "height": h}
if task == "pdf":
s = buf.decode("latin1")
heights = [int(m) for m in re.findall(r"/Height (\d+)", s)]
return {"text_pdf": bool(re.search(r"/Font\b", s)), "pages": len(re.findall(r"/Type\s*/Page(?![s\w])", s)),
"image_height": max(heights) if heights else 0}
return {}
def via_function(task, url):
# (a) The Function: one POST, the file in the response. One retry if the call fails.
body = {"url": url, "proxy": PROXY}
if task == "screenshot":
body["full_page"] = True
if task == "pdf":
body["format"] = "A4"
t = time.time()
calls = []
for _ in range(2):
res = requests.post(f"{API}/v1/functions/{task}", headers=HEADERS, json=body, timeout=40)
if res.headers.get("x-call-id"):
calls.append(res.headers["x-call-id"])
if res.status_code == 200:
break
ms = int((time.time() - t) * 1000)
buf = res.content
records = [billed(c) for c in calls]
out = {
"status": res.status_code, "attempts": len(calls), "ms": ms, "bytes": len(buf),
"content_type": res.headers.get("content-type"),
"partial": res.headers.get("x-cdpfleet-partial"),
"engine": records[-1]["engine"] if records and records[-1] else None,
"weight": records[-1]["weight"] if records and records[-1] else None,
"thread_seconds": round(sum(r["thread_seconds"] for r in records), 1) if all(records) else None,
}
if res.status_code == 200:
out.update(describe(task, buf))
if task == "markdown":
md = buf.decode("utf-8")
out["first_line"] = md.split("\n")[0][:80]
out["has_source_line"] = "\nSource: " in md
return out
def launch(pw):
# (b) Your own session: launch over HTTP, connect with the key header.
res = requests.post(f"{STARTER}/chromium/session", headers=HEADERS, json={"proxy": PROXY, "headless": True}, timeout=90)
if res.status_code != 200:
raise RuntimeError(f"launch {res.status_code} {res.text}")
s = res.json()
return s["sessionId"], pw.chromium.connect(s["wsUrl"], headers=HEADERS)
def open_page(browser, url):
page = browser.new_page(viewport={"width": 1280, "height": 720})
try:
page.goto(url, timeout=45000)
except Exception: # a proxy drops the odd connection: one retry
page.goto(url, timeout=45000)
return page
def capture(page, task):
# Markdown has no Playwright call: you'd add a converter (markdownify, html2text, …). Measured here:
# the HTML you would convert and the page's innerText.
if task == "screenshot":
buf = page.screenshot(full_page=True)
return {"bytes": len(buf), **describe(task, buf)}
if task == "pdf":
buf = page.pdf(format="A4")
return {"bytes": len(buf), **describe(task, buf)}
html = len(page.content().encode("utf-8"))
text = len(page.locator("body").inner_text().encode("utf-8"))
return {"bytes": html, "html_bytes": html, "text_bytes": text}
def via_session(pw, task, url):
t = time.time()
sid, browser = launch(pw)
launch_ms = int((time.time() - t) * 1000)
try:
out = capture(open_page(browser, url), task)
finally:
browser.close() # closing the connection ends the session
ms = int((time.time() - t) * 1000)
rec = billed(sid) or {}
return {"ms": ms, "launch_ms": launch_ms, "engine": rec.get("engine"), "weight": rec.get("weight"),
"thread_seconds": rec.get("thread_seconds"), **out}
with sync_playwright() as pw:
runs = []
for name, url in PAGES:
for task in TASKS:
runs.append({"page": name, "task": task, "function": via_function(task, url), "session": via_session(pw, task, url)})
# (c) One session for all nine captures: launch once, three pages, three outputs each.
t = time.time()
sid, browser = launch(pw)
captures = 0
try:
for _, url in PAGES:
page = open_page(browser, url)
for task in TASKS:
capture(page, task)
captures += 1
page.close()
finally:
browser.close()
one_ms = int((time.time() - t) * 1000)
one = billed(sid) or {}
print(json.dumps({
"language": "python",
"proxy": "the same PROXY_URL for every Function call and every session",
"runs": runs,
"one_session": {"captures": captures, "ms": one_ms, "thread_seconds": one.get("thread_seconds"), "weight": one.get("weight")},
"measured_at": date.today().isoformat(),
}, indent=2))
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1.
// env: CDPFLEET_API_KEY, PROXY_URL
import com.google.gson.*;
import com.microsoft.playwright.*;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.*;
import java.util.*;
import java.util.regex.*;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final String PROXY = System.getenv("PROXY_URL"); // the same proxy for the Function calls and the sessions
static final String API = "https://cdpfleet.com";
static final String STARTER = "https://starter.cdpfleet.com";
static final String[][] PAGES = {
{ "example.com", "https://example.com/" },
{ "books.toscrape.com", "https://books.toscrape.com/" },
{ "wikipedia", "https://en.wikipedia.org/wiki/Web_scraping" },
};
static final String[] TASKS = { "screenshot", "pdf", "markdown" };
static final String SINCE = Instant.now().minusSeconds(60).toString();
static final HttpClient HTTP = HttpClient.newHttpClient();
static final Gson GSON = new GsonBuilder().serializeNulls().setPrettyPrinting().disableHtmlEscaping().create();
// The record of a session or a Function call in the account's history (a call's x-call-id is its
// session id there). Billed thread-seconds = (ended_at - started_at) × weight.
static JsonObject billed(String id) throws Exception {
for (int i = 0; i < 40; i++) {
HttpRequest req = HttpRequest.newBuilder(URI.create(API + "/v1/me/sessions?limit=200&from=" + URLEncoder.encode(SINCE, StandardCharsets.UTF_8)))
.header("x-api-key", KEY).build();
JsonObject r = JsonParser.parseString(HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body()).getAsJsonObject();
for (JsonElement e : r.getAsJsonArray("sessions")) {
JsonObject s = e.getAsJsonObject();
if (!s.get("session_id").getAsString().equals(id) || s.get("ended_at").isJsonNull()) continue;
long ms = Instant.parse(s.get("ended_at").getAsString()).toEpochMilli() - Instant.parse(s.get("started_at").getAsString()).toEpochMilli();
JsonObject o = new JsonObject();
o.addProperty("engine", s.get("engine").getAsString());
o.addProperty("weight", s.get("weight").getAsInt());
o.addProperty("thread_seconds", Math.round(ms * s.get("weight").getAsInt() / 100.0) / 10.0);
return o;
}
Thread.sleep(2000);
}
return null;
}
// What a file is: PNG width × height; for a PDF, whether it has fonts (a text PDF), its page objects
// (0 when they sit in compressed object streams) and the tallest image in it.
static void describe(String task, byte[] buf, JsonObject out) {
if (task.equals("screenshot")) {
out.addProperty("width", java.nio.ByteBuffer.wrap(buf, 16, 4).getInt());
out.addProperty("height", java.nio.ByteBuffer.wrap(buf, 20, 4).getInt());
} else if (task.equals("pdf")) {
String s = new String(buf, StandardCharsets.ISO_8859_1);
int pages = 0, height = 0;
for (Matcher m = Pattern.compile("/Type\\s*/Page(?![s\\w])").matcher(s); m.find();) pages++;
for (Matcher m = Pattern.compile("/Height (\\d+)").matcher(s); m.find();) height = Math.max(height, Integer.parseInt(m.group(1)));
out.addProperty("text_pdf", Pattern.compile("/Font\\b").matcher(s).find());
out.addProperty("pages", pages);
out.addProperty("image_height", height);
}
}
// (a) The Function: one POST, the file in the response. One retry if the call fails.
static JsonObject viaFunction(String task, String url) throws Exception {
JsonObject body = new JsonObject();
body.addProperty("url", url);
body.addProperty("proxy", PROXY);
if (task.equals("screenshot")) body.addProperty("full_page", true);
if (task.equals("pdf")) body.addProperty("format", "A4");
long t = System.currentTimeMillis();
List<String> calls = new ArrayList<>();
HttpResponse<byte[]> res = null;
for (int attempt = 1; attempt <= 2; attempt++) {
HttpRequest req = HttpRequest.newBuilder(URI.create(API + "/v1/functions/" + task))
.header("x-api-key", KEY).header("content-type", "application/json").timeout(Duration.ofSeconds(40))
.POST(HttpRequest.BodyPublishers.ofString(body.toString())).build();
res = HTTP.send(req, HttpResponse.BodyHandlers.ofByteArray());
res.headers().firstValue("x-call-id").ifPresent(calls::add);
if (res.statusCode() == 200) break;
}
long ms = System.currentTimeMillis() - t;
byte[] buf = res.body();
List<JsonObject> records = new ArrayList<>();
for (String id : calls) records.add(billed(id));
JsonObject last = records.isEmpty() ? null : records.get(records.size() - 1);
JsonObject out = new JsonObject();
out.addProperty("status", res.statusCode());
out.addProperty("attempts", calls.size());
out.addProperty("ms", ms);
out.addProperty("bytes", buf.length);
out.addProperty("content_type", res.headers().firstValue("content-type").orElse(null));
out.addProperty("partial", res.headers().firstValue("x-cdpfleet-partial").orElse(null));
out.add("engine", last == null ? JsonNull.INSTANCE : last.get("engine"));
out.add("weight", last == null ? JsonNull.INSTANCE : last.get("weight"));
if (records.stream().allMatch(Objects::nonNull))
out.addProperty("thread_seconds", Math.round(records.stream().mapToDouble(r -> r.get("thread_seconds").getAsDouble()).sum() * 10) / 10.0);
else out.add("thread_seconds", JsonNull.INSTANCE);
if (res.statusCode() == 200) {
describe(task, buf, out);
if (task.equals("markdown")) {
String md = new String(buf, StandardCharsets.UTF_8);
String first = md.split("\n", -1)[0];
out.addProperty("first_line", first.substring(0, Math.min(80, first.length())));
out.addProperty("has_source_line", md.contains("\nSource: "));
}
}
return out;
}
// (b) Your own session: launch over HTTP, connect with the key header.
record Launched(String sessionId, Browser browser) {}
static Launched launch(Playwright pw) throws Exception {
JsonObject body = new JsonObject();
body.addProperty("proxy", PROXY);
body.addProperty("headless", true);
HttpRequest req = HttpRequest.newBuilder(URI.create(STARTER + "/chromium/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toString())).build();
HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new RuntimeException("launch " + res.statusCode() + " " + res.body());
JsonObject s = JsonParser.parseString(res.body()).getAsJsonObject();
Browser browser = pw.chromium().connect(s.get("wsUrl").getAsString(), new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
return new Launched(s.get("sessionId").getAsString(), browser);
}
static Page open(Browser browser, String url) {
Page page = browser.newPage(new Browser.NewPageOptions().setViewportSize(1280, 720));
// A proxy drops the odd connection: one retry.
try { page.navigate(url, new Page.NavigateOptions().setTimeout(45000)); }
catch (PlaywrightException e) { page.navigate(url, new Page.NavigateOptions().setTimeout(45000)); }
return page;
}
// Markdown has no Playwright call: you'd add a converter (flexmark, …). Measured here: the HTML you
// would convert and the page's innerText.
static JsonObject capture(Page page, String task) {
JsonObject out = new JsonObject();
if (task.equals("screenshot") || task.equals("pdf")) {
byte[] buf = task.equals("screenshot") ? page.screenshot(new Page.ScreenshotOptions().setFullPage(true)) : page.pdf(new Page.PdfOptions().setFormat("A4"));
out.addProperty("bytes", buf.length);
describe(task, buf, out);
return out;
}
int html = page.content().getBytes(StandardCharsets.UTF_8).length;
int text = page.locator("body").innerText().getBytes(StandardCharsets.UTF_8).length;
out.addProperty("bytes", html);
out.addProperty("html_bytes", html);
out.addProperty("text_bytes", text);
return out;
}
static JsonObject viaSession(Playwright pw, String task, String url) throws Exception {
long t = System.currentTimeMillis();
Launched l = launch(pw);
long launchMs = System.currentTimeMillis() - t;
JsonObject cap;
try {
cap = capture(open(l.browser(), url), task);
} finally {
l.browser().close(); // closing the connection ends the session
}
long ms = System.currentTimeMillis() - t;
JsonObject rec = billed(l.sessionId());
JsonObject out = new JsonObject();
out.addProperty("ms", ms);
out.addProperty("launch_ms", launchMs);
out.add("engine", rec == null ? JsonNull.INSTANCE : rec.get("engine"));
out.add("weight", rec == null ? JsonNull.INSTANCE : rec.get("weight"));
out.add("thread_seconds", rec == null ? JsonNull.INSTANCE : rec.get("thread_seconds"));
for (String k : cap.keySet()) out.add(k, cap.get(k));
return out;
}
public static void main(String[] args) throws Exception {
try (Playwright pw = Playwright.create()) {
JsonArray runs = new JsonArray();
for (String[] p : PAGES) {
for (String task : TASKS) {
JsonObject run = new JsonObject();
run.addProperty("page", p[0]);
run.addProperty("task", task);
run.add("function", viaFunction(task, p[1]));
run.add("session", viaSession(pw, task, p[1]));
runs.add(run);
}
}
// (c) One session for all nine captures: launch once, three pages, three outputs each.
long t = System.currentTimeMillis();
Launched l = launch(pw);
int captures = 0;
try {
for (String[] p : PAGES) {
Page page = open(l.browser(), p[1]);
for (String task : TASKS) { capture(page, task); captures++; }
page.close();
}
} finally {
l.browser().close();
}
long oneMs = System.currentTimeMillis() - t;
JsonObject rec = billed(l.sessionId());
JsonObject one = new JsonObject();
one.addProperty("captures", captures);
one.addProperty("ms", oneMs);
one.add("thread_seconds", rec == null ? JsonNull.INSTANCE : rec.get("thread_seconds"));
one.add("weight", rec == null ? JsonNull.INSTANCE : rec.get("weight"));
JsonObject out = new JsonObject();
out.addProperty("language", "java");
out.addProperty("proxy", "the same PROXY_URL for every Function call and every session");
out.add("runs", runs);
out.add("one_session", one);
out.addProperty("measured_at", LocalDate.now().toString());
System.out.println(GSON.toJson(out));
}
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
using System.Buffers.Binary;
using System.Diagnostics;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Playwright;
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
var proxy = Environment.GetEnvironmentVariable("PROXY_URL")!; // the same proxy for the Function calls and the sessions
const string Api = "https://cdpfleet.com";
const string Starter = "https://starter.cdpfleet.com";
var pages = new[] {
("example.com", "https://example.com/"),
("books.toscrape.com", "https://books.toscrape.com/"),
("wikipedia", "https://en.wikipedia.org/wiki/Web_scraping"),
};
var tasks = new[] { "screenshot", "pdf", "markdown" };
var since = DateTime.UtcNow.AddMinutes(-1).ToString("o");
var http = new HttpClient { Timeout = TimeSpan.FromSeconds(90) };
http.DefaultRequestHeaders.Add("x-api-key", key);
var headers = new Dictionary<string, string> { ["x-api-key"] = key };
// The record of a session or a Function call in the account's history (a call's x-call-id is its
// session id there). Billed thread-seconds = (ended_at - started_at) × weight.
async Task<JsonObject?> Billed(string id)
{
for (var i = 0; i < 40; i++)
{
var r = JsonNode.Parse(await http.GetStringAsync($"{Api}/v1/me/sessions?limit=200&from={Uri.EscapeDataString(since)}"))!;
var s = r["sessions"]!.AsArray().Select(x => x!.AsObject()).FirstOrDefault(x => (string?)x["session_id"] == id);
if (s != null && s["ended_at"] != null)
{
var ms = (DateTimeOffset.Parse((string)s["ended_at"]!) - DateTimeOffset.Parse((string)s["started_at"]!)).TotalMilliseconds;
var w = (int)s["weight"]!;
return new JsonObject { ["engine"] = (string?)s["engine"], ["weight"] = w, ["thread_seconds"] = Math.Round(ms * w / 1000, 1) };
}
await Task.Delay(2000);
}
return null;
}
// What a file is: PNG width × height; for a PDF, whether it has fonts (a text PDF), its page objects
// (0 when they sit in compressed object streams) and the tallest image in it.
void Describe(string task, byte[] buf, JsonObject o)
{
if (task == "screenshot")
{
o["width"] = BinaryPrimitives.ReadInt32BigEndian(buf.AsSpan(16, 4));
o["height"] = BinaryPrimitives.ReadInt32BigEndian(buf.AsSpan(20, 4));
}
else if (task == "pdf")
{
var s = Encoding.Latin1.GetString(buf);
var heights = Regex.Matches(s, @"/Height (\d+)").Select(m => int.Parse(m.Groups[1].Value)).ToList();
o["text_pdf"] = Regex.IsMatch(s, @"/Font\b");
o["pages"] = Regex.Matches(s, @"/Type\s*/Page(?![s\w])").Count;
o["image_height"] = heights.Count > 0 ? heights.Max() : 0;
}
}
// (a) The Function: one POST, the file in the response. One retry if the call fails.
async Task<JsonObject> ViaFunction(string task, string url)
{
var body = new JsonObject { ["url"] = url, ["proxy"] = proxy };
if (task == "screenshot") body["full_page"] = true;
if (task == "pdf") body["format"] = "A4";
var sw = Stopwatch.StartNew();
var calls = new List<string>();
HttpResponseMessage res = null!;
byte[] buf = [];
for (var attempt = 1; attempt <= 2; attempt++)
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(40));
res = await http.PostAsync($"{Api}/v1/functions/{task}", new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"), cts.Token);
buf = await res.Content.ReadAsByteArrayAsync(cts.Token);
if (res.Headers.TryGetValues("x-call-id", out var ids)) calls.Add(ids.First());
if ((int)res.StatusCode == 200) break;
}
var ms = sw.ElapsedMilliseconds;
var records = new List<JsonObject?>();
foreach (var id in calls) records.Add(await Billed(id));
var last = records.LastOrDefault();
var o = new JsonObject
{
["status"] = (int)res.StatusCode, ["attempts"] = calls.Count, ["ms"] = ms, ["bytes"] = buf.Length,
["content_type"] = res.Content.Headers.ContentType?.ToString(),
["partial"] = res.Headers.TryGetValues("x-cdpfleet-partial", out var p) ? p.First() : null,
["engine"] = last?["engine"]?.DeepClone(),
["weight"] = last?["weight"]?.DeepClone(),
["thread_seconds"] = records.All(r => r != null) ? (JsonNode?)Math.Round(records.Sum(r => (double)r!["thread_seconds"]!), 1) : null,
};
if ((int)res.StatusCode == 200)
{
Describe(task, buf, o);
if (task == "markdown")
{
var md = Encoding.UTF8.GetString(buf);
var first = md.Split('\n')[0];
o["first_line"] = first[..Math.Min(80, first.Length)];
o["has_source_line"] = md.Contains("\nSource: ");
}
}
return o;
}
using var pw = await Playwright.CreateAsync();
// (b) Your own session: launch over HTTP, connect with the key header.
async Task<(string Id, IBrowser Browser)> Launch()
{
using var res = await http.PostAsJsonAsync($"{Starter}/chromium/session", new { proxy, headless = true });
var text = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode) throw new Exception($"launch {(int)res.StatusCode} {text}");
var s = JsonNode.Parse(text)!;
return ((string)s["sessionId"]!, await pw.Chromium.ConnectAsync((string)s["wsUrl"]!, new() { Headers = headers }));
}
async Task<IPage> Open(IBrowser browser, string url)
{
var page = await browser.NewPageAsync(new() { ViewportSize = new() { Width = 1280, Height = 720 } });
// A proxy drops the odd connection: one retry.
try { await page.GotoAsync(url, new() { Timeout = 45000 }); }
catch (PlaywrightException) { await page.GotoAsync(url, new() { Timeout = 45000 }); }
return page;
}
// Markdown has no Playwright call: you'd add a converter (ReverseMarkdown, …). Measured here: the HTML
// you would convert and the page's innerText.
async Task<JsonObject> Capture(IPage page, string task)
{
var o = new JsonObject();
if (task == "screenshot" || task == "pdf")
{
var buf = task == "screenshot" ? await page.ScreenshotAsync(new() { FullPage = true }) : await page.PdfAsync(new() { Format = "A4" });
o["bytes"] = buf.Length;
Describe(task, buf, o);
return o;
}
var html = Encoding.UTF8.GetByteCount(await page.ContentAsync());
var txt = Encoding.UTF8.GetByteCount(await page.Locator("body").InnerTextAsync());
o["bytes"] = html;
o["html_bytes"] = html;
o["text_bytes"] = txt;
return o;
}
async Task<JsonObject> ViaSession(string task, string url)
{
var sw = Stopwatch.StartNew();
var (id, browser) = await Launch();
var launchMs = sw.ElapsedMilliseconds;
JsonObject cap;
try { cap = await Capture(await Open(browser, url), task); }
finally { await browser.CloseAsync(); } // closing the connection ends the session
var ms = sw.ElapsedMilliseconds;
var rec = await Billed(id);
var o = new JsonObject
{
["ms"] = ms, ["launch_ms"] = launchMs,
["engine"] = rec?["engine"]?.DeepClone(), ["weight"] = rec?["weight"]?.DeepClone(), ["thread_seconds"] = rec?["thread_seconds"]?.DeepClone(),
};
foreach (var (k, v) in cap) o[k] = v?.DeepClone();
return o;
}
var runs = new JsonArray();
foreach (var (name, url) in pages)
foreach (var task in tasks)
runs.Add(new JsonObject { ["page"] = name, ["task"] = task, ["function"] = await ViaFunction(task, url), ["session"] = await ViaSession(task, url) });
// (c) One session for all nine captures: launch once, three pages, three outputs each.
var oneSw = Stopwatch.StartNew();
var (oneId, oneBrowser) = await Launch();
var captures = 0;
try
{
foreach (var (_, url) in pages)
{
var page = await Open(oneBrowser, url);
foreach (var task in tasks) { await Capture(page, task); captures++; }
await page.CloseAsync();
}
}
finally { await oneBrowser.CloseAsync(); }
var oneMs = oneSw.ElapsedMilliseconds;
var one = await Billed(oneId);
Console.WriteLine(new JsonObject
{
["language"] = "csharp",
["proxy"] = "the same PROXY_URL for every Function call and every session",
["runs"] = runs,
["one_session"] = new JsonObject { ["captures"] = captures, ["ms"] = oneMs, ["thread_seconds"] = one?["thread_seconds"]?.DeepClone(), ["weight"] = one?["weight"]?.DeepClone() },
["measured_at"] = DateTime.UtcNow.ToString("yyyy-MM-dd"),
}.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = System.Text.Encodings.Web.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"
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
"github.com/playwright-community/playwright-go"
)
const api = "https://cdpfleet.com"
const starter = "https://starter.cdpfleet.com"
var key = os.Getenv("CDPFLEET_API_KEY")
var proxy = os.Getenv("PROXY_URL") // the same proxy for the Function calls and the sessions
var headers = map[string]string{"x-api-key": key}
var pages = [][2]string{
{"example.com", "https://example.com/"},
{"books.toscrape.com", "https://books.toscrape.com/"},
{"wikipedia", "https://en.wikipedia.org/wiki/Web_scraping"},
}
var tasks = []string{"screenshot", "pdf", "markdown"}
var since = time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
func must[T any](v T, err error) T {
if err != nil {
log.Fatal(err)
}
return v
}
func round1(x float64) float64 { return math.Round(x*10) / 10 }
type record struct {
Engine string
Weight int
ThreadSeconds float64
}
// The record of a session or a Function call in the account's history (a call's x-call-id is its
// session id there). Billed thread-seconds = (ended_at - started_at) × weight.
func billed(id string) *record {
for i := 0; i < 40; i++ {
req, _ := http.NewRequest("GET", api+"/v1/me/sessions?limit=200&from="+url.QueryEscape(since), nil)
req.Header.Set("x-api-key", key)
res := must(http.DefaultClient.Do(req))
var r struct {
Sessions []struct {
SessionID string `json:"session_id"`
Engine string `json:"engine"`
Weight int `json:"weight"`
StartedAt time.Time `json:"started_at"`
EndedAt *time.Time `json:"ended_at"`
} `json:"sessions"`
}
json.NewDecoder(res.Body).Decode(&r)
res.Body.Close()
for _, s := range r.Sessions {
if s.SessionID == id && s.EndedAt != nil {
return &record{s.Engine, s.Weight, round1(s.EndedAt.Sub(s.StartedAt).Seconds() * float64(s.Weight))}
}
}
time.Sleep(2 * time.Second)
}
return nil
}
// One capture's numbers; the task-specific fields are left out where they don't apply.
type result struct {
Status int `json:"status,omitempty"`
Attempts int `json:"attempts,omitempty"`
Ms int64 `json:"ms"`
LaunchMs *int64 `json:"launch_ms,omitempty"`
Bytes int `json:"bytes"`
ContentType *string `json:"content_type,omitempty"`
Partial any `json:"partial,omitempty"` // functions only: the x-cdpfleet-partial header or null
Engine *string `json:"engine"`
Weight *int `json:"weight"`
ThreadSeconds *float64 `json:"thread_seconds"`
Width *int `json:"width,omitempty"`
Height *int `json:"height,omitempty"`
TextPDF *bool `json:"text_pdf,omitempty"`
Pages *int `json:"pages,omitempty"`
ImageHeight *int `json:"image_height,omitempty"`
HTMLBytes *int `json:"html_bytes,omitempty"`
TextBytes *int `json:"text_bytes,omitempty"`
FirstLine *string `json:"first_line,omitempty"`
HasSource *bool `json:"has_source_line,omitempty"`
}
func ptr[T any](v T) *T { return &v }
var pageObj = regexp.MustCompile(`/Type\s*/Page([^s\w]|$)`)
var heightRe = regexp.MustCompile(`/Height (\d+)`)
var fontRe = regexp.MustCompile(`/Font\b`)
// What a file is: PNG width × height; for a PDF, whether it has fonts (a text PDF), its page objects
// (0 when they sit in compressed object streams) and the tallest image in it.
func describe(task string, buf []byte, r *result) {
switch task {
case "screenshot":
r.Width = ptr(int(binary.BigEndian.Uint32(buf[16:20])))
r.Height = ptr(int(binary.BigEndian.Uint32(buf[20:24])))
case "pdf":
h := 0
for _, m := range heightRe.FindAllSubmatch(buf, -1) {
var v int
fmt.Sscan(string(m[1]), &v)
h = max(h, v)
}
r.TextPDF = ptr(fontRe.Match(buf))
r.Pages = ptr(len(pageObj.FindAll(buf, -1)))
r.ImageHeight = ptr(h)
}
}
// (a) The Function: one POST, the file in the response. One retry if the call fails.
func viaFunction(task, u string) result {
body := map[string]any{"url": u, "proxy": proxy}
if task == "screenshot" {
body["full_page"] = true
}
if task == "pdf" {
body["format"] = "A4"
}
payload := must(json.Marshal(body))
t := time.Now()
var calls []string
var res *http.Response
var buf []byte
for attempt := 1; attempt <= 2; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
req, _ := http.NewRequestWithContext(ctx, "POST", api+"/v1/functions/"+task, bytes.NewReader(payload))
req.Header.Set("x-api-key", key)
req.Header.Set("content-type", "application/json")
res = must(http.DefaultClient.Do(req))
buf = must(io.ReadAll(res.Body))
res.Body.Close()
cancel()
if id := res.Header.Get("x-call-id"); id != "" {
calls = append(calls, id)
}
if res.StatusCode == 200 {
break
}
}
r := result{Status: res.StatusCode, Attempts: len(calls), Ms: time.Since(t).Milliseconds(), Bytes: len(buf), ContentType: ptr(res.Header.Get("content-type"))}
r.Partial = json.RawMessage("null")
if p := res.Header.Get("x-cdpfleet-partial"); p != "" {
r.Partial = p
}
total, all := 0.0, true
for _, id := range calls {
rec := billed(id)
if rec == nil {
all = false
continue
}
total += rec.ThreadSeconds
r.Engine, r.Weight = ptr(rec.Engine), ptr(rec.Weight)
}
if all && len(calls) > 0 {
r.ThreadSeconds = ptr(round1(total))
}
if res.StatusCode == 200 {
describe(task, buf, &r)
if task == "markdown" {
md := string(buf)
first := strings.SplitN(md, "\n", 2)[0]
if len(first) > 80 {
first = first[:80]
}
r.FirstLine = &first
r.HasSource = ptr(strings.Contains(md, "\nSource: "))
}
}
return r
}
// (b) Your own session: launch over HTTP, connect with the key header.
func launch(pw *playwright.Playwright) (string, playwright.Browser) {
payload := must(json.Marshal(map[string]any{"proxy": proxy, "headless": true}))
req, _ := http.NewRequest("POST", starter+"/chromium/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 {
b, _ := io.ReadAll(res.Body)
log.Fatalf("launch %d %s", res.StatusCode, b)
}
var s struct {
SessionID string `json:"sessionId"`
WsURL string `json:"wsUrl"`
}
json.NewDecoder(res.Body).Decode(&s)
return s.SessionID, must(pw.Chromium.Connect(s.WsURL, playwright.BrowserTypeConnectOptions{Headers: headers}))
}
func open(browser playwright.Browser, u string) playwright.Page {
page := must(browser.NewPage(playwright.BrowserNewPageOptions{Viewport: &playwright.Size{Width: 1280, Height: 720}}))
// A proxy drops the odd connection: one retry.
if _, err := page.Goto(u, playwright.PageGotoOptions{Timeout: playwright.Float(45000)}); err != nil {
must(page.Goto(u, playwright.PageGotoOptions{Timeout: playwright.Float(45000)}))
}
return page
}
// Markdown has no Playwright call: you'd add a converter (html-to-markdown, …). Measured here: the
// HTML you would convert and the page's innerText.
func capture(page playwright.Page, task string) result {
var r result
switch task {
case "screenshot":
buf := must(page.Screenshot(playwright.PageScreenshotOptions{FullPage: playwright.Bool(true)}))
r.Bytes = len(buf)
describe(task, buf, &r)
case "pdf":
buf := must(page.PDF(playwright.PagePdfOptions{Format: playwright.String("A4")}))
r.Bytes = len(buf)
describe(task, buf, &r)
default:
html := len(must(page.Content()))
text := len(must(page.Locator("body").InnerText()))
r.Bytes, r.HTMLBytes, r.TextBytes = html, &html, &text
}
return r
}
func viaSession(pw *playwright.Playwright, task, u string) result {
t := time.Now()
id, browser := launch(pw)
launchMs := time.Since(t).Milliseconds()
r := capture(open(browser, u), task)
browser.Close() // closing the connection ends the session
r.Ms, r.LaunchMs = time.Since(t).Milliseconds(), &launchMs
if rec := billed(id); rec != nil {
r.Engine, r.Weight, r.ThreadSeconds = ptr(rec.Engine), ptr(rec.Weight), ptr(rec.ThreadSeconds)
}
return r
}
type run struct {
Page string `json:"page"`
Task string `json:"task"`
Function result `json:"function"`
Session result `json:"session"`
}
func main() {
pw := must(playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true}))
defer pw.Stop()
var runs []run
for _, p := range pages {
for _, task := range tasks {
runs = append(runs, run{p[0], task, viaFunction(task, p[1]), viaSession(pw, task, p[1])})
}
}
// (c) One session for all nine captures: launch once, three pages, three outputs each.
t := time.Now()
id, browser := launch(pw)
captures := 0
for _, p := range pages {
page := open(browser, p[1])
for _, task := range tasks {
capture(page, task)
captures++
}
page.Close()
}
browser.Close()
oneMs := time.Since(t).Milliseconds()
one := billed(id)
oneSession := map[string]any{"captures": captures, "ms": oneMs, "thread_seconds": nil, "weight": nil}
if one != nil {
oneSession["thread_seconds"], oneSession["weight"] = one.ThreadSeconds, one.Weight
}
out := struct {
Language string `json:"language"`
Proxy string `json:"proxy"`
Runs []run `json:"runs"`
OneSession map[string]any `json:"one_session"`
MeasuredAt string `json:"measured_at"`
}{"go", "the same PROXY_URL for every Function call and every session", runs, oneSession, time.Now().UTC().Format("2006-01-02")}
enc := json.NewEncoder(os.Stdout)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
enc.Encode(out)
}
What we got
| Page | Output | Function: time · billed · size | Function file | Own session: time · billed · size | Own session file | Measured |
|---|---|---|---|---|---|---|
| example.com | screenshot | 4.0 s · 3.8 thread-s · 81 KB | PNG 1280×800 | 1.5 s (launch 0.3 s) · 1.3 thread-s · 69 KB | PNG 1280×797 | 2026-11-06 |
| example.com | 3.1 s · 3 thread-s · 97 KB | image PDF (a 775 px tall picture) | 1.9 s (launch 0.4 s) · 1.5 thread-s · 44 KB | text PDF, 1 A4 page | 2026-11-06 | |
| example.com | markdown | 4.1 s · 4.1 thread-s · 1.4 KB | Markdown | 2.1 s (launch 0.3 s) · 1.9 thread-s · 2.3 KB | HTML (innerText 1.3 KB) | 2026-11-06 |
| books.toscrape.com | screenshot | 5.2 s · 5 thread-s · 850 KB | PNG 1280×2,549 | 5.4 s (launch 0.4 s) · 5.1 thread-s · 654 KB | PNG 1280×2,549 | 2026-11-06 |
| books.toscrape.com | 7.1 s · 7 thread-s · 567 KB | image PDF (a 2,549 px tall picture) | 6.4 s (launch 0.4 s) · 6.1 thread-s · 330 KB | text PDF, 4 A4 pages | 2026-11-06 | |
| books.toscrape.com | markdown | 4.0 s · 3.9 thread-s · 13 KB | Markdown | 2.0 s (launch 0.3 s) · 1.7 thread-s · 50 KB | HTML (innerText 2.0 KB) | 2026-11-06 |
| wikipedia | screenshot | 5.5 s · 5.4 thread-s · 2.9 MB | PNG 1280×10,838 | 6.9 s (launch 0.3 s) · 6.8 thread-s · 2.3 MB | PNG 1280×10,474 | 2026-11-06 |
| wikipedia | 7.6 s · 7.5 thread-s · 3.3 MB | image PDF (a 10,838 px tall picture) | 5.9 s (launch 0.5 s) · 5.6 thread-s · 263 KB | text PDF, 8 A4 pages | 2026-11-06 | |
| wikipedia | markdown | 10.3 s · 10.2 thread-s · 55 KB | Markdown | 2.4 s (launch 0.4 s) · 2.2 thread-s · 409 KB | HTML (innerText 28 KB) | 2026-11-06 |
| all three | all nine | 50.9 s · 49.9 thread-s (nine calls) | — | 15.1 s · 14.8 thread-s (one session) | — | 2026-11-06 |
From the Node.js run on 2026-11-06. 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",
"proxy": "the same PROXY_URL for every Function call and every session",
"runs": [
{
"page": "example.com",
"task": "screenshot",
"function": {
"status": 200,
"attempts": 1,
"ms": 4011,
"bytes": 83455,
"content_type": "image/png",
"partial": null,
"engine": "function:screenshot",
"weight": 1,
"thread_seconds": 3.8,
"width": 1280,
"height": 800
},
"session": {
"ms": 1545,
"launch_ms": 312,
"engine": "chromium",
"weight": 1,
"thread_seconds": 1.3,
"bytes": 70583,
"width": 1280,
"height": 797
}
},
{
"page": "example.com",
"task": "pdf",
"function": {
"status": 200,
"attempts": 1,
"ms": 3113,
"bytes": 98924,
"content_type": "application/pdf",
"partial": null,
"engine": "function:pdf",
"weight": 1,
"thread_seconds": 3,
"text_pdf": false,
"pages": 0,
"image_height": 775
},
"session": {
"ms": 1922,
"launch_ms": 449,
"engine": "chromium",
"weight": 1,
"thread_seconds": 1.5,
"bytes": 45438,
"text_pdf": true,
"pages": 1,
"image_height": 0
}
},
{
"page": "example.com",
"task": "markdown",
"function": {
"status": 200,
"attempts": 1,
"ms": 4097,
"bytes": 1395,
"content_type": "text/markdown; charset=utf-8",
"partial": null,
"engine": "function:markdown",
"weight": 1,
"thread_seconds": 4.1,
"first_line": "# Example Domain",
"has_source_line": true
},
"session": {
"ms": 2116,
"launch_ms": 259,
"engine": "chromium",
"weight": 1,
"thread_seconds": 1.9,
"bytes": 2373,
"html_bytes": 2373,
"text_bytes": 1301
}
},
{
"page": "books.toscrape.com",
"task": "screenshot",
"function": {
"status": 200,
"attempts": 1,
"ms": 5166,
"bytes": 870755,
"content_type": "image/png",
"partial": null,
"engine": "function:screenshot",
"weight": 1,
"thread_seconds": 5,
"width": 1280,
"height": 2549
},
"session": {
"ms": 5365,
"launch_ms": 447,
"engine": "chromium",
"weight": 1,
"thread_seconds": 5.1,
"bytes": 669597,
"width": 1280,
"height": 2549
}
},
{
"page": "books.toscrape.com",
"task": "pdf",
"function": {
"status": 200,
"attempts": 1,
"ms": 7085,
"bytes": 580995,
"content_type": "application/pdf",
"partial": null,
"engine": "function:pdf",
"weight": 1,
"thread_seconds": 7,
"text_pdf": false,
"pages": 0,
"image_height": 2549
},
"session": {
"ms": 6366,
"launch_ms": 418,
"engine": "chromium",
"weight": 1,
"thread_seconds": 6.1,
"bytes": 338351,
"text_pdf": true,
"pages": 4,
"image_height": 155
}
},
{
"page": "books.toscrape.com",
"task": "markdown",
"function": {
"status": 200,
"attempts": 1,
"ms": 3990,
"bytes": 13153,
"content_type": "text/markdown; charset=utf-8",
"partial": null,
"engine": "function:markdown",
"weight": 1,
"thread_seconds": 3.9,
"first_line": "# All products | Books to Scrape - Sandbox",
"has_source_line": true
},
"session": {
"ms": 1980,
"launch_ms": 317,
"engine": "chromium",
"weight": 1,
"thread_seconds": 1.7,
"bytes": 51024,
"html_bytes": 51024,
"text_bytes": 2049
}
},
{
"page": "wikipedia",
"task": "screenshot",
"function": {
"status": 200,
"attempts": 1,
"ms": 5537,
"bytes": 2999095,
"content_type": "image/png",
"partial": null,
"engine": "function:screenshot",
"weight": 1,
"thread_seconds": 5.4,
"width": 1280,
"height": 10838
},
"session": {
"ms": 6932,
"launch_ms": 330,
"engine": "chromium",
"weight": 1,
"thread_seconds": 6.8,
"bytes": 2447315,
"width": 1280,
"height": 10474
}
},
{
"page": "wikipedia",
"task": "pdf",
"function": {
"status": 200,
"attempts": 1,
"ms": 7582,
"bytes": 3491544,
"content_type": "application/pdf",
"partial": null,
"engine": "function:pdf",
"weight": 1,
"thread_seconds": 7.5,
"text_pdf": false,
"pages": 0,
"image_height": 10838
},
"session": {
"ms": 5896,
"launch_ms": 460,
"engine": "chromium",
"weight": 1,
"thread_seconds": 5.6,
"bytes": 269738,
"text_pdf": true,
"pages": 8,
"image_height": 0
}
},
{
"page": "wikipedia",
"task": "markdown",
"function": {
"status": 200,
"attempts": 1,
"ms": 10298,
"bytes": 56237,
"content_type": "text/markdown; charset=utf-8",
"partial": null,
"engine": "function:markdown",
"weight": 1,
"thread_seconds": 10.2,
"first_line": "# Web scraping",
"has_source_line": true
},
"session": {
"ms": 2421,
"launch_ms": 430,
"engine": "chromium",
"weight": 1,
"thread_seconds": 2.2,
"bytes": 419020,
"html_bytes": 419020,
"text_bytes": 28724
}
}
],
"one_session": {
"captures": 9,
"ms": 15114,
"thread_seconds": 14.8,
"weight": 1
},
"measured_at": "2026-11-06"
}Takeaways
- Your own session was faster in 35 of 45 captures, launch included. On 2026-11-06, across five runs (one per language), launching and connecting took 0.2–0.6 s (median 0.38 s). Median time per capture: screenshot 4.8 s by Function vs 3.6 s in your own session, PDF 4.4 vs 3.7 s, Markdown 4.6 vs 2.3 s (the session side only took the HTML; a converter would add its own time). Both sides swung widely with the proxy — 2.4–14.6 s for a Function call, 1.3–12.6 s for a session. The Function waits more on purpose: the screenshot waits for the network to go quiet and markdown 1.5 s after
load, wherepage.goto()stops atload. - Both are billed the same way: wall time × one thread. Every call was in
GET /v1/me/sessionsunder itsx-call-idasfunction:<name>with weight 1, billed 0–0.45 s less than the client waited; every session was there aschromium, weight 1, 0.1–0.4 s under. A capture costs what it takes: the nine Function calls of a run were billed 39.4–55.3 thread-seconds, nine one-shot sessions 23.9–37.2. - Several outputs of the same page: one session is 2.5–4× cheaper. One session that loaded each page once and took all three outputs from it was billed 13.2–16.5 thread-seconds for the nine captures — 2.5–4.0× less than nine Function calls and 1.8–2.8× less than nine separate sessions. Each Function call loads its page again; scrape returns several formats from one load (not measured here).
- The PDFs are different files. The pooled Function returned an image PDF without fonts every time — 97–99 KB, 581 KB and 3.4–3.5 MB — while
page.pdf()gave text PDFs of 45 KB (1 page), 338 KB (4 A4 pages) and 270 KB (8 pages): for the Wikipedia article 12–13× smaller, and searchable. For a text PDF from a Function, run it in your own browser ("browser": "chrome", paid plans). - Screenshots match in width, not pixel for pixel. Both sides were 1280 px wide in every run, and the catalogue was 2,549 px tall on both. The Function renders in Camoufox, so other pages came out at their own heights — Wikipedia 10,463 or 10,838 px against Chromium's 10,474, example.com 775–800 against 797 — and its PNGs were 17–18% (example.com) and 30% (the catalogue) larger.
- Markdown is where the Function saves you work. It returned the same bytes in all five runs — 1,395, 13,153 and 56,237 — each with a
#title and aSource:line. The pages' HTML was 2,373 bytes, 51,024 bytes and 318–419 KB (Wikipedia served two variants), so the Wikipedia Markdown is 13–18% of it;innerTextwas smaller still (1,301, 2,049 and 28,719 bytes) but has no links or structure. Doing it yourself means picking a converter and its rules (main content only, absolute links), which this case doesn't measure. - When each fits. A Function: a one-off capture from any code that can send a POST — no Playwright, no browser to launch or close, Markdown ready for an LLM, a fresh tab and exit country per call. Your own session: several outputs or pages from one load, anything after a login or a click, text PDFs, and your own wait rules — and in this run it was also the faster and cheaper path.