[Examples & benchmarks](https://cdpfleet.com/docs/cases) / #6 · 2026-09-30 · Example · Hard · for [web scraping](https://cdpfleet.com/web-scraping), [geo testing](https://cdpfleet.com/geo-testing)

# 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.

[Chromium](https://cdpfleet.com/docs/browsers/chromium)

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

1.  Launch with a residential proxy and `proxy_updatable: true`; set cookies and localStorage; note the exit IP.
2.  Swap the proxy to a SOCKS5 proxy.
3.  Ask the same host again (reused keep-alive connection), then a host we haven't talked to (new connection).
4.  Move everything to a new context with `storageState` and check exit IP, cookies and localStorage.

## The code

The same program in five languages (also [on GitHub](https://github.com/cdpfleet/cdpfleet/tree/main/cases/06-live-proxy-swap), 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:pass@proxy.example.com:8000`
-   `SOCKS_PROXIES` — comma-separated `socks5://user:pass@host:port` proxies

 

```node
// npm install playwright@1.60.0
// 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();
}
```

```python
# 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()
```

```java
// 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();
      }
    }
  }
}
```

```csharp
// 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
// go get github.com/playwright-community/playwright-go@v0.6000.0
// 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_rules` alike; no launch option is needed.

---
Source: https://cdpfleet.com/docs/cases/live-proxy-swap · Markdown: https://cdpfleet.com/docs/cases/live-proxy-swap.md · All docs for AI tools: https://cdpfleet.com/llms.txt
