← Blog

BLOG

Playwright ConnectOverCDP Persistent Context Example

A working Playwright connectOverCDP persistent context example, plus how to keep profiles alive across remote Chromium sessions in production.

October 9, 20269 min readRemote Browser

# Playwright ConnectOverCDP Persistent Context Example

If you want a Playwright connectOverCDP persistent context example that actually survives a restart, the short answer is: connectOverCDP gives you a live browser, but it does not give you a persistent context by itself. Persistence comes from the browser process you connect to — its user data directory, its profile, and how the remote runtime manages that state between sessions. This post walks through a concrete TypeScript example, explains why browser.newContext() behaves differently over CDP, and shows the production pattern for keeping cookies, localStorage, and logins alive across runs.

The primary keyword here is playwright connectovercdp persistent context example, and the goal is to answer the search intent directly: you have a remote Chromium endpoint, you want Playwright to attach to it, and you want the same authenticated session next time.

What connectOverCDP actually returns

chromium.connectOverCDP(endpointURL) connects Playwright to an already-running Chromium instance over the Chrome DevTools Protocol. It does not launch a browser. It does not create a fresh profile. It attaches to whatever browser is listening on that endpoint.

That distinction matters for persistence:

  • `chromium.launch()` starts a new browser process. You control userDataDir, so you control persistence.
  • `chromium.launchPersistentContext()` starts a browser with a specific profile directory and returns a context directly.
  • `chromium.connectOverCDP()` attaches to a browser that already exists. The default context is whatever that browser already has open.

So when people search for a "persistent context" example with connectOverCDP, what they usually want is one of two things:

  1. Reuse the existing default context of the remote browser (the one with the logged-in profile).
  2. Create a new context over CDP and keep its storage state across sessions.

These are different problems. The first is a connection pattern. The second is a state-management pattern.

The minimal connectOverCDP example

Start with the connection itself. This is the part most examples get right, and it is the part you need before persistence is even on the table.

import { chromium, Browser, BrowserContext, Page } from 'playwright';

async function connectToRemote(): Promise<{
  browser: Browser;
  context: BrowserContext;
  page: Page;
}> {
  // endpointURL comes from your remote browser provider's session API.
  // Example shape: wss://<host>/cdp/<session-id>
  const endpointURL = process.env.CDP_ENDPOINT;
  if (!endpointURL) {
    throw new Error('CDP_ENDPOINT is not set');
  }

  const browser = await chromium.connectOverCDP(endpointURL, {
    timeout: 30_000,
  });

  // connectOverCDP attaches to a running browser, so contexts already exist.
  const contexts = browser.contexts();
  const context = contexts.length > 0 ? contexts[0] : await browser.newContext();

  const pages = context.pages();
  const page = pages.length > 0 ? pages[0] : await context.newPage();

  return { browser, context, page };
}

async function main() {
  const { browser, context, page } = await connectToRemote();

  await page.goto('https://example.com/dashboard', {
    waitUntil: 'domcontentloaded',
  });

  console.log('Title:', await page.title());

  // Do NOT call browser.close() if you want the remote session to persist.
  // Disconnect instead, and let the runtime own the lifecycle.
  await browser.close();
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

Two things to notice.

First, browser.contexts() is not empty when you connect over CDP. The remote browser already has a default context. If you call browser.newContext() blindly, you create a second, isolated context and lose access to whatever profile state the browser was already holding.

Second, browser.close() over CDP is a disconnect, not a process kill. Whether the underlying browser keeps running depends on the runtime you connected to. On a hosted runtime, the session lifecycle is usually controlled by the platform, not by your Playwright script.

Why "persistent context" is a misnomer over CDP

Playwright's launchPersistentContext() is a launch-time API. It creates a browser bound to a userDataDir and returns a BrowserContext. There is no connectPersistentContext() equivalent, because the persistence decision was already made when the browser started.

Over CDP, persistence is a property of the remote browser, not of your Playwright call. Three things determine whether your session survives:

ConcernlaunchPersistentContextconnectOverCDP
Who owns the profileYour script (userDataDir)The remote runtime
When persistence is decidedAt launchBefore you connect
Cookies / localStoragePersist to disk automaticallyPersist if the runtime keeps the profile
Multiple parallel sessionsOne profile per launchDepends on runtime isolation model
Restart behaviorReopen same userDataDirReconnect to same profile or session
Best fitLocal dev, single machineHosted Chromium, multi-tenant agents

The practical takeaway: if you need a persistent context over CDP, you are really asking the remote runtime to give you a persistent profile. Your Playwright code just connects to it.

The production pattern: persistent profiles over CDP

Here is the pattern that holds up when you move from a laptop to a hosted runtime.

  1. Request a session bound to a named profile. The runtime starts Chromium with a user data directory that maps to that profile.
  2. Connect over CDP to the session's WebSocket endpoint.
  3. Reuse the existing default context instead of creating a new one.
  4. Do your work, then disconnect without destroying the profile.
  5. Next run, request a session on the same profile and repeat.

The Playwright side is small. The runtime side is where the state lives.

import { chromium, BrowserContext, Page } from 'playwright';

type SessionInfo = {
  cdpUrl: string;
  profileId: string;
};

// Your runtime's session API returns a CDP URL bound to a profile.
async function createSession(profileId: string): Promise<SessionInfo> {
  const res = await fetch('https://your-runtime.example/sessions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.RUNTIME_TOKEN}`,
    },
    body: JSON.stringify({ profileId, browser: 'chromium' }),
  });

  if (!res.ok) {
    throw new Error(`Session create failed: ${res.status}`);
  }

  const data = (await res.json()) as { cdpUrl: string; profileId: string };
  return data;
}

