Playwright Automation Tool in the Cloud: Setup & Benchmarks

TL;DR

  • Running 4 concurrent browsers raised the local process tree to 1,229 MB in 18 processes. Adding 4 CDP connections cost the client 0.3 MB in 2 processes.
  • Every remote CDP call is a network round trip. On a 237 ms connection, 20 cards took 16,010 ms with per-field locators and 254 ms in one page.evaluate().
  • Sessions outlive your socket: reconnect took 1,556 ms with the page still open, and billing continues until provider policy ends it.
  • Page fetch can be faster remotely when the cloud node has a better route: 488 ms against 2,130 ms from my broadband in India.

What is the Playwright automation tool?

The Playwright automation tool is an open-source library from Microsoft that controls Chromium, Firefox and WebKit through one API. You write a script in JavaScript, TypeScript, Python, Java or .NET. The library launches a real browser, navigates, clicks, types, and reads the page back to you.

Microsoft released it in January 2020. Its advantage over the previous generation of tools is auto-waiting: a locator resolves when the element is actionable, so most of the sleep() calls that made older suites flaky disappear. A bundled runner, Playwright Test, handles parallelism, retries, traces and screenshots.

Under the API, Playwright talks to Chromium over the Chrome DevTools Protocol, the same wire protocol Chrome’s own DevTools uses. That detail carries the rest of this guide. A protocol on a socket does not care which machine sits at the other end.

Two kinds of web automation. A test suite runs against your own application on CI, finishes in minutes, and throws the browser away. Unattended automation runs against somebody else’s site, holds a login for days, and cares which IP the request leaves from. The API is identical. The infrastructure underneath is not, and the second job is the one that breaks first.

That second job is what I measured here.

Serverless Playwright means your automation logic runs in a function or container, while Chromium runs somewhere else. Your client reaches that browser over a WebSocket, using the Chrome DevTools Protocol (CDP). Only the client library goes into your deployment.

But the alternative, packaging Chromium into the function itself, costs you a special build and a slow cold start. Stock chrome-headless-shell 140 needs 19 shared libraries an Amazon Linux 2023 base image lacks. A Lambda-ready build closes that gap and expands to roughly 208 MB of the default 512 MB /tmp. Memory limits and orphaned processes follow.

In this guide, I will explain how the remote browser model works, what it costs, and what it requires in production. The worked example runs one persistent profile, the shape a logged-in session needs. A job that wants a fresh identity per target uses a profile per target instead, with its own proxy.

Fact sheet: local, self-hosted pool, and remote CDP

I measured the cloud startup and per-operation latency against a browser in Falkenstein, Germany. My machine in Kanpur, India, ran Playwright 1.63 on Node 25. The client-memory rows came from loopback CDP endpoints, so they isolate the client library’s own cost from the network. The Lambda rows came from a Lambda base image under Docker CPU limits.

playwright-core requires Node 20 or newer. I inferred the pool column from the local figures rather than measuring it here. Absolute values change with geography and page weight. The memory and process ratios hold anywhere, but the latency ratios scale with your own round-trip time.

Metric Local or Lambda headless Self-hosted browser pool Remote cloud browser over CDP
Resident memory per concurrent browser ~270 MB on the host running your code The same ~270 MB, on a node you operate +0.1 MB per CDP connection, over a 161 MB client baseline
Processes on the client host 4 per browser, 18 at 4 browsers 2 (client only) 2 (client only)
Startup 1,389-2,030 ms cold at 1 vCPU in a Lambda base image, 72-252 ms warm on a laptop Sub-second when a warm node is free, minutes when the pool has to scale 5,273-6,616 ms cold provision, 1,554-1,676 ms reconnect to a live session
What you operate Chromium binary, /tmp space, missing shared libraries Kubernetes, autoscaling, Xvfb, node health, restart policy One HTTPS endpoint, plus session and proxy tracking
Session state between runs Discarded with the invocation Needs external storage sync Survives a disconnect, not a stop

A stop in the last row means the provider’s stop call, which ends the session rather than closing your socket. The memory row shows the benefit. The startup row is the one worth reading twice: a cold remote provision costs seconds, and a warm local launch costs milliseconds.

The remote model is worth its cost in 3 cases. You need sessions that outlive the process, memory per browser limits you, or you manage identity and egress as data. An agent working across turns is the first case.

But the argument against it follows from the same table. A cheap VPS running local Chromium avoids the per-call round trip entirely. That setup fits when your targets serve content without bot checks, your jobs are short, and you need no session identity.

 

The infrastructure limits of serverless Playwright

Packaging Chromium inside Lambda is a different job, and the @sparticuz/chromium README documents the deployment steps. But I measured the constraints anyway.

The Playwright and Puppeteer APIs are stable. The cost sits underneath them, in the shared libraries, the decompression, and the pool somebody has to keep running.

Stock headless Chromium 140 still needed the desktop libraries

The library lists people publish for Chromium on Lambda aren’t padding. On the Amazon Linux 2023 Lambda base image, ldd left 19 libraries unresolved for stock chrome-headless-shell 140, among them libX11, libatk-1.0, libgbm, and libasound. A Lambda-specific build closes that gap.

@sparticuz/chromium 149 solves it with its own build. The same ldd check left only 4 unresolved for that binary: libexpat.so.1, libnspr4.so, libnss3.so, and libnssutil3.so. Its bundle ships exactly those, plus the Network Security Services internals loaded later through dlopen.

Its payload expands to roughly 208 MB in /tmp, leaving about 304 MB of the default ephemeral storage for everything else. Both figures come from that release, so pin the package version and measure what actually appears in /tmp after executablePath() resolves.

Cold start is a decompression cost, and buying memory stops helping past 1 vCPU

Brotli decompression is single-threaded, so the expansion phase scales with vCPU rather than memory. Decompressing the binary took a median 12,493 ms at 0.29 vCPU against 1,203 ms at 1 vCPU. Higher vCPU runs differed only by measurement noise.

