Cases / #13 · 2026-10-01 · Medium
Real-time sites through a proxy: WebSocket latency by proxy
Chats, live prices and dashboards run on WebSockets. They work through every proxy type — but each message pays the full proxy path, and location decides how much.
Run on production on 2026-10-01: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
Live sites push data over WebSockets, and your browser's WebSocket goes through your proxy like everything else. Does it work through residential and SOCKS proxies, and how much latency does each add to every message?
What we used, and why
| What | Why |
|---|---|
| Three sessions, three proxies | A random residential exit, a residential exit in Germany, and a datacenter SOCKS5 proxy. |
wss://ws.postman-echo.com/raw | A public echo server: every message comes straight back. |
page.evaluate with WebSocket | The browser opens the socket itself (through the proxy), times the handshake and 20 sequential round trips. |
| ip-api.com | Where each proxy exits, to explain the numbers. |
How it works
- For each proxy, launch a session and look up its exit.
- From a real https page, open the WebSocket and time the connection.
- Send 20 messages one at a time and record each round trip; report the median and 95th percentile.
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 ECHO = 'wss://ws.postman-echo.com/raw'; // a public WebSocket echo server
const PROXIES = {
'residential (any country)': process.env.PROXY_URL,
'residential (Germany)': process.env.PROXY_URL_DE,
'datacenter SOCKS5': process.env.SOCKS_PROXIES.split(',')[0],
};
// Runs in the page: connect, then 20 echo round trips, one at a time.
const PROBE = async (url) => {
const t0 = performance.now();
const ws = new WebSocket(url);
await new Promise((ok, fail) => { ws.onopen = ok; ws.onerror = () => fail(new Error('WebSocket failed')); });
const connectMs = performance.now() - t0;
const rtts = [];
for (let i = 0; i < 20; i++) {
const sent = performance.now();
const echoed = new Promise((ok) => { ws.onmessage = (m) => ok(m.data); });
ws.send(`ping ${i}`);
if ((await echoed) !== `ping ${i}`) throw new Error('wrong echo');
rtts.push(performance.now() - sent);
}
ws.close();
rtts.sort((a, b) => a - b);
return { connectMs, p50: rtts[10], p95: rtts[18], min: rtts[0] };
};
// One session per proxy. Residential exits drop a connection now and then: one retry
// in a new session, then report the error instead of failing everything.
async function measure(label, proxy) {
for (let attempt = 1; ; attempt++) {
try {
return await measureOnce(label, proxy);
} catch (err) {
if (attempt === 2) return { proxy: label, error: err.message.split('\n')[0] };
}
}
}
async function measureOnce(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) throw new Error(`launch ${res.status}`);
const { wsUrl } = await res.json();
const browser = await chromium.connect(wsUrl, { headers: { 'x-api-key': KEY } });
try {
const page = await browser.newPage();
// Where this proxy exits (also gives the page an https origin to open the socket from).
const geo = await (await page.goto('http://ip-api.com/json/?fields=country,city', { timeout: 30000 })).json();
await page.goto('https://httpbin.org/html', { timeout: 30000 });
const r = await page.evaluate(PROBE, ECHO);
return {
proxy: label, exit: `${geo.city}, ${geo.country}`,
connect_ms: Math.round(r.connectMs), rtt_p50_ms: Math.round(r.p50), rtt_p95_ms: Math.round(r.p95), rtt_min_ms: Math.round(r.min),
};
} finally {
await browser.close();
}
}
const rows = [];
for (const [label, proxy] of Object.entries(PROXIES)) rows.push(await measure(label, proxy));
console.log(JSON.stringify({ echo_server: ECHO, round_trips: 20, 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 requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
ECHO = "wss://ws.postman-echo.com/raw" # a public WebSocket echo server
PROXIES = {
"residential (any country)": os.environ["PROXY_URL"],
"residential (Germany)": os.environ["PROXY_URL_DE"],
"datacenter SOCKS5": os.environ["SOCKS_PROXIES"].split(",")[0],
}
# Runs in the page: connect, then 20 echo round trips, one at a time.
PROBE = """async (url) => {
const t0 = performance.now();
const ws = new WebSocket(url);
await new Promise((ok, fail) => { ws.onopen = ok; ws.onerror = () => fail(new Error('WebSocket failed')); });
const connectMs = performance.now() - t0;
const rtts = [];
for (let i = 0; i < 20; i++) {
const sent = performance.now();
const echoed = new Promise((ok) => { ws.onmessage = (m) => ok(m.data); });
ws.send(`ping ${i}`);
if ((await echoed) !== `ping ${i}`) throw new Error('wrong echo');
rtts.push(performance.now() - sent);
}
ws.close();
rtts.sort((a, b) => a - b);
return { connectMs, p50: rtts[10], p95: rtts[18], min: rtts[0] };
}"""
def measure_once(p, label, proxy):
res = requests.post("https://starter.cdpfleet.com/chromium/session", headers={"x-api-key": KEY},
json={"proxy": proxy, "headless": True}, timeout=60)
res.raise_for_status()
browser = p.chromium.connect(res.json()["wsUrl"], headers={"x-api-key": KEY})
try:
page = browser.new_page()
# Where this proxy exits (also gives the page an https origin to open the socket from).
geo = page.goto("http://ip-api.com/json/?fields=country,city", timeout=30000).json()
page.goto("https://httpbin.org/html", timeout=30000)
r = page.evaluate(PROBE, ECHO)
return {"proxy": label, "exit": f"{geo['city']}, {geo['country']}", "connect_ms": round(r["connectMs"]),
"rtt_p50_ms": round(r["p50"]), "rtt_p95_ms": round(r["p95"]), "rtt_min_ms": round(r["min"])}
finally:
browser.close()
def measure(p, label, proxy):
# Residential exits drop a connection now and then: one retry in a new session.
for attempt in (1, 2):
try:
return measure_once(p, label, proxy)
except Exception as err:
if attempt == 2:
return {"proxy": label, "error": str(err).splitlines()[0]}
with sync_playwright() as p:
rows = [measure(p, label, proxy) for label, proxy in PROXIES.items()]
print(json.dumps({"echo_server": ECHO, "round_trips": 20, "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 java.net.URI;
import java.net.http.*;
import java.util.*;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final String ECHO = "wss://ws.postman-echo.com/raw"; // a public WebSocket echo server
// Runs in the page: connect, then 20 echo round trips, one at a time.
static final String PROBE = """
async (url) => {
const t0 = performance.now();
const ws = new WebSocket(url);
await new Promise((ok, fail) => { ws.onopen = ok; ws.onerror = () => fail(new Error('WebSocket failed')); });
const connectMs = performance.now() - t0;
const rtts = [];
for (let i = 0; i < 20; i++) {
const sent = performance.now();
const echoed = new Promise((ok) => { ws.onmessage = (m) => ok(m.data); });
ws.send(`ping ${i}`);
if ((await echoed) !== `ping ${i}`) throw new Error('wrong echo');
rtts.push(performance.now() - sent);
}
ws.close();
rtts.sort((a, b) => a - b);
return { connectMs, p50: rtts[10], p95: rtts[18], min: rtts[0] };
}""";
static JsonObject measureOnce(Playwright playwright, 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());
if (res.statusCode() != 200) throw new RuntimeException("launch " + res.statusCode());
String wsUrl = JsonParser.parseString(res.body()).getAsJsonObject().get("wsUrl").getAsString();
Browser browser = playwright.chromium().connect(wsUrl, new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
try {
Page page = browser.newPage();
// Where this proxy exits (also gives the page an https origin to open the socket from).
JsonObject geo = JsonParser.parseString(page.navigate("http://ip-api.com/json/?fields=country,city",
new Page.NavigateOptions().setTimeout(30000)).text()).getAsJsonObject();
page.navigate("https://httpbin.org/html", new Page.NavigateOptions().setTimeout(30000));
JsonObject r = new Gson().toJsonTree(page.evaluate(PROBE, ECHO)).getAsJsonObject();
JsonObject out = new JsonObject();
out.addProperty("proxy", label);
out.addProperty("exit", geo.get("city").getAsString() + ", " + geo.get("country").getAsString());
out.addProperty("connect_ms", Math.round(r.get("connectMs").getAsDouble()));
out.addProperty("rtt_p50_ms", Math.round(r.get("p50").getAsDouble()));
out.addProperty("rtt_p95_ms", Math.round(r.get("p95").getAsDouble()));
out.addProperty("rtt_min_ms", Math.round(r.get("min").getAsDouble()));
return out;
} finally {
browser.close();
}
}
// Residential exits drop a connection now and then: one retry in a new session.
static JsonObject measure(Playwright playwright, String label, String proxy) {
for (int attempt = 1; ; attempt++) {
try {
return measureOnce(playwright, label, proxy);
} catch (Exception err) {
if (attempt == 2) {
JsonObject out = new JsonObject();
out.addProperty("proxy", label);
out.addProperty("error", String.valueOf(err.getMessage()).split("\n")[0]);
return out;
}
}
}
}
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]);
JsonArray rows = new JsonArray();
try (Playwright playwright = Playwright.create()) {
for (Map.Entry<String, String> e : proxies.entrySet()) rows.add(measure(playwright, e.getKey(), e.getValue()));
}
JsonObject out = new JsonObject();
out.addProperty("echo_server", ECHO);
out.addProperty("round_trips", 20);
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 Echo = "wss://ws.postman-echo.com/raw"; // a public WebSocket echo server
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]),
};
// Runs in the page: connect, then 20 echo round trips, one at a time.
const string Probe = """
async (url) => {
const t0 = performance.now();
const ws = new WebSocket(url);
await new Promise((ok, fail) => { ws.onopen = ok; ws.onerror = () => fail(new Error('WebSocket failed')); });
const connectMs = performance.now() - t0;
const rtts = [];
for (let i = 0; i < 20; i++) {
const sent = performance.now();
const echoed = new Promise((ok) => { ws.onmessage = (m) => ok(m.data); });
ws.send(`ping ${i}`);
if ((await echoed) !== `ping ${i}`) throw new Error('wrong echo');
rtts.push(performance.now() - sent);
}
ws.close();
rtts.sort((a, b) => a - b);
return { connectMs, p50: rtts[10], p95: rtts[18], min: rtts[0] };
}
""";
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();
async Task<JsonObject> MeasureOnce(string label, string proxy)
{
var res = await http.PostAsJsonAsync("https://starter.cdpfleet.com/chromium/session", new { proxy, headless = true });
if (!res.IsSuccessStatusCode) throw new Exception($"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 page = await browser.NewPageAsync();
// Where this proxy exits (also gives the page an https origin to open the socket from).
var geo = JsonNode.Parse(await (await page.GotoAsync("http://ip-api.com/json/?fields=country,city", new() { Timeout = 30000 }))!.TextAsync())!;
await page.GotoAsync("https://httpbin.org/html", new() { Timeout = 30000 });
var r = await page.EvaluateAsync<JsonElement>(Probe, Echo);
return new JsonObject
{
["proxy"] = label, ["exit"] = $"{geo["city"]}, {geo["country"]}",
["connect_ms"] = Math.Round(r.GetProperty("connectMs").GetDouble()),
["rtt_p50_ms"] = Math.Round(r.GetProperty("p50").GetDouble()),
["rtt_p95_ms"] = Math.Round(r.GetProperty("p95").GetDouble()),
["rtt_min_ms"] = Math.Round(r.GetProperty("min").GetDouble()),
};
}
finally
{
await browser.CloseAsync();
}
}
// Residential exits drop a connection now and then: one retry in a new session.
async Task<JsonObject> Measure(string label, string proxy)
{
for (var attempt = 1; ; attempt++)
{
try { return await MeasureOnce(label, proxy); }
catch (Exception err) when (attempt < 2) { _ = err; }
catch (Exception err) { return new JsonObject { ["proxy"] = label, ["error"] = err.Message.Split('\n')[0] }; }
}
}
var rows = new JsonArray();
foreach (var (label, proxy) in proxies) rows.Add(await Measure(label, proxy));
var result = new JsonObject { ["echo_server"] = Echo, ["round_trips"] = 20, ["results"] = rows };
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"
"strings"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
const echoServer = "wss://ws.postman-echo.com/raw" // a public WebSocket echo server
// Runs in the page: connect, then 20 echo round trips, one at a time.
const probe = `async (url) => {
const t0 = performance.now();
const ws = new WebSocket(url);
await new Promise((ok, fail) => { ws.onopen = ok; ws.onerror = () => fail(new Error('WebSocket failed')); });
const connectMs = performance.now() - t0;
const rtts = [];
for (let i = 0; i < 20; i++) {
const sent = performance.now();
const echoed = new Promise((ok) => { ws.onmessage = (m) => ok(m.data); });
ws.send('ping ' + i);
if ((await echoed) !== 'ping ' + i) throw new Error('wrong echo');
rtts.push(performance.now() - sent);
}
ws.close();
rtts.sort((a, b) => a - b);
return { connectMs, p50: rtts[10], p95: rtts[18], min: rtts[0] };
}`
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 measureOnce(pw *playwright.Playwright, label, proxy string) (map[string]any, error) {
session, err := launch("chromium", map[string]any{"proxy": proxy, "headless": true})
if err != nil {
return nil, err
}
browser, err := pw.Chromium.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
if err != nil {
return nil, err
}
defer browser.Close()
page, _ := browser.NewPage()
// Where this proxy exits (also gives the page an https origin to open the socket from).
res, err := page.Goto("http://ip-api.com/json/?fields=country,city", playwright.PageGotoOptions{Timeout: playwright.Float(30000)})
if err != nil {
return nil, err
}
var geo struct{ Country, City string }
res.JSON(&geo)
if _, err := page.Goto("https://httpbin.org/html", playwright.PageGotoOptions{Timeout: playwright.Float(30000)}); err != nil {
return nil, err
}
v, err := page.Evaluate(probe, echoServer)
if err != nil {
return nil, err
}
r := v.(map[string]any)
ms := func(k string) int64 { return int64(math.Round(num(r[k]))) }
return map[string]any{"proxy": label, "exit": geo.City + ", " + geo.Country,
"connect_ms": ms("connectMs"), "rtt_p50_ms": ms("p50"), "rtt_p95_ms": ms("p95"), "rtt_min_ms": ms("min")}, nil
}
// Residential exits drop a connection now and then: one retry in a new session.
func measure(pw *playwright.Playwright, label, proxy string) map[string]any {
var err error
for attempt := 1; attempt <= 2; attempt++ {
var r map[string]any
if r, err = measureOnce(pw, label, proxy); err == nil {
return r
}
}
return map[string]any{"proxy": label, "error": strings.SplitN(err.Error(), "\n", 2)[0]}
}
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 := []map[string]any{}
for _, p := range proxies {
rows = append(rows, measure(pw, p[0], p[1]))
}
out, _ := json.MarshalIndent(map[string]any{"echo_server": echoServer, "round_trips": 20, "results": rows}, "", " ")
fmt.Println(string(out))
}
What we got
| Proxy | Exit | Connect (ms) | Round trip p50 (ms) | p95 (ms) |
|---|---|---|---|---|
| residential (any country) | Ho Chi Minh City, Vietnam | 676 | 185 | 196 |
| residential (Germany) | Hagen, Germany | 575 | 142 | 147 |
| datacenter SOCKS5 | Los Angeles, United States | 662 | 110 | 120 |
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)
{
"echo_server": "wss://ws.postman-echo.com/raw",
"round_trips": 20,
"results": [
{
"proxy": "residential (any country)",
"exit": "Ho Chi Minh City, Vietnam",
"connect_ms": 676,
"rtt_p50_ms": 185,
"rtt_p95_ms": 196,
"rtt_min_ms": 182
},
{
"proxy": "residential (Germany)",
"exit": "Hagen, Germany",
"connect_ms": 575,
"rtt_p50_ms": 142,
"rtt_p95_ms": 147,
"rtt_min_ms": 139
},
{
"proxy": "datacenter SOCKS5",
"exit": "Los Angeles, United States",
"connect_ms": 662,
"rtt_p50_ms": 110,
"rtt_p95_ms": 120,
"rtt_min_ms": 109
}
]
}Takeaways
- WebSockets work through every proxy type — residential and SOCKS5 alike; they ride the same CONNECT tunnel as HTTPS.
- Every message pays the whole path: browser (our servers, in Germany) → proxy exit → the site → back. The round-trip time is the sum of both legs.
- Location beats proxy type: a proxy exit close to both our servers and the site wins; a residential exit on another continent adds hundreds of milliseconds to every message.
- For latency-sensitive sites, choose the exit country near the site's servers, and measure — residential pools vary from exit to exit.