Cases / #14 · 2026-10-01 · Medium
What your proxy costs you in page speed
The same page, cold, through three proxies: connection setup, time to first byte, largest paint and full load — measured by the browser itself.
Run on production on 2026-10-01: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
Slow scrapes are usually blamed on the browser, but most of the time goes into the network path through the proxy. How much does the proxy choice change the numbers that matter — and is "residential" always the slow one?
What we used, and why
| What | Why |
|---|---|
| Three sessions in parallel | Random residential, residential in Germany, datacenter SOCKS5 — the same page through each. |
| A fresh context per load | Cold cache and new connections every time, so every load pays the full setup. |
Navigation Timing (performance.getEntriesByType('navigation')) | Connection setup through the proxy tunnel, time to first byte, DOM ready and load, as the browser recorded them. |
PerformanceObserver for largest-contentful-paint | When the main content appeared — closest to "the page is usable". |
| Median of 3 loads | Residential exits vary; the median smooths one-off spikes. |
How it works
- Launch one session per proxy, all at once.
- Load the page three times per proxy, each in a new context, and read the timings in the page.
- Report the median per proxy.
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]:8000PROXY_URL_DE— a proxy exit in GermanySOCKS_PROXIES— comma-separatedsocks5://user:pass@host:portproxies
// npm install [email protected]
// env: CDPFLEET_API_KEY, PROXY_URL, PROXY_URL_DE, SOCKS_PROXIES (comma-separated socks5:// URLs)
import { chromium } from 'playwright';
const KEY = process.env.CDPFLEET_API_KEY;
const PAGE = 'https://en.wikipedia.org/wiki/Web_browser';
const LOADS = 3; // per proxy, each in a fresh context (cold cache, new connections)
const PROXIES = {
'residential (any country)': process.env.PROXY_URL,
'residential (Germany)': process.env.PROXY_URL_DE,
'datacenter SOCKS5': process.env.SOCKS_PROXIES.split(',')[0],
};
// Navigation Timing + Largest Contentful Paint, read in the page after load.
const TIMINGS = () => new Promise((done) => {
const nav = performance.getEntriesByType('navigation')[0];
new PerformanceObserver((list) => {
const lcp = list.getEntries().at(-1);
done({
connect: nav.connectEnd - nav.connectStart, // TCP + TLS through the proxy tunnel
ttfb: nav.responseStart - nav.requestStart, // first byte after the request was sent
domReady: nav.domContentLoadedEventEnd,
load: nav.loadEventEnd,
lcp: lcp.startTime,
});
}).observe({ type: 'largest-contentful-paint', buffered: true });
});
const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)];
async function measure(label, proxy) {
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, headless: true }),
});
if (!res.ok) return { proxy: label, error: `launch ${res.status}` };
const { wsUrl } = await res.json();
const browser = await chromium.connect(wsUrl, { headers: { 'x-api-key': KEY } });
try {
const runs = [];
for (let i = 0, failures = 0; i < LOADS; i++) {
const context = await browser.newContext();
try {
const page = await context.newPage();
await page.goto(PAGE, { waitUntil: 'load', timeout: 60000 });
runs.push(await page.evaluate(TIMINGS));
} catch (err) {
if (++failures > 1) throw err; // one failed load is the proxy's noise; two is a problem
i--;
} finally {
await context.close();
}
}
const m = (k) => Math.round(median(runs.map((r) => r[k])));
return { proxy: label, loads: runs.length, connect_ms: m('connect'), ttfb_ms: m('ttfb'), dom_ready_ms: m('domReady'), lcp_ms: m('lcp'), load_ms: m('load') };
} finally {
await browser.close();
}
}
const rows = await Promise.all(Object.entries(PROXIES).map(([label, proxy]) => measure(label, proxy)));
console.log(JSON.stringify({ page: PAGE, statistic: `median of ${LOADS} cold loads`, results: rows }, null, 2));
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL, PROXY_URL_DE, SOCKS_PROXIES (comma-separated socks5:// URLs)
import json
import os
import statistics
from concurrent.futures import ThreadPoolExecutor
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
PAGE = "https://en.wikipedia.org/wiki/Web_browser"
LOADS = 3 # per proxy, each in a fresh context (cold cache, new connections)
PROXIES = {
"residential (any country)": os.environ["PROXY_URL"],
"residential (Germany)": os.environ["PROXY_URL_DE"],
"datacenter SOCKS5": os.environ["SOCKS_PROXIES"].split(",")[0],
}
# Navigation Timing + Largest Contentful Paint, read in the page after load.
TIMINGS = """() => new Promise((done) => {
const nav = performance.getEntriesByType('navigation')[0];
new PerformanceObserver((list) => {
const lcp = list.getEntries().at(-1);
done({
connect: nav.connectEnd - nav.connectStart,
ttfb: nav.responseStart - nav.requestStart,
domReady: nav.domContentLoadedEventEnd,
load: nav.loadEventEnd,
lcp: lcp.startTime,
});
}).observe({ type: 'largest-contentful-paint', buffered: true });
})"""
def measure(item):
label, proxy = item
res = requests.post("https://starter.cdpfleet.com/chromium/session", headers={"x-api-key": KEY},
json={"proxy": proxy, "headless": True}, timeout=60)
if not res.ok:
return {"proxy": label, "error": f"launch {res.status_code}"}
with sync_playwright() as p: # the sync API is per thread
browser = p.chromium.connect(res.json()["wsUrl"], headers={"x-api-key": KEY})
try:
runs, failures = [], 0
while len(runs) < LOADS:
context = browser.new_context()
try:
page = context.new_page()
page.goto(PAGE, wait_until="load", timeout=60000)
runs.append(page.evaluate(TIMINGS))
except Exception:
failures += 1
if failures > 1: # one failed load is the proxy's noise; two is a problem
raise
finally:
context.close()
m = lambda k: round(statistics.median(r[k] for r in runs))
return {"proxy": label, "loads": len(runs), "connect_ms": m("connect"), "ttfb_ms": m("ttfb"),
"dom_ready_ms": m("domReady"), "lcp_ms": m("lcp"), "load_ms": m("load")}
finally:
browser.close()
with ThreadPoolExecutor(len(PROXIES)) as pool:
rows = list(pool.map(measure, PROXIES.items()))
print(json.dumps({"page": PAGE, "statistic": f"median of {LOADS} cold loads", "results": rows}, 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, PROXY_URL_DE, SOCKS_PROXIES (comma-separated socks5:// URLs)
import com.google.gson.*;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.*;
import java.net.URI;
import java.net.http.*;
import java.util.*;
import java.util.concurrent.*;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final String PAGE = "https://en.wikipedia.org/wiki/Web_browser";
static final int LOADS = 3; // per proxy, each in a fresh context (cold cache, new connections)
// Navigation Timing + Largest Contentful Paint, read in the page after load.
static final String TIMINGS = """
() => new Promise((done) => {
const nav = performance.getEntriesByType('navigation')[0];
new PerformanceObserver((list) => {
const lcp = list.getEntries().at(-1);
done({
connect: nav.connectEnd - nav.connectStart,
ttfb: nav.responseStart - nav.requestStart,
domReady: nav.domContentLoadedEventEnd,
load: nav.loadEventEnd,
lcp: lcp.startTime,
});
}).observe({ type: 'largest-contentful-paint', buffered: true });
})""";
static JsonObject measure(String label, String proxy) throws Exception {
String body = "{\"proxy\": " + new Gson().toJson(proxy) + ", \"headless\": true}";
HttpResponse<String> res = HttpClient.newHttpClient().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());
JsonObject out = new JsonObject();
out.addProperty("proxy", label);
if (res.statusCode() != 200) { out.addProperty("error", "launch " + res.statusCode()); return out; }
String wsUrl = JsonParser.parseString(res.body()).getAsJsonObject().get("wsUrl").getAsString();
try (Playwright playwright = Playwright.create()) { // Playwright objects are per thread
Browser browser = playwright.chromium().connect(wsUrl, new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
try {
List<JsonObject> runs = new ArrayList<>();
int failures = 0;
while (runs.size() < LOADS) {
BrowserContext context = browser.newContext();
try {
Page page = context.newPage();
page.navigate(PAGE, new Page.NavigateOptions().setWaitUntil(WaitUntilState.LOAD).setTimeout(60000));
runs.add(new Gson().toJsonTree(page.evaluate(TIMINGS)).getAsJsonObject());
} catch (PlaywrightException err) {
if (++failures > 1) throw err; // one failed load is the proxy's noise; two is a problem
} finally {
context.close();
}
}
out.addProperty("loads", runs.size());
for (String[] k : new String[][] {{"connect", "connect_ms"}, {"ttfb", "ttfb_ms"}, {"domReady", "dom_ready_ms"}, {"lcp", "lcp_ms"}, {"load", "load_ms"}}) {
double[] v = runs.stream().mapToDouble(r -> r.get(k[0]).getAsDouble()).sorted().toArray();
out.addProperty(k[1], Math.round(v[v.length / 2])); // median
}
return out;
} finally {
browser.close();
}
}
}
public static void main(String[] args) throws Exception {
Map<String, String> proxies = new LinkedHashMap<>();
proxies.put("residential (any country)", System.getenv("PROXY_URL"));
proxies.put("residential (Germany)", System.getenv("PROXY_URL_DE"));
proxies.put("datacenter SOCKS5", System.getenv("SOCKS_PROXIES").split(",")[0]);
ExecutorService pool = Executors.newFixedThreadPool(proxies.size());
List<Future<JsonObject>> jobs = new ArrayList<>();
for (Map.Entry<String, String> e : proxies.entrySet()) jobs.add(pool.submit(() -> measure(e.getKey(), e.getValue())));
JsonArray rows = new JsonArray();
for (Future<JsonObject> f : jobs) rows.add(f.get());
pool.shutdown();
JsonObject out = new JsonObject();
out.addProperty("page", PAGE);
out.addProperty("statistic", "median of " + LOADS + " cold loads");
out.add("results", rows);
System.out.println(new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create().toJson(out));
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL, PROXY_URL_DE, SOCKS_PROXIES (comma-separated socks5:// URLs)
using System.Net.Http.Json;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;
const string PageUrl = "https://en.wikipedia.org/wiki/Web_browser";
const int Loads = 3; // per proxy, each in a fresh context (cold cache, new connections)
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
var proxies = new (string Label, string Proxy)[]
{
("residential (any country)", Environment.GetEnvironmentVariable("PROXY_URL")!),
("residential (Germany)", Environment.GetEnvironmentVariable("PROXY_URL_DE")!),
("datacenter SOCKS5", Environment.GetEnvironmentVariable("SOCKS_PROXIES")!.Split(',')[0]),
};
// Navigation Timing + Largest Contentful Paint, read in the page after load.
const string Timings = """
() => new Promise((done) => {
const nav = performance.getEntriesByType('navigation')[0];
new PerformanceObserver((list) => {
const lcp = list.getEntries().at(-1);
done({
connect: nav.connectEnd - nav.connectStart,
ttfb: nav.responseStart - nav.requestStart,
domReady: nav.domContentLoadedEventEnd,
load: nav.loadEventEnd,
lcp: lcp.startTime,
});
}).observe({ type: 'largest-contentful-paint', buffered: true });
})
""";
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();
async Task<JsonObject> Measure(string label, string proxy)
{
var res = await http.PostAsJsonAsync("https://starter.cdpfleet.com/chromium/session", new { proxy, headless = true });
if (!res.IsSuccessStatusCode) return new JsonObject { ["proxy"] = label, ["error"] = $"launch {(int)res.StatusCode}" };
var wsUrl = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("wsUrl").GetString()!;
var browser = await playwright.Chromium.ConnectAsync(wsUrl, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
try
{
var runs = new List<JsonElement>();
var failures = 0;
while (runs.Count < Loads)
{
var context = await browser.NewContextAsync();
try
{
var page = await context.NewPageAsync();
await page.GotoAsync(PageUrl, new() { WaitUntil = WaitUntilState.Load, Timeout = 60000 });
runs.Add(await page.EvaluateAsync<JsonElement>(Timings));
}
catch (Exception err) when ((err is PlaywrightException or TimeoutException) && ++failures <= 1) { } // one failed load is the proxy's noise
finally { await context.CloseAsync(); }
}
long Median(string k) => (long)Math.Round(runs.Select(r => r.GetProperty(k).GetDouble()).OrderBy(v => v).ElementAt(runs.Count / 2));
return new JsonObject
{
["proxy"] = label, ["loads"] = runs.Count, ["connect_ms"] = Median("connect"), ["ttfb_ms"] = Median("ttfb"),
["dom_ready_ms"] = Median("domReady"), ["lcp_ms"] = Median("lcp"), ["load_ms"] = Median("load"),
};
}
finally
{
await browser.CloseAsync();
}
}
var rows = await Task.WhenAll(proxies.Select(p => Measure(p.Label, p.Proxy)));
var result = new JsonObject { ["page"] = PageUrl, ["statistic"] = $"median of {Loads} cold loads", ["results"] = new JsonArray(rows.ToArray<JsonNode?>()) };
Console.WriteLine(result.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = 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, PROXY_URL_DE, SOCKS_PROXIES (comma-separated socks5:// URLs)
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"sort"
"strings"
"sync"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
const pageURL = "https://en.wikipedia.org/wiki/Web_browser"
const loads = 3 // per proxy, each in a fresh context (cold cache, new connections)
// Navigation Timing + Largest Contentful Paint, read in the page after load.
const timings = `() => new Promise((done) => {
const nav = performance.getEntriesByType('navigation')[0];
new PerformanceObserver((list) => {
const lcp = list.getEntries().at(-1);
done({
connect: nav.connectEnd - nav.connectStart,
ttfb: nav.responseStart - nav.requestStart,
domReady: nav.domContentLoadedEventEnd,
load: nav.loadEventEnd,
lcp: lcp.startTime,
});
}).observe({ type: 'largest-contentful-paint', buffered: true });
})`
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
}
// Evaluate returns whole numbers as int and others as float64.
func num(v any) float64 {
switch n := v.(type) {
case float64:
return n
case int:
return float64(n)
case int64:
return float64(n)
}
return 0
}
func measure(pw *playwright.Playwright, label, proxy string) map[string]any {
fail := func(err error) map[string]any {
return map[string]any{"proxy": label, "error": strings.SplitN(err.Error(), "\n", 2)[0]}
}
session, err := launch("chromium", map[string]any{"proxy": proxy, "headless": true})
if err != nil {
return fail(err)
}
browser, err := pw.Chromium.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
if err != nil {
return fail(err)
}
defer browser.Close()
runs := []map[string]any{}
failures := 0
for len(runs) < loads {
context, err := browser.NewContext()
if err != nil {
return fail(err)
}
page, _ := context.NewPage()
_, err = page.Goto(pageURL, playwright.PageGotoOptions{WaitUntil: playwright.WaitUntilStateLoad, Timeout: playwright.Float(60000)})
var v any
if err == nil {
v, err = page.Evaluate(timings)
}
context.Close()
if err != nil {
if failures++; failures > 1 { // one failed load is the proxy's noise; two is a problem
return fail(err)
}
continue
}
runs = append(runs, v.(map[string]any))
}
median := func(k string) int64 {
xs := []float64{}
for _, r := range runs {
xs = append(xs, num(r[k]))
}
sort.Float64s(xs)
return int64(math.Round(xs[len(xs)/2]))
}
return map[string]any{"proxy": label, "loads": len(runs), "connect_ms": median("connect"), "ttfb_ms": median("ttfb"),
"dom_ready_ms": median("domReady"), "lcp_ms": median("lcp"), "load_ms": median("load")}
}
func main() {
pw := must(playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true}))
defer pw.Stop()
proxies := [][2]string{
{"residential (any country)", os.Getenv("PROXY_URL")},
{"residential (Germany)", os.Getenv("PROXY_URL_DE")},
{"datacenter SOCKS5", strings.Split(os.Getenv("SOCKS_PROXIES"), ",")[0]},
}
rows := make([]map[string]any, len(proxies))
var wg sync.WaitGroup
for i, p := range proxies {
wg.Add(1)
go func(i int, label, proxy string) { defer wg.Done(); rows[i] = measure(pw, label, proxy) }(i, p[0], p[1])
}
wg.Wait()
out, _ := json.MarshalIndent(map[string]any{"page": pageURL, "statistic": fmt.Sprintf("median of %d cold loads", loads), "results": rows}, "", " ")
fmt.Println(string(out))
}
What we got
| Proxy | Connect (ms) | First byte (ms) | LCP (ms) | DOM ready (ms) | Load (ms) |
|---|---|---|---|---|---|
| residential (any country) | 1614 | 742 | 3384 | 3382 | 7309 |
| residential (Germany) | 340 | 201 | 948 | 970 | 1946 |
| datacenter SOCKS5 | 543 | 119 | 1152 | 1165 | 2441 |
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)
{
"page": "https://en.wikipedia.org/wiki/Web_browser",
"statistic": "median of 3 cold loads",
"results": [
{
"proxy": "residential (any country)",
"loads": 3,
"connect_ms": 1614,
"ttfb_ms": 742,
"dom_ready_ms": 3382,
"lcp_ms": 3384,
"load_ms": 7309
},
{
"proxy": "residential (Germany)",
"loads": 3,
"connect_ms": 340,
"ttfb_ms": 201,
"dom_ready_ms": 970,
"lcp_ms": 948,
"load_ms": 1946
},
{
"proxy": "datacenter SOCKS5",
"loads": 3,
"connect_ms": 543,
"ttfb_ms": 119,
"dom_ready_ms": 1165,
"lcp_ms": 1152,
"load_ms": 2441
}
]
}Takeaways
- Location beats proxy type: the residential exit in Germany — next to our servers and to the site's European CDN — was the fastest of all, faster than the datacenter proxy.
- A random residential exit is the slow, unpredictable one: connection setup and the full load took several times longer, because traffic crossed continents twice.
- Connection setup dominates cold loads: every new connection is a TCP + TLS handshake through the proxy; reusing sessions and contexts (see the worker-pool case) avoids paying it over and over.
- Pick proxy countries near your targets, and measure with your real pages — these numbers move with every exit.