Sizing at 512 MB to save money makes the phase roughly 10 times slower. Sizing above the 1,769 MB point where Lambda allocates a full vCPU buys little more speed.

One problem: @sparticuz/chromium decompresses inside executablePath(). At module scope that cost runs during initialization, and in the handler it runs inside your invocation. At 1 vCPU it is 1,203 ms and the placement barely matters. At 0.29 vCPU it is 12,493 ms, so measure before you choose.

A restart policy is infrastructure somebody maintains

A scheduled restart bounds any failure that grows with session age. It is also infrastructure: somebody writes the pool, the health probe, the drain, and the restart, then keeps them working.

Hosted endpoints move that work to the provider. Gologin’s gologin-agent-browser-cli clears what it calls “a stale launch slot” with sessions --prune. A client that disconnects without stopping its session holds a concurrency slot until something reclaims it.

The CLI defaults are one starting point for your own pool code. Before opening a session, stop anything that has been idle for more than 10 minutes. On a slot-limit rejection, stop every tracked session, wait 3 seconds, and retry once. A second failure means you have hit a capacity limit, not a race condition.

Those defaults suit interchangeable sessions. Keep a profile holding a logged-in state out of the idle sweep, and export its cookies before you stop it deliberately. The identity section below has that call.

Connect Playwright over CDP to a cloud browser grid

One line connects you. After that, 3 things change: session lifecycle, round trips, and network identity. The connection itself has one compatibility detail that costs people hours.

Playwright’s connectOverCDP treats an http(s):// argument as a DevTools discovery endpoint, appending /json/version/. A WebSocket-only endpoint has no /json/version/ path, so the client produces this:


    browserType.connectOverCDP: Unexpected status 404 when connecting to
    https://cloudbrowser.gologin.com/connect/json/version/?token=...
    This does not look like a DevTools server, try connecting via ws://.
  

Puppeteer’s browserWSEndpoint upgrades the scheme itself: puppeteer-core accepted both https:// and wss://, while playwright-core accepted only wss://. Use wss://.

Playwright’s own documentation includes a warning worth reading first. It calls connectOverCDP “significantly lower fidelity than the Playwright protocol connection via browserType.connect()”. It also warns about browsers launched outside Playwright without its curated argument list: “some of Playwright functionality may be broken upon connecting to the browser”. On a hosted endpoint, that launch pattern is the normal case.

The node behind my endpoint ran --no-zygote, --disable-dev-shm-usage, and --font-masking-mode=2. Only --disable-dev-shm-usage is on Playwright’s own list. So probe the APIs you depend on before you build on them. Everything I probed against this endpoint worked.

Test suites have their own managed option. Playwright Workspaces is Microsoft’s managed service for that case. It runs an existing suite through its own package, with no CDP socket in the path.

Gologin cloud browser

Gologin Cloud Browser is one hosted CDP endpoint. It keeps a browser profile, with its cookies, storage and proxy, as data you configure over HTTP. A single URL carries the token and the profile ID, and the Cloud Browser getting-started reference documents the shape. The pattern below fits any CDP endpoint.

Before any code here runs, you need an account, an API token, and a profile for the session to attach to. Set them up in that order:


    # 1. Sign in at https://app.gologin.com, open API, and copy a token from Access tokens.
    export GL_TOKEN='<your API token>'
    
    # 2. Install the client. Node and npm are assumed from here on.
    npm i playwright-core
    
    # 3. Create a profile if you have none. os selects the profile's OS template.
    curl -s -X POST "https://api.gologin.com/browser/quick" \
      -H "Authorization: Bearer $GL_TOKEN" -H "Content-Type: application/json" \
      -d '{"name":"cdp-demo","os":"win"}' -w '\n%{http_code}\n'
    export GL_PROFILE='<the id field from that response>'
  

Step 1: Happens outside your terminal, on the API page’s Access tokens list, (in your Gologin account):

A URL missing &profile= answers 400 with {"error":"profile missing"}. A plan at its parallel-session limit answers 403 with You've reached max parallel cloud launches limit. Fix these on the HTTPS path, where the body names the cause.

This script connects, reads the remote build, and extracts rows from a page. That last step is one protocol call:


    import { chromium } from 'playwright-core';
    
    const CDP_URL = `wss://cloudbrowser.gologin.com/connect`
     + `?token=${process.env.GL_TOKEN}&profile=${process.env.GL_PROFILE}`;
    
    const browser = await chromium.connectOverCDP(CDP_URL, { timeout: 60_000 });
    try {
      console.log('remote build:', browser.version());
    
      // contexts()[0] is the persistent profile. newContext() would give you a blank one.
      const context = browser.contexts()[0];
      const page = context.pages()[0] || await context.newPage();
    
      await page.goto('https://books.toscrape.com/', { waitUntil: 'domcontentloaded' });
    
      // The DOM walk runs inside the remote browser. One round trip, not 80.
      // Optional chaining, so one malformed card still yields a usable row.
      const rows = await page.evaluate(() =>
        [...document.querySelectorAll('article.product_pod')].map(el => ({
          title: el.querySelector('h3 a')?.getAttribute('title') ?? null,
          price: el.querySelector('.price_color')?.textContent.trim() ?? null,
        })));
    
      console.log(`${rows.length} rows`, rows[0]);
    } finally {
      // Always runs. close() drops the socket. DELETE /browser/{id}/web
      // ends the billed session, and belongs here too. Built 2 sections below.
      await browser.close();
    }
  

That run printed:


    remote build: 150.0.7871.47
    20 rows { title: 'A Light in the Attic', price: '£51.77' }
  

I verified these selectors against books.toscrape.com in September 2026.

That URL shape puts a bearer credential in a query string, where proxies and error text both capture it. The 404 above is an example, and I truncated its token. Treat any error containing that URL as a leaked credential: rotate the token, and redact the URL before you paste it anywhere.

