Cases / #12 · 2026-10-01 · Medium
Files across the wire: uploading to and downloading from a remote browser
The browser runs on our servers, your files live on your machine. setInputFiles and download.saveAs move them both ways — byte for byte.
Run on production on 2026-10-01: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
Automations often upload a document (a CSV import, a profile photo) or download one (an invoice, an export). With a remote browser, the upload has to come from your disk and the download has to end up on your disk — not on the server running the browser. Does that work transparently, and are the files intact?
What we used, and why
| What | Why |
|---|---|
page.setInputFiles(selector, path) | Reads the file on your machine and streams it to the remote browser's file input. |
page.waitForEvent('download') | Catches the download the click starts; the file is first saved on the browser's server. |
download.saveAs(path) | Streams the downloaded file from the server to your machine. |
page.route on httpbin.org | Serves a tiny page with an upload form and a download link on a real origin; httpbin echoes uploads back. |
/bytes/102400?seed=42 | httpbin's seeded random bytes: the same seed always gives the same 100 KB, so the download can be checked against a direct copy. |
How it works
- Write a ~60 KB CSV locally and hash it.
- Set it into the page's file input and submit; compare the hash of what the server received.
- Click the download link, save the file locally and compare its hash with the same bytes fetched directly.
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';
import { createHash, randomBytes } from 'node:crypto';
import { writeFileSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
const KEY = process.env.CDPFLEET_API_KEY;
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
// A local file to upload: 2,000 CSV rows (~60 KB).
const uploadPath = path.join(tmpdir(), 'cdpfleet-upload.csv');
const rows = ['id,token'];
for (let i = 1; i <= 2000; i++) rows.push(`${i},${randomBytes(12).toString('hex')}`);
writeFileSync(uploadPath, `${rows.join('\n')}\n`);
const local = readFileSync(uploadPath);
const res = await fetch('https://starter.cdpfleet.com/chromium/session', {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify({ proxy: process.env.PROXY_URL, headless: true }),
});
if (!res.ok) throw new Error(`launch: ${res.status} ${await res.text()}`);
const { wsUrl } = await res.json();
const browser = await chromium.connect(wsUrl, { headers: { 'x-api-key': KEY } });
// A small page on httpbin.org's origin with an upload form and a download link.
const PAGE = `<form method="post" action="/anything" enctype="multipart/form-data">
<input type="file" name="upload" id="file"><button id="send">Send</button></form>
<a id="data" href="/bytes/102400?seed=42" download="data.bin">data</a>`;
try {
const page = await browser.newPage();
await page.route('https://httpbin.org/files-demo', (route) => route.fulfill({ contentType: 'text/html', body: PAGE }));
await page.goto('https://httpbin.org/files-demo', { timeout: 60000 });
// Upload: setInputFiles reads the file HERE and streams it to the remote browser.
let t = Date.now();
await page.setInputFiles('#file', uploadPath);
const [answer] = await Promise.all([page.waitForNavigation({ timeout: 60000 }), page.click('#send')]);
const echoed = (await answer.json()).files.upload;
const uploadMs = Date.now() - t;
// Download: the file lands on the remote server; saveAs streams it back to this machine.
await page.goto('https://httpbin.org/files-demo', { timeout: 60000 });
t = Date.now();
const [download] = await Promise.all([page.waitForEvent('download', { timeout: 60000 }), page.click('#data')]);
const downloadPath = path.join(tmpdir(), download.suggestedFilename());
await download.saveAs(downloadPath);
const downloadMs = Date.now() - t;
const got = readFileSync(downloadPath);
// The same seeded bytes fetched directly from here, to prove the copy is exact.
const direct = Buffer.from(await (await fetch('https://httpbin.org/bytes/102400?seed=42')).arrayBuffer());
console.log(JSON.stringify({
upload: {
local_file: path.basename(uploadPath), bytes: local.length, sha256: sha256(local),
server_received_bytes: Buffer.byteLength(echoed), server_sha256: sha256(Buffer.from(echoed)),
identical: sha256(local) === sha256(Buffer.from(echoed)), ms: uploadMs,
},
download: {
suggested_filename: download.suggestedFilename(), bytes: got.length, sha256: sha256(got),
direct_sha256: sha256(direct), identical: sha256(got) === sha256(direct), ms: downloadMs,
},
}, null, 2));
} finally {
await browser.close();
}
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import hashlib
import json
import os
import secrets
import tempfile
import time
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
sha256 = lambda b: hashlib.sha256(b).hexdigest()
# A local file to upload: 2,000 CSV rows (~60 KB).
upload_path = os.path.join(tempfile.gettempdir(), "cdpfleet-upload.csv")
with open(upload_path, "w") as f:
f.write("id,token\n" + "".join(f"{i},{secrets.token_hex(12)}\n" for i in range(1, 2001)))
local = open(upload_path, "rb").read()
res = requests.post("https://starter.cdpfleet.com/chromium/session", headers={"x-api-key": KEY},
json={"proxy": os.environ["PROXY_URL"], "headless": True}, timeout=60)
res.raise_for_status()
# A small page on httpbin.org's origin with an upload form and a download link.
PAGE = """<form method="post" action="/anything" enctype="multipart/form-data">
<input type="file" name="upload" id="file"><button id="send">Send</button></form>
<a id="data" href="/bytes/102400?seed=42" download="data.bin">data</a>"""
with sync_playwright() as p:
browser = p.chromium.connect(res.json()["wsUrl"], headers={"x-api-key": KEY})
try:
page = browser.new_page()
page.route("https://httpbin.org/files-demo", lambda route: route.fulfill(content_type="text/html", body=PAGE))
page.goto("https://httpbin.org/files-demo", timeout=60000)
# Upload: set_input_files reads the file HERE and streams it to the remote browser.
t = time.time()
page.set_input_files("#file", upload_path)
with page.expect_navigation(timeout=60000) as nav:
page.click("#send")
echoed = nav.value.json()["files"]["upload"].encode()
upload_ms = round((time.time() - t) * 1000)
# Download: the file lands on the remote server; save_as streams it back here.
page.goto("https://httpbin.org/files-demo", timeout=60000)
t = time.time()
with page.expect_download(timeout=60000) as dl:
page.click("#data")
download_path = os.path.join(tempfile.gettempdir(), dl.value.suggested_filename)
dl.value.save_as(download_path)
download_ms = round((time.time() - t) * 1000)
got = open(download_path, "rb").read()
# The same seeded bytes fetched directly from here, to prove the copy is exact.
direct = requests.get("https://httpbin.org/bytes/102400?seed=42", timeout=60).content
print(json.dumps({
"upload": {"local_file": os.path.basename(upload_path), "bytes": len(local), "sha256": sha256(local),
"server_received_bytes": len(echoed), "server_sha256": sha256(echoed),
"identical": sha256(local) == sha256(echoed), "ms": upload_ms},
"download": {"suggested_filename": dl.value.suggested_filename, "bytes": len(got), "sha256": sha256(got),
"direct_sha256": sha256(direct), "identical": sha256(got) == sha256(direct), "ms": download_ms},
}, indent=2))
finally:
browser.close()
// 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.charset.StandardCharsets;
import java.nio.file.*;
import java.security.*;
import java.util.*;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final Path TMP = Paths.get(System.getProperty("java.io.tmpdir"));
static String sha256(byte[] b) throws Exception {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(b));
}
// A small page on httpbin.org's origin with an upload form and a download link.
static final String PAGE = """
<form method="post" action="/anything" enctype="multipart/form-data">
<input type="file" name="upload" id="file"><button id="send">Send</button></form>
<a id="data" href="/bytes/102400?seed=42" download="data.bin">data</a>""";
public static void main(String[] args) throws Exception {
// A local file to upload: 2,000 CSV rows (~60 KB).
Path uploadPath = TMP.resolve("cdpfleet-upload.csv");
StringBuilder csv = new StringBuilder("id,token\n");
SecureRandom rnd = new SecureRandom();
for (int i = 1; i <= 2000; i++) {
byte[] token = new byte[12];
rnd.nextBytes(token);
csv.append(i).append(',').append(HexFormat.of().formatHex(token)).append('\n');
}
Files.writeString(uploadPath, csv);
byte[] local = Files.readAllBytes(uploadPath);
HttpClient http = HttpClient.newHttpClient();
String body = "{\"proxy\": " + new Gson().toJson(System.getenv("PROXY_URL")) + ", \"headless\": true}";
HttpResponse<String> res = http.send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/chromium/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new RuntimeException("launch: " + res.statusCode() + " " + res.body());
String wsUrl = JsonParser.parseString(res.body()).getAsJsonObject().get("wsUrl").getAsString();
try (Playwright playwright = Playwright.create()) {
Browser browser = playwright.chromium().connect(wsUrl, new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
try {
Page page = browser.newPage();
page.route("https://httpbin.org/files-demo", route -> route.fulfill(new Route.FulfillOptions().setContentType("text/html").setBody(PAGE)));
page.navigate("https://httpbin.org/files-demo", new Page.NavigateOptions().setTimeout(60000));
// Upload: setInputFiles reads the file HERE and streams it to the remote browser.
long t = System.currentTimeMillis();
page.setInputFiles("#file", uploadPath);
Response answer = page.waitForNavigation(new Page.WaitForNavigationOptions().setTimeout(60000), () -> page.click("#send"));
byte[] echoed = JsonParser.parseString(answer.text()).getAsJsonObject().getAsJsonObject("files").get("upload").getAsString()
.getBytes(StandardCharsets.UTF_8);
long uploadMs = System.currentTimeMillis() - t;
// Download: the file lands on the remote server; saveAs streams it back here.
page.navigate("https://httpbin.org/files-demo", new Page.NavigateOptions().setTimeout(60000));
t = System.currentTimeMillis();
Download download = page.waitForDownload(new Page.WaitForDownloadOptions().setTimeout(60000), () -> page.click("#data"));
Path downloadPath = TMP.resolve(download.suggestedFilename());
download.saveAs(downloadPath);
long downloadMs = System.currentTimeMillis() - t;
byte[] got = Files.readAllBytes(downloadPath);
// The same seeded bytes fetched directly from here, to prove the copy is exact.
byte[] direct = http.send(HttpRequest.newBuilder(URI.create("https://httpbin.org/bytes/102400?seed=42")).build(),
HttpResponse.BodyHandlers.ofByteArray()).body();
JsonObject up = new JsonObject();
up.addProperty("local_file", uploadPath.getFileName().toString());
up.addProperty("bytes", local.length);
up.addProperty("sha256", sha256(local));
up.addProperty("server_received_bytes", echoed.length);
up.addProperty("server_sha256", sha256(echoed));
up.addProperty("identical", sha256(local).equals(sha256(echoed)));
up.addProperty("ms", uploadMs);
JsonObject down = new JsonObject();
down.addProperty("suggested_filename", download.suggestedFilename());
down.addProperty("bytes", got.length);
down.addProperty("sha256", sha256(got));
down.addProperty("direct_sha256", sha256(direct));
down.addProperty("identical", sha256(got).equals(sha256(direct)));
down.addProperty("ms", downloadMs);
JsonObject out = new JsonObject();
out.add("upload", up);
out.add("download", down);
System.out.println(new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create().toJson(out));
} finally {
browser.close();
}
}
}
}
// 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.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
string Sha256(byte[] b) => Convert.ToHexString(SHA256.HashData(b)).ToLowerInvariant();
// A local file to upload: 2,000 CSV rows (~60 KB).
var uploadPath = Path.Combine(Path.GetTempPath(), "cdpfleet-upload.csv");
var csv = new StringBuilder("id,token\n");
for (var i = 1; i <= 2000; i++) csv.Append($"{i},{Convert.ToHexString(RandomNumberGenerator.GetBytes(12)).ToLowerInvariant()}\n");
await File.WriteAllTextAsync(uploadPath, csv.ToString());
var local = await File.ReadAllBytesAsync(uploadPath);
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
var res = await http.PostAsJsonAsync("https://starter.cdpfleet.com/chromium/session",
new { proxy = Environment.GetEnvironmentVariable("PROXY_URL"), headless = true });
if (!res.IsSuccessStatusCode) throw new Exception($"launch: {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
var wsUrl = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("wsUrl").GetString()!;
// A small page on httpbin.org's origin with an upload form and a download link.
const string PageHtml = """
<form method="post" action="/anything" enctype="multipart/form-data">
<input type="file" name="upload" id="file"><button id="send">Send</button></form>
<a id="data" href="/bytes/102400?seed=42" download="data.bin">data</a>
""";
using var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.ConnectAsync(wsUrl, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
try
{
var page = await browser.NewPageAsync();
await page.RouteAsync("https://httpbin.org/files-demo", route => route.FulfillAsync(new() { ContentType = "text/html", Body = PageHtml }));
await page.GotoAsync("https://httpbin.org/files-demo", new() { Timeout = 60000 });
// Upload: SetInputFilesAsync reads the file HERE and streams it to the remote browser.
var sw = Stopwatch.StartNew();
await page.SetInputFilesAsync("#file", uploadPath);
var answer = await page.RunAndWaitForNavigationAsync(() => page.ClickAsync("#send"), new() { Timeout = 60000 });
var echoed = Encoding.UTF8.GetBytes((string)JsonNode.Parse(await answer!.TextAsync())!["files"]!["upload"]!);
var uploadMs = sw.ElapsedMilliseconds;
// Download: the file lands on the remote server; SaveAsAsync streams it back here.
await page.GotoAsync("https://httpbin.org/files-demo", new() { Timeout = 60000 });
sw.Restart();
var download = await page.RunAndWaitForDownloadAsync(() => page.ClickAsync("#data"), new() { Timeout = 60000 });
var downloadPath = Path.Combine(Path.GetTempPath(), download.SuggestedFilename);
await download.SaveAsAsync(downloadPath);
var downloadMs = sw.ElapsedMilliseconds;
var got = await File.ReadAllBytesAsync(downloadPath);
// The same seeded bytes fetched directly from here, to prove the copy is exact.
var direct = await http.GetByteArrayAsync("https://httpbin.org/bytes/102400?seed=42");
var result = new JsonObject
{
["upload"] = new JsonObject
{
["local_file"] = Path.GetFileName(uploadPath), ["bytes"] = local.Length, ["sha256"] = Sha256(local),
["server_received_bytes"] = echoed.Length, ["server_sha256"] = Sha256(echoed),
["identical"] = Sha256(local) == Sha256(echoed), ["ms"] = uploadMs,
},
["download"] = new JsonObject
{
["suggested_filename"] = download.SuggestedFilename, ["bytes"] = got.Length, ["sha256"] = Sha256(got),
["direct_sha256"] = Sha256(direct), ["identical"] = Sha256(got) == Sha256(direct), ["ms"] = downloadMs,
},
};
Console.WriteLine(result.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
}
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"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
func launch(name string, options map[string]any) (map[string]any, error) {
body, _ := json.Marshal(options)
req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+name+"/session", bytes.NewReader(body))
req.Header.Set("x-api-key", key)
req.Header.Set("content-type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(res.Body)
return nil, fmt.Errorf("launch %s: %s %s", name, res.Status, msg)
}
var session map[string]any
return session, json.NewDecoder(res.Body).Decode(&session)
}
func must[T any](v T, err error) T {
if err != nil {
log.Fatal(err)
}
return v
}
func sum(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) }
// A small page on httpbin.org's origin with an upload form and a download link.
const pageHTML = `<form method="post" action="/anything" enctype="multipart/form-data">
<input type="file" name="upload" id="file"><button id="send">Send</button></form>
<a id="data" href="/bytes/102400?seed=42" download="data.bin">data</a>`
func main() {
// A local file to upload: 2,000 CSV rows (~60 KB).
uploadPath := filepath.Join(os.TempDir(), "cdpfleet-upload.csv")
var csv strings.Builder
csv.WriteString("id,token\n")
for i := 1; i <= 2000; i++ {
token := make([]byte, 12)
rand.Read(token)
fmt.Fprintf(&csv, "%d,%s\n", i, hex.EncodeToString(token))
}
must(0, os.WriteFile(uploadPath, []byte(csv.String()), 0o600))
local := must(os.ReadFile(uploadPath))
session := must(launch("chromium", map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": true}))
pw := must(playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true}))
defer pw.Stop()
browser := must(pw.Chromium.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}}))
defer browser.Close()
page := must(browser.NewPage())
must(0, page.Route("https://httpbin.org/files-demo", func(route playwright.Route) {
route.Fulfill(playwright.RouteFulfillOptions{ContentType: playwright.String("text/html"), Body: pageHTML})
}))
goTo := func() {
must(page.Goto("https://httpbin.org/files-demo", playwright.PageGotoOptions{Timeout: playwright.Float(60000)}))
}
goTo()
// Upload: SetInputFiles reads the file HERE and streams it to the remote browser.
t := time.Now()
must(0, page.SetInputFiles("#file", uploadPath))
answer := must(page.ExpectNavigation(func() error { return page.Click("#send") }, playwright.PageExpectNavigationOptions{Timeout: playwright.Float(60000)}))
var echo struct {
Files struct{ Upload string } `json:"files"`
}
must(0, answer.JSON(&echo))
echoed := []byte(echo.Files.Upload)
uploadMs := time.Since(t).Milliseconds()
// Download: the file lands on the remote server; SaveAs streams it back here.
goTo()
t = time.Now()
download := must(page.ExpectDownload(func() error { return page.Click("#data") }, playwright.PageExpectDownloadOptions{Timeout: playwright.Float(60000)}))
downloadPath := filepath.Join(os.TempDir(), download.SuggestedFilename())
must(0, download.SaveAs(downloadPath))
downloadMs := time.Since(t).Milliseconds()
got := must(os.ReadFile(downloadPath))
// The same seeded bytes fetched directly from here, to prove the copy is exact.
res := must(http.Get("https://httpbin.org/bytes/102400?seed=42"))
direct := must(io.ReadAll(res.Body))
res.Body.Close()
out, _ := json.MarshalIndent(map[string]any{
"upload": map[string]any{"local_file": filepath.Base(uploadPath), "bytes": len(local), "sha256": sum(local),
"server_received_bytes": len(echoed), "server_sha256": sum(echoed), "identical": sum(local) == sum(echoed), "ms": uploadMs},
"download": map[string]any{"suggested_filename": download.SuggestedFilename(), "bytes": len(got), "sha256": sum(got),
"direct_sha256": sum(direct), "identical": sum(got) == sum(direct), "ms": downloadMs},
}, "", " ")
fmt.Println(string(out))
}
What we got
| Direction | Bytes | Identical | Time (ms) |
|---|---|---|---|
| upload cdpfleet-upload.csv | 58902 | yes | 3134 |
| download data.bin | 102400 | yes | 1286 |
From the Node.js run on 2026-10-01. 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)
{
"upload": {
"local_file": "cdpfleet-upload.csv",
"bytes": 58902,
"sha256": "f86a1140019393e500309dc68217d77ea6410128cc3dc8630629f423dacbaadd",
"server_received_bytes": 58902,
"server_sha256": "f86a1140019393e500309dc68217d77ea6410128cc3dc8630629f423dacbaadd",
"identical": true,
"ms": 3134
},
"download": {
"suggested_filename": "data.bin",
"bytes": 102400,
"sha256": "3281a765f460e525539f06ff6f9811b0c5d3a081d30d50a50d90a10d6cfa78d1",
"direct_sha256": "3281a765f460e525539f06ff6f9811b0c5d3a081d30d50a50d90a10d6cfa78d1",
"identical": true,
"ms": 1286
}
}Takeaways
- Both directions are byte-exact: the uploaded CSV and the downloaded file hash identically on both ends.
- No extra work for "remote": the same Playwright calls you'd use locally; the client streams the files over the session's WebSocket.
- Files never reach your machine on their own: a download stays on the server until you call
saveAs(or readcreateReadStream) — and is deleted with the session. - Big files cost transfer time over the WebSocket and proxy bandwidth for what the page itself downloads; check both before moving gigabytes.