async function runWithProfile(profileId: string) {
  const session = await createSession(profileId);

  const browser = await chromium.connectOverCDP(session.cdpUrl, {
    timeout: 30_000,
  });

  // Reuse the profile-backed default context.
  const context: BrowserContext = browser.contexts()[0];
  if (!context) {
    throw new Error('No default context on remote browser');
  }

  const page: Page = context.pages()[0] ?? (await context.newPage());

  await page.goto('https://example.com/account', {
    waitUntil: 'domcontentloaded',
  });

  // If the profile is already authenticated, this lands on the dashboard.
  const url = page.url();
  console.log('Landed on:', url);

  // Disconnect. The runtime keeps the profile; the session may be reaped
  // separately depending on your plan.
  await browser.close();
}

runWithProfile('agent-profile-01').catch(console.error);

The important line is browser.contexts()[0]. That is the context carrying the profile's cookies and storage. If you skip it and call browser.newContext(), you get a clean slate and your "persistent" session looks brand new every time.

Storage state as a fallback

Sometimes you cannot get a persistent profile — for example, when the runtime gives you a fresh browser per session and you only need auth to carry over. In that case, export and re-import storage state.

import { chromium } from 'playwright';
import fs from 'node:fs/promises';

const STATE_PATH = './auth-state.json';

async function firstRun(cdpUrl: string) {
  const browser = await chromium.connectOverCDP(cdpUrl);
  const context = browser.contexts()[0];
  const page = context.pages()[0] ?? (await context.newPage());

  await page.goto('https://example.com/login');
  // ... perform login ...
  await page.waitForURL('**/dashboard');

  await context.storageState({ path: STATE_PATH });
  await browser.close();
}

async function laterRun(cdpUrl: string) {
  const state = JSON.parse(await fs.readFile(STATE_PATH, 'utf8'));

  const browser = await chromium.connectOverCDP(cdpUrl);
  // Over CDP you cannot inject storageState into the default context.
  // Create a new context with the saved state instead.
  const context = await browser.newContext({ storageState: state });
  const page = await context.newPage();

  await page.goto('https://example.com/dashboard');
  console.log('Authenticated:', page.url());

  await browser.close();
}

This works, but it has real limits. storageState covers cookies and localStorage, not IndexedDB, service workers, or in-memory session tokens. For agents that log into complex apps, a persistent profile is more reliable than replayed storage state.

Choosing between the two approaches

RequirementPersistent profileStorage state replay
Cookies survive restartYesYes
localStorage survivesYesYes
IndexedDB / service workersYesNo
Works with fresh browser per sessionNoYes
Setup complexityRuntime-sideScript-side
Best forLong-lived agents, logged-in workflowsShort tasks, stateless runtimes

If your runtime supports persistent profiles, use them. If it does not, storage state is the fallback, and you should treat it as a partial solution.

Production criteria before you ship

A few things to check before you rely on this in production:

  • Session lifecycle. Does the runtime keep the browser alive after you disconnect, or does it reap the session on disconnect? This determines whether "persistent" means the profile or the whole session.
  • Profile isolation. If two agents share a profile, they share cookies. Confirm the runtime isolates profiles per tenant.
  • Concurrency limits. Persistent profiles usually cannot be used by two sessions at once. Plan for one active session per profile.
  • Proxy and network settings. Profile persistence is separate from IP persistence. If your workflow depends on a stable egress IP, that is a runtime configuration, not a Playwright one.
  • Observability. A live viewer helps when a persistent session behaves differently than a fresh one. You want to see what the browser actually loaded.

Remote Browser exposes hosted Chromium sessions with CDP access, persistent profiles, a live viewer, and configurable browser settings, so the pattern above maps directly onto the runtime. For the broader architecture, see Remote Browser for AI agents and the documentation.

Common failure modes

The context is empty. You called browser.newContext() instead of reusing browser.contexts()[0]. Fix: check contexts() first.

Login does not persist. The runtime started a fresh profile, or you connected to a different profile ID than last time. Fix: verify the profile identifier in your session request.

Two agents clobber each other. Both sessions used the same profile. Fix: one profile per concurrent agent, or serialize access.

`connectOverCDP` times out. The endpoint URL is wrong, the session already ended, or the runtime requires auth headers. Fix: log the session response and confirm the WebSocket URL is current.

Firefox or WebKit. connectOverCDP is Chromium-only. For other engines, use the appropriate Playwright connect path. See the Playwright CDP documentation for the authoritative API surface.

When to move off local Chromium

If you are running this locally, you are managing userDataDir paths, disk cleanup, and one browser per machine. That does not scale past a handful of agents. A hosted runtime moves the profile, the browser process, and the network egress off your machine, and gives you a CDP URL to connect to. The Playwright code stays the same; the operational surface shrinks.

For a comparison of local versus hosted setups, see Remote Browser online. For pricing and session limits, check /pricing — the numbers change, so read the current page rather than trusting a blog post.

Summary

  • connectOverCDP attaches to a running browser; it does not create a persistent context.
  • Persistence over CDP comes from the remote browser's profile, not from your Playwright call.
  • Reuse browser.contexts()[0] to inherit the profile's cookies and storage.
  • Use storageState only as a fallback when persistent profiles are unavailable.
  • Treat profile isolation, session lifecycle, and concurrency as production requirements, not afterthoughts.

Get the connection pattern right, and the persistent context example becomes a one-line change: connect to the profile-backed session, reuse the default context, and let the runtime own the state.