One line in that script determines whether persistent sessions survive: browser.contexts()[0]. A CDP connection to a profile-backed browser arrives with a default context holding the profile’s cookies and storage. browser.newContext() creates a separate, isolated cookie jar.

Check that isolation in your own session. A cookie set inside a newContext() was invisible from a second newContext() and from contexts()[0]. The default context still held a cookie and a localStorage value written 20 seconds earlier, across a disconnect and reconnect. A stop ends that, so save what you need before you call one.

Isolation works, which is why a newContext() on a persistent profile opens without the logged-in state you were paying to keep. storageState copies cookies into a fresh context when you need both isolation and the login. That run reconnected after browser.close(), not after stopping the session on the provider. Prove the same behavior across a stop before you rely on it.

Cloud Puppeteer automation follows the same rule with different names. Reach the existing page through browser.pages(). A call to browser.newPage() opens an extra tab instead.

Concurrency math: what serverless Playwright actually costs

Memory per worker is the first limit on most client hosts. The measured value was lower than the round numbers people quote.

Concurrent browsers Local (chromium.launch per worker) Client change per CDP connection, from a 161 MB baseline
0 (Node + library only) 142 MB / 2 processes +0.0 MB / 2 processes
1 415 MB / 6 processes +0.0 MB / 2 processes
2 722 MB / 10 processes +0.0 MB / 2 processes
4 1,229 MB / 18 processes +0.3 MB / 2 processes

The third column shows the change from that run’s own client baseline of 161 MB, measured over loopback.

An earlier run measured absolute RSS instead. The reading for 4 remote browsers fell below the client’s own baseline. A browser cannot use negative memory. So these figures are deltas taken with forced GC over 10 rounds.

An additional connection cost 0.1 MB at the median and never more than 5.2 MB. Each extra local browser cost about 270 MB on average, ranging from 248 to 307 MB.

Sharing one browser across contexts is cheaper: each context costs roughly 100 MB against about 270 MB for a separate browser, a 2.7 times saving. I measured that locally.

The cost is that contexts share a crash domain. A hosted endpoint may also limit how many contexts you can open, so confirm that yours allows more than one before designing around them. Sharing a crash domain makes --single-process on Lambda a known reliability trade-off.

The client library is part of that 161 MB baseline. puppeteer-core left the process at 80 MB resident and unpacked to 5.7 MB. playwright-core left the process at 140 MB and unpacked to 12.8 MB.

So on your own host, you pay N x ~270 MB plus 4 processes per browser. On a remote endpoint, you pay the library baseline once, in 2 processes, plus per-session provisioning and usage.

The table below prices 100 concurrent browsers on AWS Fargate. I used the ARM rates for us-east-1 as of September 2026, $0.03238 per vCPU-hour and $0.00356 per GB-hour:

Line item 100 browsers on Fargate 100 browsers on a managed endpoint
Task shape 4 vCPU / 8 GB ARM, the minimum valid pairing at 4 vCPU in the ECS task size table None to size
Browsers per task 6, at an assumed 0.67 vCPU each None to size
Tasks needed 17 0
Compute $2.69/hour, or $0.027 per browser-hour One published browser-hour rate
Also billed NAT egress, ephemeral storage, load balancer, image registry Proxy traffic, usually per GB
Operational work Pool code, health probes, drain-and-replace, upgrades, on-call Pool and upgrades included, session tracking stays yours

The compute figure is the honest minimum, and it’s low: a managed browser-hour rate has to compete with roughly $0.027. That figure assumes a density I didn’t measure, 6 browsers per 4-vCPU task. At 8 per task, it drops to about $0.020, and at 4, it rises to about $0.040, so measure your own before quoting it. Your own usage is one API call away: GET /cloud-usage/profile/{id}/stats returned {"totalHours":1.5} for my test profile, with a workspace-level endpoint alongside it.

GoLogin’s published plans price that hour directly, as of September 2026:

Gologin plan, September 2026 Included Parallel sessions Overage
$4.50/month 50 browser-hours 1 $0.09/hour
$8.00/month 100 browser-hours 2 $0.08/hour
$14.00/month 200 browser-hours 3 $0.07/hour

On both September 2026 figures that is 2.6 to 3.3 times the Fargate compute floor. The gap is the pool code, health probes, and upgrades you don’t write. Extra parallel sessions are $2 each per month above the plan count. Check the parallel-session count against your concurrency, and price proxy traffic separately with your proxy provider.

The Operational work row sets the budget. Both columns carry real cost. Writing the restart and drain logic costs engineering time that never appears on an AWS invoice. So price your own hours before you price the browsers.

Defensive patterns for what breaks in production

Each failure below has a defensive pattern. Earlier sections covered what breaks before a session exists. These failures only appear once you have one.

End the remote session explicitly: closing the browser may not stop it

Playwright’s documentation is accurate. But it’s easy to misread. For a connected browser, the browser.close() reference says the call “clears all created contexts belonging to this browser and disconnects from the browser server”. Disconnects, not terminates.

So I called browser.close() and reconnected. The reconnect took 1,556 ms, warm-session latency. The page from the previous run was still open remotely. Calling the provider’s stop endpoint instead raised the same reconnect to 6,046 ms, a cold provision.

The reconnect time tells you which of the two actually happened:

That asymmetry has a cause you can read from the node, though it’s one provider’s deployment detail and can change without notice. SystemInfo.getInfo over a raw CDP session showed that the browser launched with --user-data-dir=/dev/shm/gologin-tmp/gologin_profile_<id>, so the profile lives in tmpfs, in memory.

A warm reconnect attaches to a directory that’s still resident. A stop removes the directory with the container. Losing the directory forces an explicit commit to persist profile state. The identity section below covers that call.

The same command line includes --disable-dev-shm-usage, which looks contradictory. It isn’t: Chrome keeps its own shared-memory files off tmpfs while the profile directory sits there by design. --restore-last-session is there too, so a cold container can open showing the previous run’s URL. Read the reconnect time, not the tab.

