Cases / #6 · 2026-09-30 · Hard
Swap a proxy mid-session without losing the login — and the keep-alive trap
One API call changes the exit IP of a running browser. Open connections don't follow; storageState moves everything over.
Run on production on 2026-09-30: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
A proxy gets blocked or slow halfway through a logged-in flow. Restarting the browser means logging in again. Instead, you can point the running browser at a different proxy with one call — but does traffic really move, and what happens to the connections the browser already has open?
What we used, and why
| What | Why |
|---|---|
proxy_updatable: true | Kept in the code for clarity: every cdpfleet session is swappable now, whatever you send. |
POST https://<router>/admin/session/proxy | The swap call, sent to the host in your wsUrl with your API key: {session_id, proxy}. It returns in milliseconds. |
httpbin.org cookies + localStorage | Stand-ins for a login: state that must survive the swap. |
api.ipify.org / api64.ipify.org | Two different hosts that echo the exit IP — one we have already connected to, one we haven't. |
context.storageState() | Exports cookies and localStorage so a new context can continue where the old one stopped. |
How it works
- Launch with a residential proxy and
proxy_updatable: true; set cookies and localStorage; note the exit IP. - Swap the proxy to a SOCKS5 proxy.
- Ask the same host again (reused keep-alive connection), then a host we haven't talked to (new connection).
- Move everything to a new context with
storageStateand check exit IP, cookies and localStorage.
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]:8000SOCKS_PROXIES— comma-separatedsocks5://user:pass@host:portproxies
// npm install [email protected]
// env: CDPFLEET_API_KEY, PROXY_URL, SOCKS_PROXIES (comma-separated socks5:// URLs)
import { chromium } from 'playwright';
const KEY = process.env.CDPFLEET_API_KEY;
const NEXT_PROXY = process.env.SOCKS_PROXIES.split(',')[0];
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, proxy_updatable: true, headless: true }),
});
if (!res.ok) throw new Error(`launch: ${res.status} ${await res.text()}`);
const { sessionId, wsUrl } = await res.json();
const browser = await chromium.connect(wsUrl, { headers: { 'x-api-key': KEY } });
// Swap the proxy on the session's router (the host in wsUrl). Takes effect for new
// connections; the browser, its tabs, cookies and storage stay as they are.
async function swapProxy(proxy) {
const r = await fetch(`https://${new URL(wsUrl).host}/admin/session/proxy`, {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, proxy }),
});
if (!r.ok) throw new Error(`swap: ${r.status} ${await r.text()}`);
}
const exitIp = async (page, host) => (await (await page.goto(`https://${host}/?format=json`, { timeout: 60000 })).json()).ip;
const cookies = async (page) => (await (await page.goto('https://httpbin.org/cookies', { timeout: 60000 })).json()).cookies;
try {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://httpbin.org/cookies/set?cart=3-items&login=alice', { timeout: 60000 });
await page.evaluate(() => localStorage.setItem('draft', 'half-written review'));
const before = { exit_ip: await exitIp(page, 'api.ipify.org'), cookies: await cookies(page) };
const t = Date.now();
await swapProxy(NEXT_PROXY);
const swapMs = Date.now() - t;
// 1. Same tab, same host: the open keep-alive connection still goes through the old proxy.
const reusedConnection = await exitIp(page, 'api.ipify.org');
// 2. Same tab, a host we haven't connected to yet: a new connection, so the new proxy.
const newConnection = await exitIp(page, 'api64.ipify.org');
// 3. Move everything over: a new context (its own connection pool) with the old
// cookies and localStorage.
const moved = await browser.newContext({ storageState: await context.storageState() });
await context.close();
const page2 = await moved.newPage();
const after = {
exit_ip: await exitIp(page2, 'api.ipify.org'),
cookies: await cookies(page2),
local_storage: await page2.evaluate(() => localStorage.getItem('draft')),
};
console.log(JSON.stringify({
before,
swap_ms: swapMs,
same_tab_reused_connection: reusedConnection,
same_tab_new_connection: newConnection,
new_context_with_storage_state: after,
same_browser_session: browser.isConnected(),
}, null, 2));
} finally {
await browser.close();
}
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL, SOCKS_PROXIES (comma-separated socks5:// URLs)
import json
import os
import time
from urllib.parse import urlparse
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
NEXT_PROXY = os.environ["SOCKS_PROXIES"].split(",")[0]
res = requests.post("https://starter.cdpfleet.com/chromium/session", headers={"x-api-key": KEY},
json={"proxy": os.environ["PROXY_URL"], "proxy_updatable": True, "headless": True}, timeout=60)
res.raise_for_status()
session = res.json()
def swap_proxy(proxy):
# Swap the proxy on the session's router (the host in wsUrl). Takes effect for new
# connections; the browser, its tabs, cookies and storage stay as they are.
r = requests.post(f"https://{urlparse(session['wsUrl']).netloc}/admin/session/proxy",
headers={"x-api-key": KEY}, json={"session_id": session["sessionId"], "proxy": proxy}, timeout=30)
r.raise_for_status()
def exit_ip(page, host):
return page.goto(f"https://{host}/?format=json", timeout=60000).json()["ip"]
def cookies(page):
return page.goto("https://httpbin.org/cookies", timeout=60000).json()["cookies"]
with sync_playwright() as p:
browser = p.chromium.connect(session["wsUrl"], headers={"x-api-key": KEY})
try:
context = browser.new_context()
page = context.new_page()
page.goto("https://httpbin.org/cookies/set?cart=3-items&login=alice", timeout=60000)
page.evaluate("localStorage.setItem('draft', 'half-written review')")
before = {"exit_ip": exit_ip(page, "api.ipify.org"), "cookies": cookies(page)}
t = time.time()
swap_proxy(NEXT_PROXY)
swap_ms = round((time.time() - t) * 1000)
# 1. Same tab, same host: the open keep-alive connection still goes through the old proxy.
reused_connection = exit_ip(page, "api.ipify.org")
# 2. Same tab, a host we haven't connected to yet: a new connection, so the new proxy.
new_connection = exit_ip(page, "api64.ipify.org")
# 3. Move everything over: a new context (its own connection pool) with the old
# cookies and localStorage.
moved = browser.new_context(storage_state=context.storage_state())
context.close()
page2 = moved.new_page()
after = {
"exit_ip": exit_ip(page2, "api.ipify.org"),
"cookies": cookies(page2),
"local_storage": page2.evaluate("localStorage.getItem('draft')"),
}
print(json.dumps({
"before": before,
"swap_ms": swap_ms,
"same_tab_reused_connection": reused_connection,
"same_tab_new_connection": new_connection,
"new_context_with_storage_state": after,
"same_browser_session": browser.is_connected(),
}, 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, 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.Map;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final HttpClient HTTP = HttpClient.newHttpClient();
static final Gson GSON = new Gson();
static JsonObject post(String url, JsonObject body) throws Exception {
HttpResponse<String> res = HTTP.send(HttpRequest.newBuilder(URI.create(url))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toString())).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new RuntimeException(url + ": " + res.statusCode() + " " + res.body());
return res.body().isBlank() ? new JsonObject() : JsonParser.parseString(res.body()).getAsJsonObject();
}
static JsonObject json(Page page, String url) {
return JsonParser.parseString(page.navigate(url, new Page.NavigateOptions().setTimeout(60000)).text()).getAsJsonObject();
}
static String exitIp(Page page, String host) { return json(page, "https://" + host + "/?format=json").get("ip").getAsString(); }
static JsonElement cookies(Page page) { return json(page, "https://httpbin.org/cookies").get("cookies"); }
public static void main(String[] args) throws Exception {
JsonObject options = new JsonObject();
options.addProperty("proxy", System.getenv("PROXY_URL"));
options.addProperty("proxy_updatable", true);
options.addProperty("headless", true);
JsonObject session = post("https://starter.cdpfleet.com/chromium/session", options);
String wsUrl = session.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 {
BrowserContext context = browser.newContext();
Page page = context.newPage();
page.navigate("https://httpbin.org/cookies/set?cart=3-items&login=alice", new Page.NavigateOptions().setTimeout(60000));
page.evaluate("localStorage.setItem('draft', 'half-written review')");
JsonObject before = new JsonObject();
before.addProperty("exit_ip", exitIp(page, "api.ipify.org"));
before.add("cookies", cookies(page));
// Swap the proxy on the session's router (the host in wsUrl). Takes effect for new
// connections; the browser, its tabs, cookies and storage stay as they are.
long t = System.currentTimeMillis();
JsonObject swap = new JsonObject();
swap.add("session_id", session.get("sessionId"));
swap.addProperty("proxy", System.getenv("SOCKS_PROXIES").split(",")[0]);
post("https://" + URI.create(wsUrl).getHost() + "/admin/session/proxy", swap);
long swapMs = System.currentTimeMillis() - t;
// 1. Same tab, same host: the open keep-alive connection still goes through the old proxy.
String reusedConnection = exitIp(page, "api.ipify.org");
// 2. Same tab, a host we haven't connected to yet: a new connection, so the new proxy.
String newConnection = exitIp(page, "api64.ipify.org");
// 3. Move everything over: a new context (its own connection pool) with the old
// cookies and localStorage.
BrowserContext moved = browser.newContext(new Browser.NewContextOptions().setStorageState(context.storageState()));
context.close();
Page page2 = moved.newPage();
JsonObject after = new JsonObject();
after.addProperty("exit_ip", exitIp(page2, "api.ipify.org"));
after.add("cookies", cookies(page2));
after.add("local_storage", GSON.toJsonTree(page2.evaluate("localStorage.getItem('draft')")));
JsonObject out = new JsonObject();
out.add("before", before);
out.addProperty("swap_ms", swapMs);
out.addProperty("same_tab_reused_connection", reusedConnection);
out.addProperty("same_tab_new_connection", newConnection);
out.add("new_context_with_storage_state", after);
out.addProperty("same_browser_session", browser.isConnected());
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, SOCKS_PROXIES (comma-separated socks5:// URLs)
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")!;
var nextProxy = Environment.GetEnvironmentVariable("SOCKS_PROXIES")!.Split(',')[0];
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"), proxy_updatable = true, headless = true });
if (!res.IsSuccessStatusCode) throw new Exception($"launch: {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
var session = await res.Content.ReadFromJsonAsync<JsonElement>();
var sessionId = session.GetProperty("sessionId").GetString()!;
var wsUrl = session.GetProperty("wsUrl").GetString()!;
// Swap the proxy on the session's router (the host in wsUrl). Takes effect for new
// connections; the browser, its tabs, cookies and storage stay as they are.
async Task SwapProxy(string proxy)
{
var r = await http.PostAsJsonAsync($"https://{new Uri(wsUrl).Host}/admin/session/proxy", new { session_id = sessionId, proxy });
if (!r.IsSuccessStatusCode) throw new Exception($"swap: {(int)r.StatusCode} {await r.Content.ReadAsStringAsync()}");
}
async Task<JsonNode> Json(IPage page, string url) =>
JsonNode.Parse(await (await page.GotoAsync(url, new() { Timeout = 60000 }))!.TextAsync())!;
async Task<string> ExitIp(IPage page, string host) => (string)(await Json(page, $"https://{host}/?format=json"))["ip"]!;
async Task<JsonNode> Cookies(IPage page) => (await Json(page, "https://httpbin.org/cookies"))["cookies"]!.DeepClone();
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 context = await browser.NewContextAsync();
var page = await context.NewPageAsync();
await page.GotoAsync("https://httpbin.org/cookies/set?cart=3-items&login=alice", new() { Timeout = 60000 });
await page.EvaluateAsync("localStorage.setItem('draft', 'half-written review')");
var before = new JsonObject { ["exit_ip"] = await ExitIp(page, "api.ipify.org"), ["cookies"] = await Cookies(page) };
var sw = Stopwatch.StartNew();
await SwapProxy(nextProxy);
var swapMs = sw.ElapsedMilliseconds;
// 1. Same tab, same host: the open keep-alive connection still goes through the old proxy.
var reusedConnection = await ExitIp(page, "api.ipify.org");
// 2. Same tab, a host we haven't connected to yet: a new connection, so the new proxy.
var newConnection = await ExitIp(page, "api64.ipify.org");
// 3. Move everything over: a new context (its own connection pool) with the old
// cookies and localStorage.
var moved = await browser.NewContextAsync(new() { StorageState = await context.StorageStateAsync() });
await context.CloseAsync();
var page2 = await moved.NewPageAsync();
var after = new JsonObject
{
["exit_ip"] = await ExitIp(page2, "api.ipify.org"),
["cookies"] = await Cookies(page2),
["local_storage"] = await page2.EvaluateAsync<string>("localStorage.getItem('draft')"),
};
var result = new JsonObject
{
["before"] = before,
["swap_ms"] = swapMs,
["same_tab_reused_connection"] = reusedConnection,
["same_tab_new_connection"] = newConnection,
["new_context_with_storage_state"] = after,
["same_browser_session"] = browser.IsConnected,
};
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, SOCKS_PROXIES (comma-separated socks5:// URLs)
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"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 pageJSON(page playwright.Page, u string) map[string]any {
res := must(page.Goto(u, playwright.PageGotoOptions{Timeout: playwright.Float(60000)}))
var v map[string]any
if err := res.JSON(&v); err != nil {
log.Fatal(err)
}
return v
}
func exitIP(page playwright.Page, host string) string {
return pageJSON(page, "https://"+host+"/?format=json")["ip"].(string)
}
func cookies(page playwright.Page) any {
return pageJSON(page, "https://httpbin.org/cookies")["cookies"]
}
func main() {
session := must(launch("chromium", map[string]any{"proxy": os.Getenv("PROXY_URL"), "proxy_updatable": true, "headless": true}))
wsURL := session["wsUrl"].(string)
// Swap the proxy on the session's router (the host in wsUrl). Takes effect for new
// connections; the browser, its tabs, cookies and storage stay as they are.
swapProxy := func(proxy string) {
body, _ := json.Marshal(map[string]any{"session_id": session["sessionId"], "proxy": proxy})
req, _ := http.NewRequest("POST", "https://"+must(url.Parse(wsURL)).Host+"/admin/session/proxy", bytes.NewReader(body))
req.Header.Set("x-api-key", key)
req.Header.Set("content-type", "application/json")
res := must(http.DefaultClient.Do(req))
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(res.Body)
log.Fatalf("swap: %s %s", res.Status, msg)
}
}
pw := must(playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true}))
defer pw.Stop()
browser := must(pw.Chromium.Connect(wsURL, playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}}))
defer browser.Close()
context := must(browser.NewContext())
page := must(context.NewPage())
must(page.Goto("https://httpbin.org/cookies/set?cart=3-items&login=alice", playwright.PageGotoOptions{Timeout: playwright.Float(60000)}))
must(page.Evaluate("localStorage.setItem('draft', 'half-written review')"))
before := map[string]any{"exit_ip": exitIP(page, "api.ipify.org"), "cookies": cookies(page)}
t := time.Now()
swapProxy(strings.Split(os.Getenv("SOCKS_PROXIES"), ",")[0])
swapMs := time.Since(t).Milliseconds()
// 1. Same tab, same host: the open keep-alive connection still goes through the old proxy.
reusedConnection := exitIP(page, "api.ipify.org")
// 2. Same tab, a host we haven't connected to yet: a new connection, so the new proxy.
newConnection := exitIP(page, "api64.ipify.org")
// 3. Move everything over: a new context (its own connection pool) with the old
// cookies and localStorage.
state := must(context.StorageState())
moved := must(browser.NewContext(playwright.BrowserNewContextOptions{StorageState: state.ToOptionalStorageState()}))
context.Close()
page2 := must(moved.NewPage())
after := map[string]any{
"exit_ip": exitIP(page2, "api.ipify.org"),
"cookies": cookies(page2),
"local_storage": must(page2.Evaluate("localStorage.getItem('draft')")),
}
out, _ := json.MarshalIndent(map[string]any{
"before": before,
"swap_ms": swapMs,
"same_tab_reused_connection": reusedConnection,
"same_tab_new_connection": newConnection,
"new_context_with_storage_state": after,
"same_browser_session": browser.IsConnected(),
}, "", " ")
fmt.Println(string(out))
}
What we got
| Step | Exit IP | Cookies | localStorage |
|---|---|---|---|
| before the swap | 203.0.113.1 | {"cart":"3-items","login":"alice"} | (set) |
| same tab, reused connection | 203.0.113.1 | — | — |
| same tab, new connection | 203.0.113.2 | — | — |
| new context + storageState | 203.0.113.2 | {"cart":"3-items","login":"alice"} | half-written review |
From the Node.js run on 2026-09-30. 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)
{
"before": {
"exit_ip": "203.0.113.1",
"cookies": {
"cart": "3-items",
"login": "alice"
}
},
"swap_ms": 92,
"same_tab_reused_connection": "203.0.113.1",
"same_tab_new_connection": "203.0.113.2",
"new_context_with_storage_state": {
"exit_ip": "203.0.113.2",
"cookies": {
"cart": "3-items",
"login": "alice"
},
"local_storage": "half-written review"
},
"same_browser_session": true
}Takeaways
- The swap only applies to new connections. The same tab asking the same host again still came out of the old proxy: the browser reused its open keep-alive connection. A host it hadn't connected to yet came out of the new proxy.
- To move everything, open a new context with the old
storageState. A new context has its own connection pool, so every request goes through the new proxy — and the cookies and localStorage came along. - Same browser, same session, no restart: no relaunch and no re-login, and billing continues on the same session.
- Every session is swappable — single proxies, pools and
proxy_rulesalike; no launch option is needed.