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.
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
| What | Why |
|---|---|
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, headless | The two engines families encode frames differently; WebKit behaves like Chromium here. |
| Three phases | Load 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 D8 | Every frame is checked to be a real image. |
Retries on navigation and on 503 | The proxy can drop a tunnel and a browser can be momentarily out of capacity; both are retried. |
How it works
- Launch the browser, open a page and start the screencast with a callback that tags every frame with the current phase.
- Load the article, scroll it, then wait three seconds without doing anything.
- 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:
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, 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));
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import json
import os
import tempfile
import threading
import time
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
DIR = tempfile.mkdtemp(prefix="cdpfleet-live-")
TARGETS = [
{"browser": "chromium", "body": {"headless": "new"}},
{"browser": "firefox", "body": {"headless": True}},
]
def watch(p, target):
engine = target["browser"]
for attempt in range(1, 6): # 503 = momentarily no capacity for this browser
res = requests.post(f"https://starter.cdpfleet.com/{engine}/session", headers={"x-api-key": KEY}, timeout=60,
json={"proxy": os.environ["PROXY_URL"], **target["body"]})
if res.status_code != 503:
break
time.sleep(2 * attempt)
if not res.ok:
return {"browser": engine, "error": f"launch {res.status_code} {res.text}"}
browser_type = p.chromium if engine == "chromium" else p.firefox
browser = browser_type.connect(res.json()["wsUrl"], headers={"x-api-key": KEY})
try:
page = browser.new_page()
frames = [] # every frame, tagged with the phase it arrived in
lock = threading.Lock()
state = {"phase": "load"}
# Every frame arrives here as JPEG bytes — this is where a viewer would get it.
def on_frame(frame):
data = frame["data"]
with lock:
frames.append({"phase": state["phase"], "bytes": len(data), "jpeg": data[:2] == b"\xff\xd8",
"w": frame["viewportWidth"], "h": frame["viewportHeight"], "data": data})
page.screencast.start(quality=60, on_frame=on_frame)
for attempt in range(1, 4): # the proxy can drop a tunnel; retry
try:
page.goto("https://en.wikipedia.org/wiki/Web_browser", timeout=60000)
break
except Exception:
if attempt == 3:
raise
state["phase"] = "scroll"
t = time.time()
for _ in range(10):
page.mouse.wheel(0, 500)
page.wait_for_timeout(400)
scrolling = time.time() - t
state["phase"] = "idle" # nothing changes on the page now
page.wait_for_timeout(3000)
page.screencast.stop()
with lock:
got = list(frames)
count = lambda phase: sum(1 for f in got if f["phase"] == phase)
last = got[-1] if got else None
if last:
with open(os.path.join(DIR, f"{engine}.jpg"), "wb") as fh:
fh.write(last["data"])
return {
"browser": engine,
"frames_while_loading": count("load"),
"frames_while_scrolling": count("scroll"),
"fps_while_scrolling": round(count("scroll") / scrolling, 1),
"frames_in_3s_idle": count("idle"),
"avg_kb": round(sum(f["bytes"] for f in got) / max(len(got), 1) / 1024),
"all_jpeg": all(f["jpeg"] for f in got),
"viewport": f"{last['w']}x{last['h']}" if last else None,
}
finally:
browser.close()
with sync_playwright() as p:
out = [watch(p, t) for t in TARGETS]
print(json.dumps(out, 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.http.*;
import java.nio.file.*;
import java.util.*;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().serializeNulls().create();
static final HttpClient HTTP = HttpClient.newHttpClient();
record Frame(String phase, int bytes, boolean jpeg, int w, int h, byte[] data) {}
static JsonObject watch(Playwright playwright, String engine, String headless) throws Exception {
String body = "{\"proxy\": " + GSON.toJson(System.getenv("PROXY_URL")) + ", \"headless\": " + headless + "}";
HttpResponse<String> res = null;
for (int attempt = 1; attempt <= 5; attempt++) { // 503 = momentarily no capacity for this browser
res = HTTP.send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/" + engine + "/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 503) break;
Thread.sleep(2000L * attempt);
}
if (res.statusCode() != 200) {
JsonObject err = new JsonObject();
err.addProperty("browser", engine);
err.addProperty("error", "launch " + res.statusCode() + " " + res.body());
return err;
}
String wsUrl = JsonParser.parseString(res.body()).getAsJsonObject().get("wsUrl").getAsString();
BrowserType type = engine.equals("chromium") ? playwright.chromium() : playwright.firefox();
Browser browser = type.connect(wsUrl, new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
try {
Page page = browser.newPage();
List<Frame> frames = Collections.synchronizedList(new ArrayList<>()); // every frame, tagged with its phase
String[] phase = { "load" };
// Every frame arrives here as JPEG bytes — this is where a viewer would get it.
page.screencast().start(new Screencast.StartOptions().setQuality(60).setOnFrame(frame -> {
byte[] data = frame.data();
boolean jpeg = data.length > 1 && (data[0] & 0xff) == 0xff && (data[1] & 0xff) == 0xd8;
frames.add(new Frame(phase[0], data.length, jpeg, frame.viewportWidth(), frame.viewportHeight(), data));
}));
for (int attempt = 1; ; attempt++) { // the proxy can drop a tunnel; retry
try {
page.navigate("https://en.wikipedia.org/wiki/Web_browser", new Page.NavigateOptions().setTimeout(60000));
break;
} catch (PlaywrightException e) {
if (attempt == 3) throw e;
}
}
phase[0] = "scroll";
long t = System.currentTimeMillis();
for (int i = 0; i < 10; i++) {
page.mouse().wheel(0, 500);
page.waitForTimeout(400);
}
double scrolling = (System.currentTimeMillis() - t) / 1000.0;
phase[0] = "idle"; // nothing changes on the page now
page.waitForTimeout(3000);
page.screencast().stop();
List<Frame> got;
synchronized (frames) { got = new ArrayList<>(frames); }
long scroll = got.stream().filter(f -> f.phase().equals("scroll")).count();
Frame last = got.isEmpty() ? null : got.get(got.size() - 1);
if (last != null) Files.write(Path.of(System.getProperty("java.io.tmpdir"), "cdpfleet-live-" + engine + ".jpg"), last.data());
long totalBytes = got.stream().mapToLong(Frame::bytes).sum();
JsonObject row = new JsonObject();
row.addProperty("browser", engine);
row.addProperty("frames_while_loading", got.stream().filter(f -> f.phase().equals("load")).count());
row.addProperty("frames_while_scrolling", scroll);
row.addProperty("fps_while_scrolling", Math.round(scroll / scrolling * 10) / 10.0);
row.addProperty("frames_in_3s_idle", got.stream().filter(f -> f.phase().equals("idle")).count());
row.addProperty("avg_kb", Math.round((double) totalBytes / Math.max(got.size(), 1) / 1024));
row.addProperty("all_jpeg", got.stream().allMatch(Frame::jpeg));
if (last != null) row.addProperty("viewport", last.w() + "x" + last.h());
else row.add("viewport", JsonNull.INSTANCE);
return row;
} finally {
browser.close();
}
}
public static void main(String[] args) throws Exception {
JsonArray out = new JsonArray();
try (Playwright playwright = Playwright.create()) {
out.add(watch(playwright, "chromium", "\"new\""));
out.add(watch(playwright, "firefox", "true"));
}
System.out.println(GSON.toJson(out));
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
using System.Diagnostics;
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")!;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();
var output = new JsonArray
{
await Watch("chromium", "new"),
await Watch("firefox", true),
};
Console.WriteLine(output.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
async Task<JsonObject> Watch(string engine, object headless)
{
HttpResponseMessage res = null!;
for (var attempt = 1; attempt <= 5; attempt++) // 503 = momentarily no capacity for this browser
{
res = await http.PostAsJsonAsync($"https://starter.cdpfleet.com/{engine}/session",
new { proxy = Environment.GetEnvironmentVariable("PROXY_URL"), headless });
if ((int)res.StatusCode != 503) break;
await Task.Delay(2000 * attempt);
}
if (!res.IsSuccessStatusCode)
return new JsonObject { ["browser"] = engine, ["error"] = $"launch {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}" };
var wsUrl = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("wsUrl").GetString()!;
var type = engine == "chromium" ? playwright.Chromium : playwright.Firefox;
var browser = await type.ConnectAsync(wsUrl, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
try
{
var page = await browser.NewPageAsync();
var frames = new List<(string Phase, int Bytes, bool Jpeg, int W, int H, byte[] Data)>(); // every frame, tagged with its phase
var gate = new object();
var phase = "load";
// The 1.60 .NET client leaves ScreencastFrame.ViewportWidth/Height at 0; fall back to the page's viewport.
var viewport = page.ViewportSize;
// Every frame arrives here as JPEG bytes — this is where a viewer would get it.
await page.Screencast.StartAsync(new ScreencastStartOptions
{
Quality = 60,
OnFrame = frame =>
{
var data = frame.Data;
lock (gate) frames.Add((Volatile.Read(ref phase), data.Length, data.Length > 1 && data[0] == 0xff && data[1] == 0xd8, frame.ViewportWidth > 0 ? frame.ViewportWidth : viewport?.Width ?? 0, frame.ViewportHeight > 0 ? frame.ViewportHeight : viewport?.Height ?? 0, data));
return Task.CompletedTask;
},
});
for (var attempt = 1; ; attempt++) // the proxy can drop a tunnel; retry
{
try { await page.GotoAsync("https://en.wikipedia.org/wiki/Web_browser", new() { Timeout = 60000 }); break; }
catch (Exception e) when (e is PlaywrightException or TimeoutException) { if (attempt == 3) throw; }
}
Volatile.Write(ref phase, "scroll");
var sw = Stopwatch.StartNew();
for (var i = 0; i < 10; i++)
{
await page.Mouse.WheelAsync(0, 500);
await page.WaitForTimeoutAsync(400);
}
var scrolling = sw.Elapsed.TotalSeconds;
Volatile.Write(ref phase, "idle"); // nothing changes on the page now
await page.WaitForTimeoutAsync(3000);
await page.Screencast.StopAsync();
List<(string Phase, int Bytes, bool Jpeg, int W, int H, byte[] Data)> got;
lock (gate) got = frames.ToList();
int Count(string p) => got.Count(f => f.Phase == p);
var last = got.Count > 0 ? got[^1] : default;
if (got.Count > 0) await File.WriteAllBytesAsync(Path.Combine(Path.GetTempPath(), $"cdpfleet-live-{engine}.jpg"), last.Data);
return new JsonObject
{
["browser"] = engine,
["frames_while_loading"] = Count("load"),
["frames_while_scrolling"] = Count("scroll"),
["fps_while_scrolling"] = Math.Round(Count("scroll") / scrolling, 1, MidpointRounding.AwayFromZero),
["frames_in_3s_idle"] = Count("idle"),
["avg_kb"] = (long)Math.Floor((double)got.Sum(f => (long)f.Bytes) / Math.Max(got.Count, 1) / 1024 + 0.5),
["all_jpeg"] = got.All(f => f.Jpeg),
["viewport"] = got.Count > 0 ? $"{last.W}x{last.H}" : null,
};
}
finally
{
await browser.CloseAsync();
}
}
// 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"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"path/filepath"
"sync"
"time"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
type row struct {
Browser string `json:"browser"`
FramesWhileLoading int `json:"frames_while_loading"`
FramesWhileScrolling int `json:"frames_while_scrolling"`
FpsWhileScrolling float64 `json:"fps_while_scrolling"`
FramesIn3sIdle int `json:"frames_in_3s_idle"`
AvgKB int `json:"avg_kb"`
AllJPEG bool `json:"all_jpeg"`
Viewport *string `json:"viewport"`
}
type errRow struct {
Browser string `json:"browser"`
Error string `json:"error"`
}
type frame struct {
phase string
bytes int
jpeg bool
w, h int
data []byte
}
func must[T any](v T, err error) T {
if err != nil {
log.Fatal(err)
}
return v
}
func watch(pw *playwright.Playwright, engine string, headless any) any {
body, _ := json.Marshal(map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": headless})
var res *http.Response
for attempt := 1; attempt <= 5; attempt++ { // 503 = momentarily no capacity for this browser
req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+engine+"/session", bytes.NewReader(body))
req.Header.Set("x-api-key", key)
req.Header.Set("content-type", "application/json")
res = must(http.DefaultClient.Do(req))
if res.StatusCode != 503 {
break
}
res.Body.Close()
time.Sleep(time.Duration(2*attempt) * time.Second)
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
if res.StatusCode != 200 {
return errRow{Browser: engine, Error: fmt.Sprintf("launch %d %s", res.StatusCode, raw)}
}
var session struct {
WsURL string `json:"wsUrl"`
}
must(0, json.Unmarshal(raw, &session))
browserType := pw.Chromium
if engine == "firefox" {
browserType = pw.Firefox
}
browser := must(browserType.Connect(session.WsURL, playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}}))
defer browser.Close()
page := must(browser.NewPage())
var mu sync.Mutex
var frames []frame // every frame, tagged with the phase it arrived in
phase := "load"
screencast := must(page.Screencast())
// Every frame arrives here as JPEG bytes — this is where a viewer would get it.
must(0, screencast.Start(playwright.ScreencastStartOptions{
Quality: playwright.Int(60),
OnFrame: func(f playwright.OnFrame) {
mu.Lock()
defer mu.Unlock()
jpeg := len(f.Data) > 1 && f.Data[0] == 0xff && f.Data[1] == 0xd8
frames = append(frames, frame{phase: phase, bytes: len(f.Data), jpeg: jpeg, w: f.ViewportWidth, h: f.ViewportHeight, data: f.Data})
},
}))
setPhase := func(p string) { mu.Lock(); phase = p; mu.Unlock() }
for attempt := 1; ; attempt++ { // the proxy can drop a tunnel; retry
_, err := page.Goto("https://en.wikipedia.org/wiki/Web_browser", playwright.PageGotoOptions{Timeout: playwright.Float(60000)})
if err == nil {
break
}
if attempt == 3 {
log.Fatal(err)
}
}
setPhase("scroll")
t := time.Now()
for i := 0; i < 10; i++ {
must(0, page.Mouse().Wheel(0, 500))
page.WaitForTimeout(400)
}
scrolling := time.Since(t).Seconds()
setPhase("idle") // nothing changes on the page now
page.WaitForTimeout(3000)
must(0, screencast.Stop())
mu.Lock()
got := append([]frame(nil), frames...)
mu.Unlock()
count := func(p string) int {
n := 0
for _, f := range got {
if f.phase == p {
n++
}
}
return n
}
out := row{Browser: engine, FramesWhileLoading: count("load"), FramesWhileScrolling: count("scroll"),
FpsWhileScrolling: math.Round(float64(count("scroll"))/scrolling*10) / 10, FramesIn3sIdle: count("idle"), AllJPEG: true}
total := 0
for _, f := range got {
total += f.bytes
out.AllJPEG = out.AllJPEG && f.jpeg
}
out.AvgKB = int(math.Floor(float64(total)/math.Max(float64(len(got)), 1)/1024 + 0.5))
if len(got) > 0 {
last := got[len(got)-1]
vp := fmt.Sprintf("%dx%d", last.w, last.h)
out.Viewport = &vp
os.WriteFile(filepath.Join(os.TempDir(), "cdpfleet-live-"+engine+".jpg"), last.data, 0o644)
}
return out
}
func main() {
opts := &playwright.RunOptions{SkipInstallBrowsers: true}
pw := must(playwright.Run(opts))
defer pw.Stop()
out := []any{watch(pw, "chromium", "new"), watch(pw, "firefox", true)}
enc := json.NewEncoder(os.Stdout)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
enc.Encode(out)
}
What we got
| Browser | Frames while loading | Frames while scrolling | fps while scrolling | Frames in 3 s idle | Avg KB / frame | All JPEG | Viewport |
|---|---|---|---|---|---|---|---|
| chromium | 52 | 32 | 7.6 | 1 | 40 | yes | 1280x720 |
| firefox | 320 | 95 | 21.6 | 68 | 42 | yes | 1280x720 |
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
- It just works over the session connection: real JPEG frames at the page's 1280×720 viewport, from both engines, with no launch option and no extra service.
- Chromium sends frames only when pixels change: a couple of frames a second while scrolling through a proxy, and none at all on an idle page — cheap enough to leave on.
- Firefox streams continuously: many more frames while scrolling — and it kept sending them on an idle page (dozens in three seconds; we measured the same on a completely static page). Camoufox uses the same engine. Throttle in your callback if you only need a preview.
- Budget the bytes: about 40–50 KB per frame at quality 60 and full viewport. Lower
quality, or in Node.js passsizeto scale frames down, when you relay them. - View-only, your connection only: frames go to the client that drives the page; to show them elsewhere, forward them (a WebSocket relay is ten lines).