Cold provision time is an architecture choice rather than a constant. Abhishek Bhardwaj of OpenAI describes 3 ways a sandbox cloud hides that latency:

  • A warm pool, which costs CPU and memory while it’s idle.
  • A memory snapshot restored on demand, which starts in milliseconds.
  • A warm pool built from snapshots, which does both.

Your provider’s approach changes whether keeping a session alive is worth the cost. At the 5 to 6 seconds measured here, reuse beats a cold provision.

I left the session longer and got the same result. Five minutes after browser.close(), the reconnect still took 1,587 ms with the page loaded. The 4 further reconnects were all warm.

On per-hour billing, you’re paying for a session nobody is using. End sessions explicitly:


    const STOP = `https://api.gologin.com/browser/${process.env.GL_PROFILE}/web`;
    
    async function stopSession() {
      const res = await fetch(STOP, {
        method: 'DELETE',
        headers: { Authorization: `Bearer ${process.env.GL_TOKEN}` },
      });
      return res.status;
    }
    
    // One connect, one finally: socket first, then the billed session.
    const browser = await chromium.connectOverCDP(CDP_URL, { timeout: 60_000 });
    try {
      // ... your work ...
    } finally {
      await browser.close(); // drops the socket
      console.log('stop ->', await stopSession()); // ends the billed session
    }
  

Calling stopSession() after a scrape returned the documented success status:


    stop -> 204
  

Put the stop call in a finally block, not only on the success path.

Sessions outlive sockets at other providers too. A product engineer at one of them names the same hazard: costs grow unnoticed on a connection you forget to close.

The same engineer describes 2 policies at that provider that differ from the one measured here. An idle timeout closes the connection when a script goes quiet. A dropped connection ends the session at no charge. Read both for your provider before you rely on either.

Batch protocol work: every call crosses the network, and extra calls cost time

Playwright maintainer Pavel Feldman named the design assumption in a 2020 thread on attaching to an existing browser. They warned that making each CDP request “fly over the network would significantly contribute to the latency and flakiness”. You pay that cost as soon as the browser leaves your machine.

I measured a median round-trip time of 237 ms on the test connection, over 20 samples of Browser.getVersion, a trivial command. Per-operation costs differ sharply, at one run per operation:

Operation Local Remote, 237 ms RTT
goto books.toscrape.com 2,130 ms 488 ms
page.title() 4.8 ms 514 ms
page.evaluate(() => 1) 0.8 ms 247 ms
30 × evaluate, cost per call 0.6 ms 259 ms
waitForSelector 12.7 ms 1,962 ms
page.screenshot() 38.7 ms 2,504 ms

That 4.4 times speedup on goto comes from the page fetch happening next to the browser. The cloud node had a better route to the target than the client’s broadband. Page-weight work moves toward the browser, and protocol work moves away from it.

The last 2 rows break scripts in a loop. One Playwright call is not one protocol call. A locator resolves and then acts, and waitForSelector polls until the selector matches, so budget several round trips per API call.

One waitForSelector took 1,962 ms here. So a per-item waitForSelector loop over 20 cards projects to roughly 39 seconds, spent waiting for elements that were already in the DOM.

The main fix is to make fewer round trips. Per-field locators issue 80 calls for 20 product cards. One page.evaluate() issues 1:

Approach (remote) Round trips Run 1 Run 2 Run 3
locator per field 80 16,098 ms 16,010 ms 15,256 ms
Single page.evaluate() 1 500 ms 254 ms 247 ms

Normalized output matched between the two approaches on all 3 runs. At this 237 ms round trip, the evaluate path is 63 times faster on medians. The same answer closed a 2026 batching pull request: “a single evaluate would do a better job”.

Both approaches cross the same network boundary, and only the number of crossings differs:

Both approaches cross the same network boundary, and only the number of crossings differs:

The same extraction, written both ways, runs against an open page:


    // 80 round trips: one per field, per card.
    const slow = [];
    for (const card of await page.locator('article.product_pod').all()) {
      slow.push({
        title: await card.locator('h3 a').getAttribute('title'),
        price: (await card.locator('.price_color').textContent() ?? '').trim(),
      });
    }
    
    // 1 round trip: the same walk, run inside the browser.
    const fast = await page.evaluate(() =>
      [...document.querySelectorAll('article.product_pod')].map(el => ({
        title: el.querySelector('h3 a')?.getAttribute('title') ?? null,
        price: el.querySelector('.price_color')?.textContent.trim() ?? null,
      })));
    
    console.log(slow.length, fast.length, JSON.stringify(slow) === JSON.stringify(fast));
  

Same row count, same data:


    20 20 true
  

That substitution is for reading, not for clicking. A click fired from inside page.evaluate() arrives as isTrusted: false. A page can test for that flag and drop the click without raising an error, so nothing appears to happen.

Playwright’s own click() and a raw Input.dispatchMouseEvent both arrive as isTrusted: true. Batch the extraction, and leave interaction to locators.

Beyond batching, 2 options are worth knowing. Puppeteer’s connect() accepts networkEnabled: false, an experimental setting that stops the Network domain from streaming events you never read. It also disables HTTPRequest and HTTPResponse.

The round-trip problem also has a standards-track answer in WebDriver BiDi. Puppeteer’s documentation describes it as “bi-directional communication, making it fast by default”, and Puppeteer already enables it when launching Firefox. If your workload is CDP over a WAN, track that migration.

One exception: not every slow remote call is protocol overhead. The reply to a request to batch locator screenshots noted that locator.screenshot() latency comes from “forcing the entire rendering pipeline in the browser”, not from IPC. The 30-call evaluate(() => 1) row keeps rendering out of the measurement.

Recover from dropped sockets, and Target closed is often your own bug

WebSocket connections fail: idle proxies close them, sessions time out, and containers get rescheduled. Retry those connections with backoff.

