cdpfleet Docs Sign up free

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.

Chromium Camoufox

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

WhatWhy
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 launchThe same PROXY_URL for every call and every session, so the network path is the same on both sides.
x-call-idEvery 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 innerTextPlaywright 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

  1. 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.
  2. 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.
  3. Look up every call (by x-call-id) and every session (by sessionId) in GET /v1/me/sessions once it has ended, and compute its billed thread-seconds.
  4. 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.
  5. 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:

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

What we got

PageOutputFunction: time · billed · sizeFunction fileOwn session: time · billed · sizeOwn session fileMeasured
example.comscreenshot4.0 s · 3.8 thread-s · 81 KBPNG 1280×8001.5 s (launch 0.3 s) · 1.3 thread-s · 69 KBPNG 1280×7972026-11-06
example.compdf3.1 s · 3 thread-s · 97 KBimage PDF (a 775 px tall picture)1.9 s (launch 0.4 s) · 1.5 thread-s · 44 KBtext PDF, 1 A4 page2026-11-06
example.commarkdown4.1 s · 4.1 thread-s · 1.4 KBMarkdown2.1 s (launch 0.3 s) · 1.9 thread-s · 2.3 KBHTML (innerText 1.3 KB)2026-11-06
books.toscrape.comscreenshot5.2 s · 5 thread-s · 850 KBPNG 1280×2,5495.4 s (launch 0.4 s) · 5.1 thread-s · 654 KBPNG 1280×2,5492026-11-06
books.toscrape.compdf7.1 s · 7 thread-s · 567 KBimage PDF (a 2,549 px tall picture)6.4 s (launch 0.4 s) · 6.1 thread-s · 330 KBtext PDF, 4 A4 pages2026-11-06
books.toscrape.commarkdown4.0 s · 3.9 thread-s · 13 KBMarkdown2.0 s (launch 0.3 s) · 1.7 thread-s · 50 KBHTML (innerText 2.0 KB)2026-11-06
wikipediascreenshot5.5 s · 5.4 thread-s · 2.9 MBPNG 1280×10,8386.9 s (launch 0.3 s) · 6.8 thread-s · 2.3 MBPNG 1280×10,4742026-11-06
wikipediapdf7.6 s · 7.5 thread-s · 3.3 MBimage PDF (a 10,838 px tall picture)5.9 s (launch 0.5 s) · 5.6 thread-s · 263 KBtext PDF, 8 A4 pages2026-11-06
wikipediamarkdown10.3 s · 10.2 thread-s · 55 KBMarkdown2.4 s (launch 0.4 s) · 2.2 thread-s · 409 KBHTML (innerText 28 KB)2026-11-06
all threeall nine50.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