cdpfleet Docs GitHub Dashboard

Cases / #27 · 2026-10-07 · Easy

Watch it live: streaming a remote browser to your code, frame by frame

Playwright 1.60's page.screencast on Chromium and Firefox: how many frames arrive while a page loads, scrolls and sits still — and why the two engines behave nothing alike when nothing moves.

Chromium Firefox

Run on production on 2026-10-07: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go

The problem

A remote browser is a black box while your code runs. Hosted services sell a "live view" URL for that; Playwright 1.60 can do it itself: page.screencast.start({ onFrame }) sends a JPEG of the page to your code whenever the page changes, over the session's own connection. That's enough to build a viewer, a debugging dashboard or a thumbnail strip — if you know how much it sends. How many frames per second, how big, and what happens when the page is idle?

What we used, and why

WhatWhy
page.screencast.start({ quality: 60, onFrame })Starts the stream; each callback gets data (JPEG bytes) and the viewport size. Needs a Playwright 1.60+ client. Live view has a relay to a browser tab.
chromium and firefox, headlessThe two engines families encode frames differently; WebKit behaves like Chromium here.
Three phasesLoad a long Wikipedia article, scroll it ten times in four seconds, then touch nothing for three seconds. Frames are counted per phase.
The JPEG magic bytes FF D8Every frame is checked to be a real image.
Retries on navigation and on 503The proxy can drop a tunnel and a browser can be momentarily out of capacity; both are retried.

How it works

  1. Launch the browser, open a page and start the screencast with a callback that tags every frame with the current phase.
  2. Load the article, scroll it, then wait three seconds without doing anything.
  3. Stop the screencast and count frames per phase, the frame rate while scrolling, the average size and the viewport.

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, firefox } from 'playwright';
import { writeFileSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';

const KEY = process.env.CDPFLEET_API_KEY;
const dir = mkdtempSync(path.join(tmpdir(), 'cdpfleet-live-'));

const TARGETS = [
  { browser: 'chromium', type: chromium, body: { headless: 'new' } },
  { browser: 'firefox', type: firefox, body: { headless: true } },
];

async function watch({ browser: engine, type, body }) {
  let res;
  for (let attempt = 1; attempt <= 5; attempt++) { // 503 = momentarily no capacity for this browser
    res = await fetch(`https://starter.cdpfleet.com/${engine}/session`, {
      method: 'POST',
      headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
      body: JSON.stringify({ proxy: process.env.PROXY_URL, ...body }),
    });
    if (res.status !== 503) break;
    await new Promise((r) => setTimeout(r, 2000 * attempt));
  }
  if (!res.ok) return { browser: engine, error: `launch ${res.status} ${await res.text()}` };
  const { wsUrl } = await res.json();
  const browser = await type.connect(wsUrl, { headers: { 'x-api-key': KEY } });
  try {
    const page = await browser.newPage();
    const frames = []; // every frame, tagged with the phase it arrived in
    let phase = 'load';
    // Every frame arrives here as JPEG bytes — this is where a viewer would get it.
    await page.screencast.start({
      quality: 60,
      onFrame: ({ data, viewportWidth, viewportHeight }) => {
        frames.push({ phase, bytes: data.length, jpeg: data[0] === 0xff && data[1] === 0xd8, w: viewportWidth, h: viewportHeight, data });
      },
    });
    for (let attempt = 1; ; attempt++) { // the proxy can drop a tunnel; retry
      try { await page.goto('https://en.wikipedia.org/wiki/Web_browser', { timeout: 60000 }); break; } catch (err) { if (attempt === 3) throw err; }
    }
    phase = 'scroll';
    const t = Date.now();
    for (let i = 0; i < 10; i++) { await page.mouse.wheel(0, 500); await page.waitForTimeout(400); }
    const scrolling = (Date.now() - t) / 1000;
    phase = 'idle'; // nothing changes on the page now
    await page.waitForTimeout(3000);
    await page.screencast.stop();
    const count = (p) => frames.filter((f) => f.phase === p).length;
    const last = frames.at(-1);
    if (last) writeFileSync(path.join(dir, `${engine}.jpg`), last.data);
    return {
      browser: engine,
      frames_while_loading: count('load'),
      frames_while_scrolling: count('scroll'),
      fps_while_scrolling: Math.round((count('scroll') / scrolling) * 10) / 10,
      frames_in_3s_idle: count('idle'),
      avg_kb: Math.round(frames.reduce((n, f) => n + f.bytes, 0) / Math.max(frames.length, 1) / 1024),
      all_jpeg: frames.every((f) => f.jpeg),
      viewport: last ? `${last.w}x${last.h}` : null,
    };
  } finally {
    await browser.close();
  }
}

const out = [];
for (const t of TARGETS) out.push(await watch(t));
console.log(JSON.stringify(out, null, 2));

What we got

BrowserFrames while loadingFrames while scrollingfps while scrollingFrames in 3 s idleAvg KB / frameAll JPEGViewport
chromium52327.6140yes1280x720
firefox3209521.66842yes1280x720

From the Node.js run on 2026-10-07. 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)
[
  {
    "browser": "chromium",
    "frames_while_loading": 52,
    "frames_while_scrolling": 32,
    "fps_while_scrolling": 7.6,
    "frames_in_3s_idle": 1,
    "avg_kb": 40,
    "all_jpeg": true,
    "viewport": "1280x720"
  },
  {
    "browser": "firefox",
    "frames_while_loading": 320,
    "frames_while_scrolling": 95,
    "fps_while_scrolling": 21.6,
    "frames_in_3s_idle": 68,
    "avg_kb": 42,
    "all_jpeg": true,
    "viewport": "1280x720"
  }
]

Takeaways