The connect URL also answers over plain HTTPS on its own path. The earlier 404 came from the /json/version/ suffix Playwright appends, rather than from the URL itself. The HTTPS path makes a cheap pre-flight.

Two details matter. Read the response body when X-Error-Reason is absent, and treat a probe that can’t run as no signal. A DNS blip shouldn’t block a connection that would have worked.

The first of those isn’t hypothetical. When I probed a profile that wouldn’t launch, the endpoint tried to return the reason in that header. It answered 500 Invalid character in header content instead. The reason never arrived, and only the fallback chain produced anything at all:


    // Reads the refusal reason early. Diagnostics only: on failure, connect anyway.
    async function preflight(url, timeoutMs = 5_000) {
      const ctrl = new AbortController();
      const timer = setTimeout(() => ctrl.abort(), timeoutMs);
      try {
        const res = await fetch(url.replace('wss://', 'https://'), { signal: ctrl.signal });
        if (!res.ok) {
          const reason = res.headers.get('x-error-reason')
            || (await res.text().catch(() => '')).trim().slice(0, 120)
            || res.statusText;
          throw new Error(`cloud session refused (${res.status}): ${reason}`);
        }
      } catch (err) {
        if (err.message?.startsWith('cloud session refused')) throw err;
        // probe unavailable: fall through and let CDP decide
      } finally {
        clearTimeout(timer);
      }
    }
  

Put preflight, stopSession from above, and the wrapper below in one file. Add import { chromium } from 'playwright-core' and the same CDP_URL, the wss:// connect URL carrying your token and profile ID. The wrapper calls preflight and stopSession. An anti-bot block isn’t a transport failure, and a retry makes the block worse, so the wrapper treats a detected challenge as terminal:


    // endSession true ends the billed session after every attempt. Set it false to keep the
    // session warm and skip the next cold provision, at the cost of metering while idle.
    async function withBrowser(fn, { attempts = 3, endSession = true } = {}) {
      let lastErr;
      for (let i = 1; i <= attempts; i++) {
        let browser;
        try {
          await preflight(CDP_URL);
          browser = await chromium.connectOverCDP(CDP_URL, { timeout: 60_000 });
          return await fn(browser);
        } catch (err) {
          lastErr = err;
          // A detected challenge is terminal. Never retry into a block.
          // PROFILE_BUSY is this endpoint's slot-in-use rejection. Substitute yours,
          // and on a capped account stop every tracked session before retrying.
          const retryable = !err.terminal &&
            /Target closed|Session closed|WebSocket|ECONNRESET|Timeout|PROFILE_BUSY/i
            .test(String(err.message));
          if (!retryable || i === attempts) break;
        } finally {
          // close() drops the socket. stopSession() ends the billed session.
          if (browser) await browser.close().catch(() => {});
          if (endSession) await stopSession().catch(() => {});
        }
        await new Promise(r => setTimeout(r, 2 ** i * 1000 + Math.random() * 500));
      }
      throw lastErr;
    }
  

