cdpfleet Docs GitHub Dashboard

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.

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

WhatWhy
proxy_updatable: trueKept in the code for clarity: every cdpfleet session is swappable now, whatever you send.
POST https://<router>/admin/session/proxyThe swap call, sent to the host in your wsUrl with your API key: {session_id, proxy}. It returns in milliseconds.
httpbin.org cookies + localStorageStand-ins for a login: state that must survive the swap.
api.ipify.org / api64.ipify.orgTwo 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, with the raw output). Set these environment variables first:

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

What we got

StepExit IPCookieslocalStorage
before the swap203.0.113.1{"cart":"3-items","login":"alice"}(set)
same tab, reused connection203.0.113.1——
same tab, new connection203.0.113.2——
new context + storageState203.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