Call assertNotChallenged before each action, not only after navigation. A challenge can appear mid-session, and the cost is acting on it without noticing:


    // Match on text and status together: a block renders rather than throws, and text
    // matching is a starting point, nothing more.
    const CHALLENGE_PATTERNS = [
      { reason: 'captcha', pattern: /\bcaptcha\b/i },
      { reason: 'human verification', pattern: /verif(?:y|ying) (?:you(?:'| a)?re|that you are) human/i },
      { reason: 'interstitial', pattern: /just a moment|checking your browser|press (?:&|and) hold/i },
      { reason: 'access denied', pattern: /access denied|request blocked|you have been blocked|forbidden/i },
      { reason: 'bot protection', pattern: /unusual traffic|robot check|are you a robot|automated queries|security check/i },
    ];
    
    async function assertNotChallenged(page, response) {
      const [title, text] = await Promise.all([
        page.title(),
        // Reads the first 2000 chars. A challenge below that escapes the match.
        page.evaluate(() => document.body?.innerText?.slice(0, 2000) ?? ''),
      ]);
      const hit = CHALLENGE_PATTERNS.find(c => c.pattern.test(`${page.url()}\n${title}\n${text}`));
      const status = response?.status();
      if (!hit && status !== 403 && status !== 429) return;
      const err = new Error(`CHALLENGE_DETECTED: ${hit?.reason ?? `HTTP ${status}`}`);
      err.terminal = true; // never retry into a block
      throw err;
    }
  

Pointed at a page carrying human-verification wording, the guard throws with the reason attached and with terminal set, so the wrapper stops instead of retrying:


    CHALLENGE_DETECTED: human verification
  

When I first wrote this guard, it matched verify you are human, and it missed Cloudflare’s Verifying you are human. One gerund is the difference between detecting a block and scraping the block page as data. The list above flagged all 4 challenge pages I tested and let a clean product page through.

The status check exists because any word list becomes outdated. A page genuinely about captchas matches the check too, so log the reason and read it before you act.

Here’s the complete version. page.goto() returns a response, so the status half of the check has something to read. A click or a fill returns nothing. Passing no response skips that half and uses text alone:


    const title = await withBrowser(async (browser) => {
      const context = browser.contexts()[0];
      const page = context.pages()[0] || await context.newPage();
      const response = await page.goto('https://books.toscrape.com/', { waitUntil: 'domcontentloaded' });
      await assertNotChallenged(page, response);
      await page.click('article.product_pod h3 a');
      await assertNotChallenged(page); // text and URL only, no response to read
      return page.title();
    });
  

I borrowed that pattern. The vendor’s CLI keeps a labeled-pattern array of its own and marks a set of session errors as terminal. The set includes CHALLENGE_DETECTED and excludes PROFILE_BUSY, which is why PROFILE_BUSY sits in the retry test earlier. Starting from an existing classification saves inventing one per project.

The check runs before every click, type, and fill when the CLI drives local Chromium. The CLI’s diagnosis for an unexplained close reads “the site may have shown a challenge or Orbita may have crashed”. Orbita is the vendor’s Chromium fork. So on a remote endpoint, the guard is yours to add.

That wrapper handles transport failures. Target page, context or browser has been closed often comes from somewhere else: a missing await on a Playwright call in your own code. Check for the missing await before blaming the network.

Attach a proxy per profile: without one, sessions can share an exit IP

I ran 2 cloud profiles from different OS templates, both at proxy.mode: "none". Both exited from the identical datacenter address at the same time. Later runs received different addresses, so the exit pool is shared and you get whichever address is free.

Both profiles reported the same timezone and Accept-Language, taken from the exit address rather than the profile. WebGL and font values differed per profile. Timezone and language matched.

Setting them on the profile isn’t a workaround. autoLang fills the language from the exit address, and timezone.fillBasedOnIp fills the timezone. Both were on in the profile I ran, so its stored language didn’t reach the session.

When you need distinct exits, attach a proxy per profile. The host, port, and credentials below are placeholders. Real values come from your proxy provider, not from the browser vendor:


    # Read these from the environment. Inline values land in shell history.
    curl -i -X PATCH "https://api.gologin.com/browser/$GL_PROFILE/proxy" \
      -H "Authorization: Bearer $GL_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"mode":"http","host":"proxy.example.net","port":10000,
    "username":"user","password":"pass"}'
  

A successful update answers with no body:


    HTTP/1.1 204 No Content
  

Read the profile back afterward with GET /browser/features/{id}/info-for-run, the same call the SDK uses. Confirm that proxyEnabled reads true and that the proxy block matches what you sent. A profile whose proxy fails validation stops at launch, which is safer than a silent fallback to the datacenter pool.

But reading it back is necessary and not sufficient. I attached 2 built-in geolocation proxies. Both read back correctly and still returned 503 Failed to start browser at launch. The account had likely reached a traffic limit on its built-in allowance.

The profile launched again as soon as I removed the proxy. A third-party residential proxy attached first time. Treat a successful launch as the validation step.

When one does validate, the change appears in a single session. Unproxied, the profile exited from a German datacenter address and reported Europe/Berlin with de,de-DE,en-US,en.

Attaching a residential proxy in the UAE moved the exit. The timezone moved to Asia/Dubai, and the languages moved to ar,ar-AE,en-US,en. The profile itself specified none of those.

The browser’s own settings close the WebRTC path. The proxy doesn’t close it. ICE candidate gathering, the WebRTC connectivity check, can expose a host address independently of your HTTP egress.

In the proxied session above, ICE gathering returned one server-reflexive candidate matching the proxy exit and no host candidate at all. The profile’s webRTC block read mode: alerted, isEmptyIceList: true, and fillBasedOnIp: true. Those settings closed the path in that session. Read that block back from info-for-run before you trust a proxied session.

Geo-routing changes the DOM, not only the language. Localized sites differ by region in layout, currency, available fields, and sometimes whole modules.

The dangerous failure mode is silent. The response appears valid, the selectors match, and the values are wrong for the market you meant to measure. Pin the exit region per job and assert on something region-specific early, such as a currency symbol.

Debugging remote sessions and the limits worth knowing

Most of the debugging tooling still works after the move. The live view and a raw screencast replace the local DevTools window.

Watching a live session

POST /browser/{id}/web starts a session and answers with a remoteOrbitaUrl. That URL opens a streaming view of the session in an ordinary tab while your script keeps control. The view gives you the same visibility a local browser would. That URL contains the session id and requires no other authentication, so treat it as a credential.

Here’s the demo profile mid-run, with its name in the address bar and the page the script had just loaded:

Here's the demo profile mid-run, with its name in the address bar and the page the script had just loaded:

A raw CDP session gives you the same view under your own control. Page.startScreencast streams frames straight to your client. One capture I ran returned 7 frames across 7.8 seconds.

The capture is enough to watch a stuck job or keep the last frame before a failure. And the approach works against any CDP endpoint rather than one vendor’s console. Frames arrive as events, and you have to acknowledge each one or the stream stops:


    import { writeFileSync } from 'node:fs';
    
    // `context` and `page` come from any connected session.
    const cdp = await context.newCDPSession(page);
    let last = null;
    
    cdp.on('Page.screencastFrame', async ({ data, sessionId }) => {
      last = data; // base64 JPEG of the viewport
      await cdp.send('Page.screencastFrameAck', { sessionId });
    });
    
    await cdp.send('Page.startScreencast', { format: 'jpeg', quality: 60, everyNthFrame: 1 });
    // ... drive the page ...
    await cdp.send('Page.stopScreencast');
    
    if (last) writeFileSync('last-frame.jpg', Buffer.from(last, 'base64'));
  

Interception, tracing, video and PDF all work over CDP

Playwright’s fidelity warning is testable, so I probed a hosted endpoint for the features people expect to work worse over CDP. All of them worked:

  • page.route() interception aborted 23 of the 24 image, CSS, and font requests on one page load.
  • context.tracing.start() and stop() produced a trace.zip that opens in the standard viewer. An empty capture came to 85 KB, and one around a navigation and a click came to 410 KB.
  • page.screenshot(), page.pdf(), addInitScript(), setGeolocation(), setExtraHTTPHeaders(), and emulateMedia() all returned normally.
  • recordVideo wrote a 1.01 MB WebM for roughly 10 seconds of scrolling and navigation.
  • context.newCDPSession() gave raw protocol access for anything the high-level API misses.

I opened the trace from that run. The action timings, DOM snapshots, and network requests are all intact, captured from a browser running elsewhere:

The action timings, DOM snapshots, and network requests are all intact, captured from a browser running elsewhere:

Request interception costs latency rather than saving it, because each paused request needs a decision from your client. On a 237 ms connection, routing 24 image, CSS, and font requests added 154 ms. Where you can, block by resource type with the CDP Fetch domain, and reserve route() for requests you need to inspect.

Page weight changes which cost dominates. My measurement ran on a static page with 24 such requests, where the per-request decision is the bigger cost. On JS-heavy pages, the saved bytes matter more.

A Go operator running millions of pages a day reports that Fetch-domain blocking removes 20-30% of render time. Measure it on your own pages.

Five limits to plan for in your architecture

All 5 apply no matter how you write your client:

  • Uploads totaling 50 MB or more fail in Playwright 1.63. Playwright buffers files for a browser that isn’t co-located, then refuses the transfer once their combined size reaches a hard limit: “Cannot transfer files larger than 50Mb to a browser not co-located with the server”. Downloads fail differently. download.saveAs() copies from the browser host’s filesystem, which your client can’t see.
  • Video and the persistent profile combine through storageState. recordVideo is a newContext() option, and newContext() gives you an empty cookie jar. Copying cookies with storageState is the partial workaround.
  • Latency has a minimum set by geography. Choose a browser region near your targets and batch protocol work. No client-side change removes the round trip.
  • Cold provisioning costs seconds. First connection to a stopped profile took 5,273-6,616 ms across 6 measurements. At that cost, design for session reuse, and check the number on your own provider. A product engineer at another provider advises one page per browser, and reports startup there fast enough to make cold starts cheap.
  • Sessions stay warm, and a running session is a running bill. After a clean browser.close(), one session stayed warm on every reconnect I tried. So track sessions yourself, and confirm your provider’s idle timeout before you rely on one.

Identity is data you control through an API

A site reads a session’s identity from 2 places: the profile you configure, and the address the traffic leaves from. The proxy section above covers the address. The profile half is data you can read and write over HTTP.

Cookies and profile state you can carry

The cookie jar is the clearest case. GET /browser/{id}/cookies returned 51 records for my test profile, each with domain, expirationDate, httpOnly, sameSite, and secure.

POST writes a jar back. Lifting one out takes a single call:


    # Lift a logged-in jar out of a profile with one HTTP call.
    curl -s -H "Authorization: Bearer $GL_TOKEN" \
      "https://api.gologin.com/browser/$GL_PROFILE/cookies" > jar.json
  

The export format needs translating before Playwright accepts it. The export uses expirationDate where Playwright’s addCookies expects expires, and it emits no_restriction where Playwright expects None. You have to drop a sameSite of unspecified rather than translate it, because Playwright rejects the value. The vendor’s own CLI includes a cookieNormalizer for exactly this translation, so budget for the step.

The local SDK has a one-way default worth knowing before you rely on persistence: in gologin 3.0.4, writeCookiesFromServer is true and uploadCookiesToServer is false. Cookies go from the server to the browser, and they come back only if you opt in. Opting in keeps a logged-in profile usable from any machine that connects to it.

On that path, profile state reaches the server through an explicit commit that zips the directory and uploads it to a storage gateway. Neither that call nor the gateway appears in the published API spec. The cookie endpoints above are the documented durable path, and they carry cookies only. Local storage travels with the directory, so keep anything that has to survive a stop in cookies.

Controlling the profile and the exit address changes what a site sees, not what you’re allowed to collect. Read robots.txt, honor Crawl-delay, and set concurrency against the target’s capacity rather than yours. Under GDPR and similar laws, collecting personal data is regulated, not just storing it. The web scraping guides explain the broader collection patterns.

Measure your own endpoint before you scale

Measure the round-trip time to your candidate browser region, then count the protocol calls in your busiest extraction path. Those numbers and the weight of your pages predict your remote performance better than any published benchmark, mine included. This script reports them against your own endpoint:


    // cdp-bench.mjs. Measures your endpoint. npm i playwright-core
    // Run with --expose-gc so the memory row is a real delta rather than drift.
    // That row is the whole session's delta, not the +0.1 MB per connection measured earlier.
    import { chromium } from 'playwright-core';
    
    const clock = async (fn) => { const t = performance.now(); await fn(); return performance.now() - t; };
    const rss = () => { if (global.gc) { global.gc(); global.gc(); } return process.memoryUsage().rss / 1e6; };
    
    const base = rss();
    const browser = await chromium.connectOverCDP(process.env.CDP_URL, { timeout: 60_000 });
    const ctx = browser.contexts()[0] ?? await browser.newContext();
    const page = ctx.pages()[0] ?? await ctx.newPage();
    const cdp = await ctx.newCDPSession(page);
    
    const rtt = [];
    for (let i = 0; i < 20; i++) rtt.push(await clock(() => cdp.send('Browser.getVersion')));
    rtt.sort((a, b) => a - b);
    
    const goto = await clock(() => page.goto(process.env.TARGET ?? 'https://example.com/', { waitUntil: 'domcontentloaded' }));
    const wait = await clock(() => page.waitForSelector('body'));
    const shot = await clock(() => page.screenshot());
    const chatty = await clock(async () => { for (let i = 0; i < 30; i++) await page.evaluate(j => j, i); });
    const batched = await clock(() => page.evaluate(k => Array.from({ length: k }, (_, i) => i), 30));
    
    console.log(`round trip p50 ${((rtt[9] + rtt[10]) / 2).toFixed(1)} ms`);
    console.log(`goto ${goto.toFixed(1)} ms`);
    console.log(`waitForSelector ${wait.toFixed(1)} ms`);
    console.log(`screenshot ${shot.toFixed(1)} ms`);
    console.log(`30 calls ${chatty.toFixed(1)} ms`);
    console.log(`1 call, 30 values ${batched.toFixed(1)} ms -> ${(chatty / batched).toFixed(1)}x cheaper`);
    console.log(`client rss +${(rss() - base).toFixed(1)} MB${global.gc ? '' : ' (rerun with --expose-gc)'}`);
    
    await browser.close();
  

A loopback run with no network in the path prints this:


    round trip p50 0.2 ms
    goto 2241.7 ms
    waitForSelector 34.6 ms
    screenshot 213.0 ms
    30 calls 9.1 ms
    1 call, 30 values 0.9 ms -> 9.9x cheaper
    client rss +17.3 MB
  

Treat those numbers as a minimum for your own endpoint, except goto: a cloud node can have a better route to your target. Run the script on the host your jobs use. Even on loopback, one call beat 30 by about 10 times. So batching is worth doing before latency is your problem.

Set 3 defaults before the first production run:

  • Wrap every session in try/finally and call the provider’s stop endpoint there, not browser.close() alone. On a profile you are keeping logged in, export its cookies in the same block before the stop.
  • Replace per-field locator chains with one page.evaluate() for extraction, and keep locators for interaction, where auto-waiting and trusted events are worth the cost.
  • Attach a proxy to each profile, and confirm proxyEnabled reads true before scaling the job.

Final thoughts

Gologin Cloud Browser is one CDP endpoint you can point connectOverCDP at, with the profile, cookies and proxy managed over HTTP. You change one line. The stop call, the retry wrapper, and the challenge guard are the rest of the work.

Prices move, so check the published rates before you commit. Whichever provider you pick, probe its browser for the same features before you scale.

FAQ

How much Playwright code has to change to use connectOverCDP against a cloud browser?

The connection is one line: swap chromium.launch() for chromium.connectOverCDP() against a wss:// URL. The rest is yours to write, starting with a stop call in finally, a retry wrapper for dropped sockets, and a challenge guard. You move extraction from per-field locators to one page.evaluate(). The logged-in profile is already in browser.contexts()[0].

Why move Playwright from Lambda to a remote browser?

Moving helps when sessions must outlive the invocation, when memory per browser limits you, or when you manage identity and egress as data. Lambda works, but decompressing the browser binary took a median 12,493 ms at 0.29 vCPU against 1,203 ms at 1 vCPU. If jobs are short and nothing challenges them, local Chromium on a cheap VPS is enough.

Does connecting over CDP add latency?

Yes, one network round trip per protocol call. Page loads can still be faster remotely when the browser is closer to the target. On a connection with a 237 ms round trip, 30 trivial evaluate calls cost 259 ms each. Batch DOM work into a single page.evaluate().

How do persistent sessions work in the cloud?

Cookies and local storage live in the remote browser profile, and they can survive a dropped socket. I wrote a cookie and a localStorage value in one session, then reconnected 20 seconds later. Both were still readable across a browser.close(), but the provider’s stop call clears the profile. A new context gets a separate jar, so read state from browser.contexts()[0].

Is Puppeteer or Playwright better for serverless?

Puppeteer is the smaller client: in September 2026, puppeteer-core left the process at 80 MB resident and unpacked to 5.7 MB. playwright-core 1.63 left the process at 140 MB and unpacked to 12.8 MB. Playwright offers broader APIs and cross-browser support. For CDP-only cloud work, both connect fine.

How do I use a proxy with a cloud browser?

Attach the proxy to the browser profile rather than to the connection. Sessions without a profile proxy can share a platform exit address. Cloud CDP endpoints usually accept only a token and a profile ID, so apply proxy settings through the profile API before the session starts.

Why doesn’t browser.close() stop my session?

For a connected browser, browser.close() only disconnects the client. The remote process keeps running on the provider’s side, and you keep paying until provider policy ends it. Call the provider’s stop endpoint explicitly inside a finally block, for example DELETE /browser/{id}/web. A stop also clears the profile directory, so export any cookies you need first.

What is Playwright used for?

Two jobs. Teams use it for end-to-end tests of their own applications on CI. Others use it for unattended automation against sites they do not control: scraping, form filling, account work, agent tasks. The same API serves both. The second job needs sessions that outlive the process, and that is where the infrastructure diverges.

Is the Playwright automation tool the same as Playwright Test?

No. Playwright is the browser automation library. Playwright Test is the runner bundled with it: parallelism, retries, fixtures, traces, HTML reports. You can install playwright-core alone and drive a browser with no runner in the path. Every example in this guide does exactly that.

Can Playwright run serverless?

Yes, in two shapes. Package Chromium into the function, or run the script in the function and connect to a browser elsewhere over CDP. The first cost me 19 unresolved shared libraries on an Amazon Linux 2023 base image and roughly 208 MB expanded into /tmp. The second costs one WebSocket.

How much memory does one Playwright browser use?

About 270 MB resident per concurrent browser, on whichever host runs Chromium. 4 concurrent browsers raised my local process tree to 1,229 MB across 18 processes. Connecting to 4 remote browsers over CDP cost the client 0.3 MB in 2 processes. The memory does not disappear. It moves.

Does closing the browser stop a remote Playwright session?

Often not. browser.close() ends your socket. The session keeps running until the provider’s stop call ends it, and billing continues meanwhile. Reconnecting to a live session took 1,554–1,676 ms with the page still open. Call the stop endpoint explicitly, in a finally block.

Why does connectOverCDP return a 404?

Playwright treats an http(s):// argument as a DevTools discovery endpoint and appends /json/version/. A WebSocket-only endpoint has no such path, so you get a 404 and a message telling you to try ws://. puppeteer-core accepted both schemes. playwright-core accepted only wss://.

Is a remote Playwright browser slower than a local one?

Per protocol call, yes: every call is a network round trip. On a 237 ms connection, reading 20 cards took 16,010 ms with per-field locators and 254 ms in one page.evaluate(). Page fetch can go the other way: 488 ms from the cloud node against 2,130 ms from my broadband.

How does Playwright compare to Selenium?

Playwright drives Chromium, Firefox and WebKit through one API, waits for elements automatically, and ships its own runner and trace viewer. Selenium follows the W3C WebDriver standard, supports more languages and browsers, and has a mature grid ecosystem. Neither choice changes the infrastructure cost this guide measures.

Also Read