<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: RemoteBrowser</title>
    <description>The latest articles on DEV Community by RemoteBrowser (@remotebrowser2).</description>
    <link>https://dev.to/remotebrowser2</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4066733%2F97e7d9c8-7094-4ce9-8aa9-72bb1055d1a4.png</url>
      <title>DEV Community: RemoteBrowser</title>
      <link>https://dev.to/remotebrowser2</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/remotebrowser2"/>
    <language>en</language>
    <item>
      <title>Puppeteer BrowserWSEndpoint Tutorial: Connect to Remote Chromium</title>
      <dc:creator>RemoteBrowser</dc:creator>
      <pubDate>Mon, 21 Sep 2026 03:58:55 +0000</pubDate>
      <link>https://dev.to/remotebrowser2/puppeteer-browserwsendpoint-tutorial-connect-to-remote-chromium-392l</link>
      <guid>https://dev.to/remotebrowser2/puppeteer-browserwsendpoint-tutorial-connect-to-remote-chromium-392l</guid>
      <description>&lt;h1&gt;
  
  
  Puppeteer BrowserWSEndpoint Tutorial: Connect to Remote Chromium
&lt;/h1&gt;

&lt;p&gt;This Puppeteer browserWSEndpoint tutorial shows you how to connect Puppeteer to a remote Chromium instance instead of launching a local browser. The short version: get a WebSocket debugger URL from your browser provider, pass it to &lt;code&gt;puppeteer.connect({ browserWSEndpoint })&lt;/code&gt;, and drive the remote session exactly as you would a local one. The rest of this guide covers what that URL actually is, how to wire it up in TypeScript, and the production details that break naive implementations.&lt;/p&gt;

&lt;p&gt;If you already know you want hosted sessions rather than local Chrome, start with &lt;a href="https://remote-browser.dev/blog/remote-browser-for-ai-agents" rel="noopener noreferrer"&gt;Remote Browser for AI agents&lt;/a&gt; for the runtime model, then come back here for the Puppeteer specifics.&lt;/p&gt;

&lt;h2&gt;
  
  
  What browserWSEndpoint Actually Is
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;browserWSEndpoint&lt;/code&gt; is a WebSocket URL that speaks the Chrome DevTools Protocol (CDP). When you launch Chrome with &lt;code&gt;--remote-debugging-port=9222&lt;/code&gt;, Chrome exposes an HTTP endpoint at &lt;code&gt;http://localhost:9222/json/version&lt;/code&gt;. That response contains a &lt;code&gt;webSocketDebuggerUrl&lt;/code&gt; field. That value is your &lt;code&gt;browserWSEndpoint&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Puppeteer's &lt;code&gt;connect()&lt;/code&gt; method opens a WebSocket to that URL and speaks CDP over it. Every &lt;code&gt;page.goto()&lt;/code&gt;, &lt;code&gt;page.click()&lt;/code&gt;, and &lt;code&gt;page.evaluate()&lt;/code&gt; call becomes a CDP command on that socket. The browser process can be running anywhere — your laptop, a container, or a managed host — as long as the WebSocket is reachable and the transport is secure.&lt;/p&gt;

&lt;p&gt;Two things matter for correctness:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The endpoint is browser-level, not page-level.&lt;/strong&gt; You connect once, then call &lt;code&gt;browser.newPage()&lt;/code&gt; or &lt;code&gt;browser.pages()&lt;/code&gt; to get targets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The endpoint is a credential.&lt;/strong&gt; Anyone with the URL can drive the browser. Treat it like a password: never log it, never commit it, always use &lt;code&gt;wss://&lt;/code&gt; in production.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://chromedevtools.github.io/devtools-protocol/" rel="noopener noreferrer"&gt;Chrome DevTools Protocol documentation&lt;/a&gt; is the authoritative reference for the commands Puppeteer sends underneath.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting a browserWSEndpoint from a Hosted Runtime
&lt;/h2&gt;

&lt;p&gt;With a local Chrome you construct the endpoint yourself. With a hosted runtime you request a session and the provider returns a connection URL. The shape is consistent across providers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;wss://&amp;lt;host&amp;gt;/cdp/&amp;lt;session-id&amp;gt;?token=&amp;lt;short-lived-token&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remote Browser issues a per-session WebSocket URL that you pass straight into Puppeteer. Sessions are isolated, so two concurrent agents never share a browser process, profile, or cookie jar. You can also attach a live viewer to watch the session in real time while your agent runs — useful when a task fails and you need to see why rather than guess from logs.&lt;/p&gt;

&lt;p&gt;The practical difference from local Chrome is that you no longer manage the browser binary, the display server, the container image, or the process lifecycle. You manage a URL and a session budget. Current session limits and metering are on the &lt;a href="https://remote-browser.dev/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minimal TypeScript Example
&lt;/h2&gt;

&lt;p&gt;Install Puppeteer and the types:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;puppeteer-core
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; typescript @types/node
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;puppeteer-core&lt;/code&gt; rather than &lt;code&gt;puppeteer&lt;/code&gt; when connecting to a remote browser. The full &lt;code&gt;puppeteer&lt;/code&gt; package downloads a bundled Chromium you will never launch, which adds a large amount of disk usage to your image for no benefit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Browser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;puppeteer-core&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;SessionInfo&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;browserWSEndpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createSession&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;SessionInfo&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.remote-browser.dev/v1/sessions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;REMOTE_BROWSER_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="c1"&gt;// Configurable browser settings: region, viewport, proxy, profile.&lt;/span&gt;
      &lt;span class="na"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;checkout-agent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Session create failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;SessionInfo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;browserWSEndpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;browserWSEndpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// Keep the session alive if the socket drops briefly.&lt;/span&gt;
    &lt;span class="na"&gt;protocolTimeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setViewport&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;domcontentloaded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;title:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;example.png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Disconnect without killing the remote browser.&lt;/span&gt;
    &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in that snippet are easy to get wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;browser.disconnect()&lt;/code&gt; versus &lt;code&gt;browser.close()&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;disconnect()&lt;/code&gt; closes the WebSocket and leaves the remote browser running. &lt;code&gt;close()&lt;/code&gt; sends a CDP command that terminates the browser process. In a hosted runtime you usually want &lt;code&gt;disconnect()&lt;/code&gt; and then an explicit session-termination call, so cleanup is idempotent and you do not accidentally kill a session another worker is still using.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;protocolTimeout&lt;/code&gt;.&lt;/strong&gt; The default is generous but finite. Long-running agent tasks that sit idle between CDP calls can trip it. Raise it deliberately rather than disabling timeouts entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting to an Existing Local Chrome
&lt;/h2&gt;

&lt;p&gt;The same API works against a local browser, which is useful for debugging. Start Chrome with remote debugging enabled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;chrome &lt;span class="nt"&gt;--remote-debugging-port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;9222 &lt;span class="nt"&gt;--user-data-dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/chrome-debug
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then fetch the endpoint and connect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;versionRes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://localhost:9222/json/version&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;webSocketDebuggerUrl&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;versionRes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;browserWSEndpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;webSocketDebuggerUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the fastest way to confirm your Puppeteer code is correct before you introduce network latency and auth tokens. If it works locally and fails remotely, the problem is almost always the endpoint URL, the token, or a firewall — not your page logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local Chrome vs Hosted Remote Browser
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Local Chrome&lt;/th&gt;
&lt;th&gt;Hosted remote browser&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Setup&lt;/td&gt;
&lt;td&gt;Install binary, manage versions&lt;/td&gt;
&lt;td&gt;Request a session, get a URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scaling&lt;/td&gt;
&lt;td&gt;One process per machine, manual&lt;/td&gt;
&lt;td&gt;Sessions provisioned per request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Isolation&lt;/td&gt;
&lt;td&gt;Shared profile unless you configure it&lt;/td&gt;
&lt;td&gt;Isolated per session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proxies&lt;/td&gt;
&lt;td&gt;Manual config, your own IPs&lt;/td&gt;
&lt;td&gt;Configurable per session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debugging&lt;/td&gt;
&lt;td&gt;Local DevTools&lt;/td&gt;
&lt;td&gt;Live viewer plus CDP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistence&lt;/td&gt;
&lt;td&gt;Local disk, lost on rebuild&lt;/td&gt;
&lt;td&gt;Persistent profiles across sessions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost model&lt;/td&gt;
&lt;td&gt;Compute you already pay for&lt;/td&gt;
&lt;td&gt;Metered per browser-hour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failure mode&lt;/td&gt;
&lt;td&gt;Process crash kills the run&lt;/td&gt;
&lt;td&gt;Session ends, reconnect or re-provision&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The trade-off is not "remote is always better." If you run a handful of tests on a laptop, local Chrome is simpler and free. The calculus changes when you need concurrency, consistent IPs, or sessions that survive a deploy. That is the point where managing Chrome becomes a second job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production Criteria Before You Commit
&lt;/h2&gt;

&lt;p&gt;Before you move a Puppeteer workload to a hosted runtime, check these against your requirements.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reconnect semantics.&lt;/strong&gt; What happens when the WebSocket drops mid-task? A good runtime lets you reconnect to the same session by ID rather than losing state. Test this by killing the socket deliberately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Profile persistence.&lt;/strong&gt; If your agent logs in, does the session keep cookies and local storage across runs? Persistent profiles matter for anything behind auth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proxy and network controls.&lt;/strong&gt; Per-session proxy configuration, including residential IPs where needed, is often the difference between a task succeeding and getting blocked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolation guarantees.&lt;/strong&gt; Confirm that sessions do not share a browser process or profile. Shared state between agents is a correctness bug, not a performance optimization.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability.&lt;/strong&gt; A live viewer plus structured session logs turns a 40-minute debugging session into a 4-minute one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metering transparency.&lt;/strong&gt; Know how browser-hours are counted and whether idle time bills. See &lt;a href="https://remote-browser.dev/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt; for the current model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are evaluating this for an agent stack rather than a test suite, the &lt;a href="https://remote-browser.dev/blog/remote-web-browser" rel="noopener noreferrer"&gt;remote browser guide&lt;/a&gt; covers the runtime layer in more depth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Failure Modes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Error: Failed to launch the browser process&lt;/code&gt;&lt;/strong&gt; — you called &lt;code&gt;puppeteer.launch()&lt;/code&gt; instead of &lt;code&gt;puppeteer.connect()&lt;/code&gt;. Check your import and call site.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;WebSocket connection closed abnormally&lt;/code&gt;&lt;/strong&gt; — usually a token expiry or a proxy stripping the &lt;code&gt;Upgrade&lt;/code&gt; header. Verify the token lifetime and that nothing between your worker and the runtime terminates long-lived connections.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Target closed&lt;/code&gt; mid-task&lt;/strong&gt; — the remote browser was reaped, often because the session hit a time limit or the worker that created it exited. Decouple session lifetime from worker lifetime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hangs with no error&lt;/strong&gt; — a CDP command was sent but the response never arrived. This is frequently a &lt;code&gt;protocolTimeout&lt;/code&gt; that is too low combined with a slow page. Log the last command issued before the hang.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Works locally, fails in CI&lt;/strong&gt; — the CI runner cannot reach the WebSocket host, or the token is not injected. Print the host portion of the endpoint (never the token) to confirm.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Fits in an Agent Stack
&lt;/h2&gt;

&lt;p&gt;Puppeteer over &lt;code&gt;browserWSEndpoint&lt;/code&gt; is the lowest-level way to drive a remote browser. It gives you full CDP access and no abstraction. That is an advantage when you need precise control over network interception, request blocking, or performance tracing, and a disadvantage when you would rather describe a task in natural language.&lt;/p&gt;

&lt;p&gt;Most production agent stacks end up with both: a high-level agent loop for task planning, and a Puppeteer or Playwright layer for the deterministic steps — login, checkout, file upload — where you want exact control. The remote runtime is shared between them, which means one place to configure proxies, profiles, and isolation.&lt;/p&gt;

&lt;p&gt;If you are coming from Playwright rather than Puppeteer, the connection model is nearly identical; the &lt;a href="https://remote-browser.dev/blog/remote-control-browser" rel="noopener noreferrer"&gt;remote control browser guide&lt;/a&gt; maps the concepts across both libraries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Use &lt;code&gt;puppeteer-core&lt;/code&gt;, not &lt;code&gt;puppeteer&lt;/code&gt;, when connecting remotely.&lt;/li&gt;
&lt;li&gt;Request a session, take the &lt;code&gt;browserWSEndpoint&lt;/code&gt;, and pass it to &lt;code&gt;connect()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;protocolTimeout&lt;/code&gt; based on your longest expected idle gap.&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;disconnect()&lt;/code&gt; in a &lt;code&gt;finally&lt;/code&gt; block; terminate the session explicitly.&lt;/li&gt;
&lt;li&gt;Never log or commit the endpoint URL — it is a credential.&lt;/li&gt;
&lt;li&gt;Test reconnect behavior before you rely on it.&lt;/li&gt;
&lt;li&gt;Verify isolation and profile persistence against your actual workload.&lt;/li&gt;
&lt;li&gt;Check current session limits and metering on &lt;a href="https://remote-browser.dev/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The API surface is small. The operational details are where remote Puppeteer succeeds or fails, and they are worth getting right before you scale past a single worker. For the full connection reference, see the &lt;a href="https://remote-browser.dev/documentation" rel="noopener noreferrer"&gt;documentation&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>puppeteer</category>
      <category>cdp</category>
      <category>remotebrowser</category>
      <category>browserautomation</category>
    </item>
    <item>
      <title>Puppeteer Install Browser: Local vs Remote CDP Setup</title>
      <dc:creator>RemoteBrowser</dc:creator>
      <pubDate>Mon, 21 Sep 2026 03:58:55 +0000</pubDate>
      <link>https://dev.to/remotebrowser2/puppeteer-install-browser-local-vs-remote-cdp-setup-4po0</link>
      <guid>https://dev.to/remotebrowser2/puppeteer-install-browser-local-vs-remote-cdp-setup-4po0</guid>
      <description>&lt;h1&gt;
  
  
  Puppeteer Install Browser: Local vs Remote CDP Setup
&lt;/h1&gt;

&lt;p&gt;When you run &lt;code&gt;npm install puppeteer&lt;/code&gt;, the package downloads a pinned Chrome build into a local cache directory. That is the default "puppeteer install browser" step, and it works fine on a laptop. It becomes a problem the moment you deploy to a container, a CI runner, or a fleet of agent workers: the download is large, the cache path differs per OS, and the browser binary has to match the Puppeteer version that launched it. This guide covers what the install actually does, how to control it, and when to stop installing Chrome locally and connect Puppeteer to a hosted Chromium session over CDP instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;puppeteer install browser&lt;/code&gt; actually does
&lt;/h2&gt;

&lt;p&gt;Puppeteer ships two packages. &lt;code&gt;puppeteer&lt;/code&gt; runs a postinstall script that fetches a browser; &lt;code&gt;puppeteer-core&lt;/code&gt; does not. That single distinction drives most install decisions.&lt;/p&gt;

&lt;p&gt;When you install the full &lt;code&gt;puppeteer&lt;/code&gt; package, the install script:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reads the browser version pinned to that Puppeteer release.&lt;/li&gt;
&lt;li&gt;Downloads the matching Chrome for Testing build (or Chrome Headless Shell, depending on config).&lt;/li&gt;
&lt;li&gt;Extracts it into a cache directory — &lt;code&gt;~/.cache/puppeteer&lt;/code&gt; on Linux, &lt;code&gt;~/Library/Caches/puppeteer&lt;/code&gt; on macOS, &lt;code&gt;%USERPROFILE%\.cache\puppeteer&lt;/code&gt; on Windows.&lt;/li&gt;
&lt;li&gt;Records the revision so &lt;code&gt;puppeteer.launch()&lt;/code&gt; can find the binary without you passing an executable path.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The cache is keyed by browser and revision, so upgrading Puppeteer can trigger a second download rather than reusing the first. On a build machine with no persistent cache, every clean install pays that cost again.&lt;/p&gt;

&lt;h3&gt;
  
  
  Environment variables that control the install
&lt;/h3&gt;

&lt;p&gt;You can steer the install without touching code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PUPPETEER_SKIP_DOWNLOAD=true&lt;/code&gt; — skip the browser download entirely. Use this when you only need &lt;code&gt;puppeteer-core&lt;/code&gt; behavior or you plan to connect to a remote browser.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PUPPETEER_CACHE_DIR=/path&lt;/code&gt; — relocate the cache, useful for Docker layers and CI caching.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PUPPETEER_DOWNLOAD_BASE_URL&lt;/code&gt; — point at an internal mirror if your network blocks the default CDN.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PUPPETEER_EXECUTABLE_PATH&lt;/code&gt; — at runtime, force Puppeteer to use a specific Chrome binary instead of the cached one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you install &lt;code&gt;puppeteer-core&lt;/code&gt;, none of this applies. You get the API surface with no bundled browser, and you must supply a browser yourself — either a system Chrome via &lt;code&gt;executablePath&lt;/code&gt;, or a remote endpoint via &lt;code&gt;browserWSEndpoint&lt;/code&gt; or &lt;code&gt;browserURL&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local install vs remote connect
&lt;/h2&gt;

&lt;p&gt;The install question is really a deployment question. Here is the trade-off in concrete terms.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Local &lt;code&gt;puppeteer&lt;/code&gt; install&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;puppeteer-core&lt;/code&gt; + remote CDP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Browser binary&lt;/td&gt;
&lt;td&gt;Downloaded per install, cached on disk&lt;/td&gt;
&lt;td&gt;None; browser runs elsewhere&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image size&lt;/td&gt;
&lt;td&gt;+300–500 MB typical&lt;/td&gt;
&lt;td&gt;Package only, tens of MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Version coupling&lt;/td&gt;
&lt;td&gt;Puppeteer pins the Chrome revision&lt;/td&gt;
&lt;td&gt;You control the remote browser version&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold start&lt;/td&gt;
&lt;td&gt;Download + extract on first run&lt;/td&gt;
&lt;td&gt;Connect handshake, typically sub-second&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scaling&lt;/td&gt;
&lt;td&gt;One browser per process/container&lt;/td&gt;
&lt;td&gt;Many sessions against hosted Chromium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session persistence&lt;/td&gt;
&lt;td&gt;Tied to the process lifetime&lt;/td&gt;
&lt;td&gt;Profiles can outlive the worker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debugging&lt;/td&gt;
&lt;td&gt;Local DevTools&lt;/td&gt;
&lt;td&gt;Live viewer or CDP over the wire&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best fit&lt;/td&gt;
&lt;td&gt;Local dev, one-off scripts&lt;/td&gt;
&lt;td&gt;CI, serverless, agent fleets&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern that holds up in production: use the full &lt;code&gt;puppeteer&lt;/code&gt; package locally so you get a working browser with zero configuration, and use &lt;code&gt;puppeteer-core&lt;/code&gt; in deployed code where the browser is a remote resource. That keeps your dependency tree honest — your production code never silently depends on a downloaded binary that may not exist in the runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting Puppeteer to a remote browser
&lt;/h2&gt;

&lt;p&gt;Puppeteer connects to a remote Chromium in two ways. &lt;code&gt;puppeteer.connect({ browserWSEndpoint })&lt;/code&gt; uses the WebSocket endpoint that Chrome exposes when started with &lt;code&gt;--remote-debugging-port&lt;/code&gt;. &lt;code&gt;puppeteer.connect({ browserURL })&lt;/code&gt; hits the HTTP endpoint and resolves the WebSocket URL for you. Both speak the &lt;a href="https://chromedevtools.github.io/devtools-protocol/" rel="noopener noreferrer"&gt;Chrome DevTools Protocol&lt;/a&gt;, so anything you can do with a local browser you can do against a hosted one.&lt;/p&gt;

&lt;p&gt;The endpoint is the only thing that changes. Your page logic — selectors, waits, evaluation — stays identical.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Browser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;puppeteer-core&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// The WebSocket endpoint comes from your browser provider.&lt;/span&gt;
&lt;span class="c1"&gt;// It looks like: wss://&amp;lt;host&amp;gt;/cdp/&amp;lt;session-id&amp;gt;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browserWSEndpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BROWSER_WS_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runTask&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Browser&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;browserWSEndpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;defaultViewport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="c1"&gt;// Keep the session alive if the socket blips.&lt;/span&gt;
      &lt;span class="na"&gt;protocolTimeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Route through the remote browser's network stack.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;networkidle2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;links&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="nf"&gt;$eval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;anchors&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nx"&gt;anchors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HTMLAnchorElement&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;links&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Surface the CDP error rather than swallowing it.&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Remote browser task failed:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Disconnect, do not close — the remote session may be reused.&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;runTask&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here. First, &lt;code&gt;browser.disconnect()&lt;/code&gt; instead of &lt;code&gt;browser.close()&lt;/code&gt;. Closing a remote browser tears down a session you may not own; disconnecting releases your client while leaving the session intact for reuse or inspection. Second, &lt;code&gt;protocolTimeout&lt;/code&gt; guards against a stalled CDP call hanging your worker indefinitely — worth setting explicitly rather than relying on defaults.&lt;/p&gt;

&lt;p&gt;If you are wiring this into a Playwright-based stack instead, the same endpoint works through &lt;code&gt;chromium.connectOverCDP()&lt;/code&gt;. The &lt;a href="https://playwright.dev/docs/api/class-browsertype#browser-type-connect-over-cdp" rel="noopener noreferrer"&gt;Playwright CDP guide&lt;/a&gt; documents the connection semantics, and the protocol underneath is identical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production criteria for the remote path
&lt;/h2&gt;

&lt;p&gt;Connecting is easy. Running it reliably is the part that needs decisions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Session lifecycle.&lt;/strong&gt; Decide up front whether a session is per-task or long-lived. Per-task sessions are simpler to reason about and isolate failures. Long-lived sessions preserve login state and cookies but need explicit cleanup. Remote Browser exposes persistent profiles for the second case, so a worker can reconnect to a session that already holds an authenticated state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Endpoint stability.&lt;/strong&gt; A WebSocket endpoint that changes on every reconnect forces you to re-fetch it from an API before each task. Prefer a provider that gives you a stable connection URL per session, or an API call that returns the current endpoint for a session ID.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Isolation.&lt;/strong&gt; If two agents share a browser, they share cookies, storage, and possibly a page context. Session isolation at the browser level is the safe default; sharing a browser across tenants is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Observability.&lt;/strong&gt; When a remote task fails, you need to see what the browser saw. A live viewer that streams the session is far more useful than a stack trace alone. Pair it with CDP-level logging so you can correlate agent actions with page state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost model.&lt;/strong&gt; Remote browsers are usually metered by session time. Understand whether you pay for idle sessions, how disconnects are billed, and whether there is a minimum increment. Current rates are on the &lt;a href="https://remote-browser.dev/pricing" rel="noopener noreferrer"&gt;/pricing&lt;/a&gt; page rather than baked into this post, since they change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Network posture.&lt;/strong&gt; If your targets are geo-restricted or rate-limited, the browser's egress IP matters as much as the browser itself. Configurable proxy settings at the session level let you route traffic without changing your Puppeteer code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common failure modes
&lt;/h2&gt;

&lt;p&gt;Most "remote browser not working" reports trace back to a handful of causes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wrong endpoint scheme.&lt;/strong&gt; &lt;code&gt;browserWSEndpoint&lt;/code&gt; needs &lt;code&gt;ws://&lt;/code&gt; or &lt;code&gt;wss://&lt;/code&gt;. Passing an &lt;code&gt;http://&lt;/code&gt; URL to it fails immediately. Use &lt;code&gt;browserURL&lt;/code&gt; for HTTP endpoints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session already closed.&lt;/strong&gt; Reconnecting to an expired session returns a connection error. Fetch a fresh endpoint rather than retrying a dead one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version skew.&lt;/strong&gt; A very old &lt;code&gt;puppeteer-core&lt;/code&gt; against a very new Chromium can hit unsupported CDP methods. Keep the client reasonably current.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing &lt;code&gt;--remote-debugging-address&lt;/code&gt;.&lt;/strong&gt; If you self-host Chrome and it only binds to localhost, remote clients cannot reach it. Hosted providers handle this for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timeouts on heavy pages.&lt;/strong&gt; Default navigation timeouts are short. Set &lt;code&gt;waitUntil&lt;/code&gt; and &lt;code&gt;timeout&lt;/code&gt; explicitly for pages with client-side rendering.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a broader look at how hosted sessions fit into agent architectures, see &lt;a href="https://remote-browser.dev/blog/remote-browser-for-ai-agents" rel="noopener noreferrer"&gt;Remote Browser for AI Agents&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to keep the local install
&lt;/h2&gt;

&lt;p&gt;The remote path is not universally better. Keep the local &lt;code&gt;puppeteer&lt;/code&gt; install when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You are developing and iterating on selectors, where a local browser and DevTools are faster to inspect.&lt;/li&gt;
&lt;li&gt;You need to test against a specific Chrome build that you control end to end.&lt;/li&gt;
&lt;li&gt;Your workload is a single script on a machine that already has Chrome.&lt;/li&gt;
&lt;li&gt;You are debugging a CDP interaction and want zero network variables.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Switch to &lt;code&gt;puppeteer-core&lt;/code&gt; plus a remote endpoint when you deploy, when you need more concurrent sessions than one machine can hold, when you want sessions to survive worker restarts, or when you need a live view of what an agent is doing. The &lt;a href="https://remote-browser.dev/blog/remote-web-browser" rel="noopener noreferrer"&gt;remote browser overview&lt;/a&gt; covers the runtime model in more depth, and the &lt;a href="https://remote-browser.dev/documentation" rel="noopener noreferrer"&gt;/documentation&lt;/a&gt; has the connection specifics for each supported client.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical migration path
&lt;/h2&gt;

&lt;p&gt;If you have a working local Puppeteer script and want to move it to a hosted browser without a rewrite:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Swap the dependency from &lt;code&gt;puppeteer&lt;/code&gt; to &lt;code&gt;puppeteer-core&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Replace &lt;code&gt;puppeteer.launch({ ... })&lt;/code&gt; with &lt;code&gt;puppeteer.connect({ browserWSEndpoint })&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Move any launch flags you relied on — viewport, user agent, proxy — into the session configuration on the provider side.&lt;/li&gt;
&lt;li&gt;Replace &lt;code&gt;browser.close()&lt;/code&gt; with &lt;code&gt;browser.disconnect()&lt;/code&gt; unless you genuinely want to end the session.&lt;/li&gt;
&lt;li&gt;Add a retry wrapper that fetches a fresh endpoint on connection failure.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is the whole change. The page automation code — the part you actually spent time on — does not move.&lt;/p&gt;

&lt;p&gt;The install step is a deployment detail, not an architectural one. Treat it that way: install locally for development, connect remotely for production, and keep the browser binary out of your deployed artifact.&lt;/p&gt;

</description>
      <category>puppeteer</category>
      <category>cdp</category>
      <category>browserautomation</category>
      <category>remotebrowser</category>
    </item>
    <item>
      <title>Puppeteer Browsers Npm: Install, Connect, and Run Remotely</title>
      <dc:creator>RemoteBrowser</dc:creator>
      <pubDate>Sun, 20 Sep 2026 03:27:46 +0000</pubDate>
      <link>https://dev.to/remotebrowser2/puppeteer-browsers-npm-install-connect-and-run-remotely-2gf9</link>
      <guid>https://dev.to/remotebrowser2/puppeteer-browsers-npm-install-connect-and-run-remotely-2gf9</guid>
      <description>&lt;h1&gt;
  
  
  Puppeteer Browsers Npm: Install, Connect, and Run Remotely
&lt;/h1&gt;

&lt;p&gt;If you have run &lt;code&gt;npm install puppeteer&lt;/code&gt; recently, you already know the surprise: the package pulls a Chromium build into your &lt;code&gt;node_modules&lt;/code&gt; or cache directory, and that download is often the slowest, most fragile part of your CI pipeline. The &lt;code&gt;puppeteer browsers&lt;/code&gt; CLI exists to manage exactly that — installing, listing, and pinning browser binaries. This guide covers what the &lt;code&gt;puppeteer browsers&lt;/code&gt; npm workflow actually does, where it breaks in production, and how to connect Puppeteer to a hosted Chromium runtime so you stop shipping browser binaries with your app.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;puppeteer browsers&lt;/code&gt; Actually Manages
&lt;/h2&gt;

&lt;p&gt;Puppeteer ships two related things: the Node library and a browser binary. Since Puppeteer v19+, browser management moved into a dedicated CLI and a set of npm packages under the &lt;code&gt;@puppeteer/browsers&lt;/code&gt; namespace. The command surface looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx puppeteer browsers &lt;span class="nb"&gt;install &lt;/span&gt;chrome
npx puppeteer browsers &lt;span class="nb"&gt;install &lt;/span&gt;chrome@stable
npx puppeteer browsers &lt;span class="nb"&gt;install &lt;/span&gt;chrome-headless-shell@121.0.6167.85
npx puppeteer browsers list
npx puppeteer browsers uninstall chrome
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each command resolves a browser "build ID" (a version string like &lt;code&gt;121.0.6167.85&lt;/code&gt; or a channel like &lt;code&gt;stable&lt;/code&gt;, &lt;code&gt;beta&lt;/code&gt;, &lt;code&gt;canary&lt;/code&gt;, &lt;code&gt;dev&lt;/code&gt;), downloads the matching archive from Google's Chrome for Testing endpoints, and unpacks it into a cache directory. On Linux that is typically &lt;code&gt;~/.cache/puppeteer&lt;/code&gt;; on macOS it is &lt;code&gt;~/Library/Caches/puppeteer&lt;/code&gt;. The path is configurable via &lt;code&gt;PUPPETEER_CACHE_DIR&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The key detail for production: &lt;strong&gt;the browser is not part of your application artifact&lt;/strong&gt;. It is a side-loaded binary that must exist on the machine that runs &lt;code&gt;puppeteer.launch()&lt;/code&gt;. That distinction drives most of the operational pain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Version pinning and the &lt;code&gt;puppeteer&lt;/code&gt; vs &lt;code&gt;puppeteer-core&lt;/code&gt; split
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;puppeteer&lt;/code&gt; — installs a browser on &lt;code&gt;npm install&lt;/code&gt; (unless &lt;code&gt;PUPPETEER_SKIP_DOWNLOAD&lt;/code&gt; is set) and exposes the full API.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;puppeteer-core&lt;/code&gt; — no browser download, no install hooks. You supply an executable path or a CDP endpoint.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For containerized workloads, &lt;code&gt;puppeteer-core&lt;/code&gt; is almost always the right dependency. It keeps your image small and removes a network-dependent step from &lt;code&gt;npm ci&lt;/code&gt;. You then either bake a browser into the image or — more usefully — connect to a browser that already exists somewhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the Local Install Model Breaks Down
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;puppeteer browsers&lt;/code&gt; npm flow is fine for a laptop. It gets awkward at scale for predictable reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cold-start cost.&lt;/strong&gt; A fresh &lt;code&gt;npm ci&lt;/code&gt; in CI pulls a ~150–200 MB archive per job. Multiply by parallel runners and you are paying for bandwidth and wall-clock time on every build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version drift.&lt;/strong&gt; If you do not pin the build ID, &lt;code&gt;chrome@stable&lt;/code&gt; resolves to whatever is current that day. A Chrome release can change rendering, network behavior, or CDP surface between your test runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing system libraries.&lt;/strong&gt; Chrome needs a long list of shared libraries (&lt;code&gt;libnss3&lt;/code&gt;, &lt;code&gt;libatk&lt;/code&gt;, &lt;code&gt;libgbm&lt;/code&gt;, and friends). Minimal base images do not have them, and the failure mode is a cryptic launch error rather than a clear dependency message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Architecture mismatch.&lt;/strong&gt; &lt;code&gt;chrome-headless-shell&lt;/code&gt; and full Chrome have different build matrices. Cross-compiling or running on ARM runners means verifying the right artifact exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sandbox and privilege issues.&lt;/strong&gt; Running Chrome as root in a container requires &lt;code&gt;--no-sandbox&lt;/code&gt; or a properly configured user namespace. Both have security implications you should decide on deliberately, not by copy-pasting a flag.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these are fatal. They are the tax you pay for owning the browser lifecycle. The question is whether that ownership buys you anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Alternative: Connect Instead of Install
&lt;/h2&gt;

&lt;p&gt;Puppeteer's &lt;code&gt;connect()&lt;/code&gt; method attaches to an existing browser over the Chrome DevTools Protocol. If a browser is already running somewhere reachable, you skip installation entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;puppeteer-core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// A hosted runtime exposes a CDP WebSocket endpoint.&lt;/span&gt;
&lt;span class="c1"&gt;// Treat this URL as a secret — it grants full control of the session.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;browserWSEndpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BROWSER_WS_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;defaultViewport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;networkidle2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;links&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="nf"&gt;$eval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HTMLAnchorElement&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;links&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Close the page, but do not kill the remote browser unless you own it.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things matter here. First, &lt;code&gt;browser.disconnect()&lt;/code&gt; detaches your client without terminating the remote process — the opposite of &lt;code&gt;browser.close()&lt;/code&gt;. Second, the &lt;code&gt;browserWSEndpoint&lt;/code&gt; is a credential. Anyone with that URL can drive the session, read cookies, and navigate. Store it in a secret manager, not in a checked-in &lt;code&gt;.env&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The same pattern works with Playwright's &lt;code&gt;connectOverCDP&lt;/code&gt;, which is documented in the &lt;a href="https://playwright.dev/docs/api/class-browsertype#browser-type-connect-over-cdp" rel="noopener noreferrer"&gt;Playwright CDP guide&lt;/a&gt;. If you are evaluating both libraries, the connection model is nearly identical; the difference is in the higher-level API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local Install vs Hosted Runtime: A Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;puppeteer browsers&lt;/code&gt; local install&lt;/th&gt;
&lt;th&gt;Hosted Chromium via CDP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Setup step&lt;/td&gt;
&lt;td&gt;Download + unpack per machine&lt;/td&gt;
&lt;td&gt;Paste a WebSocket URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image size&lt;/td&gt;
&lt;td&gt;+150–200 MB per browser&lt;/td&gt;
&lt;td&gt;No browser in your artifact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Version control&lt;/td&gt;
&lt;td&gt;You pin build IDs manually&lt;/td&gt;
&lt;td&gt;Runtime owns the build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;System deps&lt;/td&gt;
&lt;td&gt;You install &lt;code&gt;libnss3&lt;/code&gt;, &lt;code&gt;libgbm&lt;/code&gt;, etc.&lt;/td&gt;
&lt;td&gt;Handled by the runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold start&lt;/td&gt;
&lt;td&gt;Seconds to minutes on first run&lt;/td&gt;
&lt;td&gt;Connection handshake&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session persistence&lt;/td&gt;
&lt;td&gt;You manage profile dirs&lt;/td&gt;
&lt;td&gt;Persistent profiles available&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debugging&lt;/td&gt;
&lt;td&gt;Local DevTools or screenshots&lt;/td&gt;
&lt;td&gt;Live viewer + CDP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scaling model&lt;/td&gt;
&lt;td&gt;One browser per container&lt;/td&gt;
&lt;td&gt;Sessions provisioned per request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best fit&lt;/td&gt;
&lt;td&gt;Local dev, offline work&lt;/td&gt;
&lt;td&gt;CI, agents, multi-tenant automation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest read: local installs win when you need a specific patched build, work offline, or want zero external dependencies. Hosted runtimes win when browser lifecycle is not your product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production Criteria Before You Switch
&lt;/h2&gt;

&lt;p&gt;Before moving Puppeteer workloads to a remote runtime, check these against your requirements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CDP compatibility.&lt;/strong&gt; Confirm the runtime exposes a standard CDP endpoint. Puppeteer's &lt;code&gt;connect()&lt;/code&gt; expects a WebSocket URL; some providers only offer REST wrappers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session isolation.&lt;/strong&gt; Each task should get its own browser context or process. Shared state across tenants is a correctness and security problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Profile persistence.&lt;/strong&gt; If your workflow logs in once and reuses cookies, you need persistent profiles with a defined retention policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proxy and network controls.&lt;/strong&gt; Egress IP, geolocation, and header behavior should be configurable per session, not global.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability.&lt;/strong&gt; A live viewer or session recording turns "the agent failed" into "the agent clicked the wrong element at step 6."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Usage accounting.&lt;/strong&gt; Browser time is the unit that scales. Know how sessions are metered before you commit to a volume.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Remote Browser exposes hosted Chromium sessions with CDP access, Playwright/Puppeteer/Selenium compatibility, persistent profiles, configurable browser settings, session isolation, and a live viewer. Current limits and metering are on the &lt;a href="https://remote-browser.dev/pricing" rel="noopener noreferrer"&gt;/pricing&lt;/a&gt; page — check there rather than assuming a number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring It Into an Existing Puppeteer Codebase
&lt;/h2&gt;

&lt;p&gt;The migration is usually smaller than teams expect. The main changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Swap &lt;code&gt;puppeteer&lt;/code&gt; for &lt;code&gt;puppeteer-core&lt;/code&gt; in &lt;code&gt;package.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Replace &lt;code&gt;puppeteer.launch({ ... })&lt;/code&gt; with &lt;code&gt;puppeteer.connect({ browserWSEndpoint })&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Remove &lt;code&gt;PUPPETEER_CACHE_DIR&lt;/code&gt;, browser install steps, and the associated Docker layers.&lt;/li&gt;
&lt;li&gt;Move launch options that no longer apply (like &lt;code&gt;executablePath&lt;/code&gt;) into runtime configuration.&lt;/li&gt;
&lt;li&gt;Keep &lt;code&gt;--no-sandbox&lt;/code&gt;-style flags out of your code; the runtime owns process configuration.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you use &lt;code&gt;puppeteer.launch()&lt;/code&gt; with a &lt;code&gt;browserURL&lt;/code&gt; instead of a WebSocket, note that &lt;code&gt;browserURL&lt;/code&gt; points at the HTTP debugging endpoint (usually port 9222), while &lt;code&gt;browserWSEndpoint&lt;/code&gt; is the WebSocket. Mixing them up produces a connection error that looks like a network problem but is actually a protocol mismatch.&lt;/p&gt;

&lt;p&gt;For a broader look at how hosted sessions fit into agent workflows, see &lt;a href="https://remote-browser.dev/blog/remote-browser-for-ai-agents" rel="noopener noreferrer"&gt;/blog/remote-browser-for-ai-agents&lt;/a&gt;. If you are comparing connection styles across libraries, &lt;a href="https://remote-browser.dev/blog/remote-control-browser" rel="noopener noreferrer"&gt;/blog/remote-control-browser&lt;/a&gt; covers the control-plane side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Failure Modes and How to Read Them
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Could not find browser revision&lt;/code&gt;&lt;/strong&gt; — you are on &lt;code&gt;puppeteer&lt;/code&gt; (not &lt;code&gt;puppeteer-core&lt;/code&gt;) and the install step was skipped or the cache was cleared. Either run &lt;code&gt;npx puppeteer browsers install&lt;/code&gt; or switch to &lt;code&gt;puppeteer-core&lt;/code&gt; with an explicit endpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Target closed&lt;/code&gt; immediately after connect&lt;/strong&gt; — the remote session expired or was reclaimed. Check session TTL and whether your client is holding the connection open during long idle periods.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Protocol error (Page.navigate): Session closed&lt;/code&gt;&lt;/strong&gt; — often a proxy or network policy killing the connection mid-navigation. Verify egress rules and timeouts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timeouts on &lt;code&gt;page.goto&lt;/code&gt; with &lt;code&gt;networkidle0&lt;/code&gt;&lt;/strong&gt; — a common Puppeteer footgun. Pages with long-polling or analytics beacons may never reach idle. Prefer &lt;code&gt;domcontentloaded&lt;/code&gt; plus an explicit selector wait.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stale cookies across runs&lt;/strong&gt; — you are reusing a persistent profile when you meant a fresh context, or vice versa. Decide per workload and make it explicit in code.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Keep the Local Install
&lt;/h2&gt;

&lt;p&gt;There are legitimate reasons to stay with &lt;code&gt;puppeteer browsers&lt;/code&gt; npm installs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need a specific Chrome build that a hosted runtime does not offer.&lt;/li&gt;
&lt;li&gt;Your workload runs in an air-gapped environment.&lt;/li&gt;
&lt;li&gt;You are testing browser-version-specific behavior and need to switch builds frequently.&lt;/li&gt;
&lt;li&gt;Your volume is low enough that CI download time is not a real cost.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The decision is not ideological. It is a question of whether browser lifecycle management is core to your product or incidental overhead. For most agent and automation workloads, it is the latter — which is why connecting to a hosted runtime tends to win once you have more than a handful of concurrent sessions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;

&lt;p&gt;The fastest path is to keep your existing Puppeteer code and change one function call. Install &lt;code&gt;puppeteer-core&lt;/code&gt;, point &lt;code&gt;connect()&lt;/code&gt; at a CDP endpoint, and delete the browser download from your build. The &lt;a href="https://remote-browser.dev/documentation" rel="noopener noreferrer"&gt;/documentation&lt;/a&gt; covers session creation, endpoint formats, and profile handling. If you want to see the runtime in action before committing, &lt;a href="https://remote-browser.dev/blog/remote-browser-online" rel="noopener noreferrer"&gt;/blog/remote-browser-online&lt;/a&gt; walks through a browser session without any local Chrome install.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;puppeteer browsers&lt;/code&gt; npm tooling is genuinely useful — it solved a real problem when Puppeteer stopped bundling browsers into the main package. But it solves a problem you only have if you own the browser. Once you stop owning it, the CLI becomes a local development convenience rather than a production dependency.&lt;/p&gt;

</description>
      <category>puppeteer</category>
      <category>npm</category>
      <category>browserautomation</category>
      <category>remotebrowser</category>
    </item>
    <item>
      <title>Puppeteer BrowserWSEndpoint Example: Connect to Remote Chromium</title>
      <dc:creator>RemoteBrowser</dc:creator>
      <pubDate>Sun, 20 Sep 2026 03:27:43 +0000</pubDate>
      <link>https://dev.to/remotebrowser2/puppeteer-browserwsendpoint-example-connect-to-remote-chromium-2adc</link>
      <guid>https://dev.to/remotebrowser2/puppeteer-browserwsendpoint-example-connect-to-remote-chromium-2adc</guid>
      <description>&lt;h1&gt;
  
  
  Puppeteer BrowserWSEndpoint Example: Connect to Remote Chromium
&lt;/h1&gt;

&lt;p&gt;If you are searching for a Puppeteer &lt;code&gt;browserWSEndpoint&lt;/code&gt; example, you probably already have a WebSocket URL and want to know exactly how to hand it to Puppeteer so your script drives a browser that is not running on your machine. The short answer: pass the endpoint to &lt;code&gt;puppeteer.connect()&lt;/code&gt; instead of &lt;code&gt;puppeteer.launch()&lt;/code&gt;, then call &lt;code&gt;browser.newPage()&lt;/code&gt; as usual. Everything after that — pages, selectors, network interception — behaves the same as a local launch, because &lt;code&gt;browserWSEndpoint&lt;/code&gt; is just the Chrome DevTools Protocol (CDP) WebSocket address of a running browser.&lt;/p&gt;

&lt;p&gt;This post gives you a working example, explains what the endpoint actually is, and covers the production details that trip people up: version matching, session lifetime, reconnection, and when a WebSocket endpoint is the wrong abstraction.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;browserWSEndpoint&lt;/code&gt; actually is
&lt;/h2&gt;

&lt;p&gt;When Chromium starts with remote debugging enabled, it opens a WebSocket server and prints a line like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;DevTools listening on ws://127.0.0.1:9222/devtools/browser/6f3c...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That URL is the browser-level CDP endpoint. It is not a page, not a tab, and not a session — it is the control channel for the whole browser process. Puppeteer's &lt;code&gt;browserWSEndpoint&lt;/code&gt; option is simply the string it uses to open that channel.&lt;/p&gt;

&lt;p&gt;Two things follow from this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The endpoint is transport, not state.&lt;/strong&gt; Connecting to it does not create a browser; it attaches to one that already exists. If the process dies, your endpoint is dead too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The endpoint is version-sensitive.&lt;/strong&gt; CDP is a moving protocol. Puppeteer ships with a pinned Chromium and a matching protocol definition. Connecting a Puppeteer version to a Chromium build several major versions away is the single most common source of "it connected but nothing works" bugs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a hosted runtime, the endpoint is issued per session. You request a session, receive a &lt;code&gt;ws://&lt;/code&gt; or &lt;code&gt;wss://&lt;/code&gt; URL, connect, do your work, and release the session. That lifecycle is the part worth designing around.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal Puppeteer browserWSEndpoint example
&lt;/h2&gt;

&lt;p&gt;Here is the smallest useful version. It assumes you already have an endpoint string from your browser provider or from a locally launched Chrome with &lt;code&gt;--remote-debugging-port&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Browser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;puppeteer-core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BROWSER_WS_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BROWSER_WS_ENDPOINT is not set&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;browserWSEndpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// Keep the remote browser alive when the client disconnects.&lt;/span&gt;
    &lt;span class="c1"&gt;// Set false if you want the session torn down with the socket.&lt;/span&gt;
    &lt;span class="na"&gt;defaultViewport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;domcontentloaded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Disconnect, do not close. close() would kill the remote browser.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details in that snippet matter more than they look:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;puppeteer-core&lt;/code&gt;, not &lt;code&gt;puppeteer&lt;/code&gt;.&lt;/strong&gt; If you are connecting to a browser you did not download, you do not need the bundled Chromium. &lt;code&gt;puppeteer-core&lt;/code&gt; skips the ~150 MB download and avoids version drift between the bundled binary and the remote one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;browser.disconnect()&lt;/code&gt; vs &lt;code&gt;browser.close()&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;disconnect()&lt;/code&gt; drops the WebSocket and leaves the browser running. &lt;code&gt;close()&lt;/code&gt; sends a protocol command that shuts the browser down. On a metered hosted runtime, calling &lt;code&gt;close()&lt;/code&gt; when you meant &lt;code&gt;disconnect()&lt;/code&gt; will end your session — which is sometimes what you want, and sometimes a bug that kills a long-running agent mid-task.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;defaultViewport&lt;/code&gt; is a client-side hint.&lt;/strong&gt; It sets the viewport Puppeteer applies to new pages. It does not resize the remote browser window. If your provider exposes viewport configuration, set it there too.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Connecting with Playwright over CDP instead
&lt;/h2&gt;

&lt;p&gt;Puppeteer is not the only client for a CDP endpoint. Playwright's &lt;code&gt;connectOverCDP&lt;/code&gt; accepts the same WebSocket URL, which is useful if your test suite is already Playwright-based but your runtime is a hosted Chromium session.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Browser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playwright&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BROWSER_WS_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connectOverCDP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Reuse the existing context if the session already has one.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contexts&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newContext&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;domcontentloaded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// For connectOverCDP this disconnects the client.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trade-off is real. Playwright over CDP gives you Playwright's locators, auto-waiting, and tracing, but it does not give you Playwright's browser patching — the instrumentation Playwright normally injects at launch. Some features behave differently when attached to an externally managed browser. If your workflow depends on Playwright-specific internals, test them against the remote endpoint before committing. The &lt;a href="https://playwright.dev/docs/api/class-browsertype#browser-type-connect-over-cdp" rel="noopener noreferrer"&gt;Playwright CDP documentation&lt;/a&gt; is the authoritative reference for what &lt;code&gt;connectOverCDP&lt;/code&gt; does and does not support.&lt;/p&gt;

&lt;h2&gt;
  
  
  Puppeteer vs Playwright over a WebSocket endpoint
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Puppeteer &lt;code&gt;connect()&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Playwright &lt;code&gt;connectOverCDP()&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Protocol&lt;/td&gt;
&lt;td&gt;CDP only&lt;/td&gt;
&lt;td&gt;CDP only (for Chromium)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser patching&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None when attaching&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto-waiting&lt;/td&gt;
&lt;td&gt;Manual / &lt;code&gt;waitForSelector&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Built into locators&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tracing&lt;/td&gt;
&lt;td&gt;Not built in&lt;/td&gt;
&lt;td&gt;Available, with caveats&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Version coupling&lt;/td&gt;
&lt;td&gt;Tight to Chromium build&lt;/td&gt;
&lt;td&gt;Tolerant but not immune&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best fit&lt;/td&gt;
&lt;td&gt;Chrome-centric scripts, existing Puppeteer code&lt;/td&gt;
&lt;td&gt;Test suites, teams already on Playwright&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Neither is "better." If you have a working Puppeteer codebase, &lt;code&gt;puppeteer.connect()&lt;/code&gt; is a two-line change. If you are starting fresh and want tracing and resilient locators, Playwright is the more ergonomic client — just verify the features you rely on actually work over CDP.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production criteria for a remote endpoint
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;browserWSEndpoint&lt;/code&gt; example gets you connected. Keeping it connected under load is a different problem. These are the criteria that matter when you move from a laptop to a deployed agent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Session lifetime and idle timeouts.&lt;/strong&gt; Hosted sessions are usually metered and expire. Know your idle timeout and whether the clock resets on activity. An agent that pauses for a model call longer than the idle window will come back to a dead socket.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reconnection semantics.&lt;/strong&gt; WebSockets drop. Plan for it: catch the disconnect, request a new session, and decide whether you need to restore state (cookies, storage, open tabs) or can restart the task. Persistent profiles help here — if the runtime supports them, session state survives across connections.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version pinning.&lt;/strong&gt; Ask what Chromium build the runtime serves and whether you can pin it. If your Puppeteer version expects CDP methods the remote build does not implement, you get silent failures. Pin both sides.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proxy and network configuration.&lt;/strong&gt; Many production workflows need specific egress IPs, geolocation, or header handling. Check whether the runtime exposes proxy configuration per session and whether it is configurable at the session level or only globally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Observability.&lt;/strong&gt; When a remote task fails, you need to see what the browser saw. A live viewer or session recording turns a 40-minute debugging session into a 40-second one. This is the difference between a hosted browser you can operate and one you can only hope works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Isolation.&lt;/strong&gt; Sessions should not share cookies, storage, or process state unless you explicitly want them to. Verify this rather than assuming it.&lt;/p&gt;

&lt;p&gt;For a broader look at how these criteria map onto agent workloads, see &lt;a href="https://remote-browser.dev/blog/remote-browser-for-ai-agents" rel="noopener noreferrer"&gt;Remote Browser for AI Agents&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common failure modes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"Protocol error (Target.setAutoAttach): Target closed."&lt;/strong&gt; Usually the session expired or the browser crashed. Check session lifetime before debugging your script.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Connection succeeds, &lt;code&gt;newPage()&lt;/code&gt; hangs.&lt;/strong&gt; Often a version mismatch or a session that is already at its page limit. Log the browser version from &lt;code&gt;browser.version()&lt;/code&gt; and compare it to your Puppeteer's expected Chromium.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Works locally, fails remotely.&lt;/strong&gt; Local Chrome and hosted Chromium differ in flags, extensions, and sometimes headless mode. Anything that depends on a specific launch flag needs to be configured on the runtime side, not passed to &lt;code&gt;connect()&lt;/code&gt; — &lt;code&gt;connect()&lt;/code&gt; has no launch options.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Endpoint works once, then 401s.&lt;/strong&gt; Many providers issue single-use or short-lived endpoints. Request a fresh endpoint per connection rather than caching the string.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a WebSocket endpoint is the wrong tool
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;browserWSEndpoint&lt;/code&gt; is the right abstraction when you want to drive a browser with code you already have. It is the wrong abstraction when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You want a managed task API that takes a natural-language goal and returns a result. That is an agent runtime, not a CDP endpoint.&lt;/li&gt;
&lt;li&gt;You need the browser to outlive your process by hours or days without a client attached. Look for session persistence rather than a socket.&lt;/li&gt;
&lt;li&gt;You are running thousands of short tasks and want the runtime to handle pooling. Managing your own connection pool over raw WebSockets is possible but rarely worth it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are still deciding between a raw endpoint and a higher-level API, &lt;a href="https://remote-browser.dev/blog/remote-browser-online" rel="noopener noreferrer"&gt;Remote Browser Online&lt;/a&gt; walks through the options.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting an endpoint and running the example
&lt;/h2&gt;

&lt;p&gt;The example above works against any CDP-compatible endpoint. To run it against a hosted Chromium session:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a session through the API and read the WebSocket URL from the response.&lt;/li&gt;
&lt;li&gt;Export it as &lt;code&gt;BROWSER_WS_ENDPOINT&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Run the script with &lt;code&gt;puppeteer-core&lt;/code&gt; installed.&lt;/li&gt;
&lt;li&gt;Release the session when the task finishes — do not rely on the socket closing to clean up.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;a href="https://remote-browser.dev/documentation" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; covers session creation, endpoint formats, and the connection lifecycle in detail. Current session limits and metering are on the &lt;a href="https://remote-browser.dev/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One practical note: treat the endpoint as a credential. It grants full control of the browser, including any authenticated sessions inside it. Do not log it, do not commit it, and rotate it per session where the provider supports it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;A Puppeteer &lt;code&gt;browserWSEndpoint&lt;/code&gt; example is short — &lt;code&gt;puppeteer.connect({ browserWSEndpoint })&lt;/code&gt; and you are driving a remote browser. The engineering is in everything around it: matching Puppeteer to the remote Chromium version, distinguishing &lt;code&gt;disconnect()&lt;/code&gt; from &lt;code&gt;close()&lt;/code&gt;, handling session expiry, and choosing a runtime that gives you the observability and isolation you need. Get those right and the endpoint becomes an implementation detail. Get them wrong and you will spend your time debugging sockets instead of shipping the task.&lt;/p&gt;

</description>
      <category>puppeteer</category>
      <category>browserwssendpoint</category>
      <category>cdp</category>
      <category>remotebrowser</category>
    </item>
    <item>
      <title>Puppeteer Remote Browser Chrome: Connect and Run in the Cloud</title>
      <dc:creator>RemoteBrowser</dc:creator>
      <pubDate>Sat, 19 Sep 2026 03:50:05 +0000</pubDate>
      <link>https://dev.to/remotebrowser2/puppeteer-remote-browser-chrome-connect-and-run-in-the-cloud-35p6</link>
      <guid>https://dev.to/remotebrowser2/puppeteer-remote-browser-chrome-connect-and-run-in-the-cloud-35p6</guid>
      <description>&lt;h1&gt;
  
  
  Puppeteer Remote Browser Chrome
&lt;/h1&gt;

&lt;p&gt;Running Puppeteer against a remote browser Chrome instance means your Node process no longer launches a local binary. Instead, it connects to a Chromium process running elsewhere over the Chrome DevTools Protocol (CDP). This is the pattern behind most production browser automation today: the script stays small, the browser becomes infrastructure, and you stop shipping a large Chrome download with every deploy. This guide covers how &lt;code&gt;puppeteer.connect()&lt;/code&gt; works with a &lt;code&gt;browserWSEndpoint&lt;/code&gt;, how to get that endpoint, how browser versions and launch args behave when the browser is remote, and what to check before you put it in production.&lt;/p&gt;

&lt;p&gt;If you already know you want a hosted runtime, you can skip ahead to &lt;a href="https://remote-browser.dev/blog/remote-browser-for-ai-agents" rel="noopener noreferrer"&gt;Remote Browser for AI agents&lt;/a&gt; for the architectural picture. The rest of this post is the Puppeteer-specific mechanics.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "remote browser Chrome" actually means in Puppeteer
&lt;/h2&gt;

&lt;p&gt;Puppeteer has two entry points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;puppeteer.launch()&lt;/code&gt; — spawns a local Chromium/Chrome process and connects to it over a local pipe or WebSocket.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;puppeteer.connect()&lt;/code&gt; — attaches to an &lt;em&gt;already running&lt;/em&gt; browser via a WebSocket URL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A remote browser is just the second case where the WebSocket URL points at a machine that isn't yours. The browser could be a container in your own cluster, a VM you manage, or a hosted service. Puppeteer doesn't care about the topology; it only cares that the endpoint speaks CDP.&lt;/p&gt;

&lt;p&gt;That distinction matters because almost every operational difference between "local Puppeteer" and "remote Puppeteer" comes from the fact that you no longer control the process lifecycle. You don't call &lt;code&gt;browser.close()&lt;/code&gt; to kill a machine you don't own — you disconnect. You don't pass &lt;code&gt;args&lt;/code&gt; to a process that's already running — those were set at launch time by whoever runs the browser. Understanding that split is the whole game.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting a browserWSEndpoint
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;browserWSEndpoint&lt;/code&gt; is a &lt;code&gt;ws://&lt;/code&gt; or &lt;code&gt;wss://&lt;/code&gt; URL that Puppeteer uses to open a CDP session. There are three common ways to obtain one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. From a local launch.&lt;/strong&gt; If you launch Chrome yourself with &lt;code&gt;--remote-debugging-port=9222&lt;/code&gt;, you can read the endpoint from &lt;code&gt;http://localhost:9222/json/version&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:9222/json/version | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.webSocketDebuggerUrl'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is useful for development, but it's not a remote browser — it's your own machine with an extra step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. From a self-hosted container.&lt;/strong&gt; You run Chromium in Docker with a debugging port exposed, then construct the endpoint from the host and port. You own patching, scaling, session cleanup, and outbound network configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. From a hosted runtime.&lt;/strong&gt; You request a session and the provider returns a ready-to-use &lt;code&gt;browserWSEndpoint&lt;/code&gt; (often alongside a CDP URL and a live viewer link). This is the model Remote Browser uses: you get a connection string, paste it into &lt;code&gt;puppeteer.connect()&lt;/code&gt;, and the browser is already running with the settings you requested. See &lt;a href="https://remote-browser.dev/blog/remote-browser-online" rel="noopener noreferrer"&gt;Remote Browser online&lt;/a&gt; for how session provisioning works end to end.&lt;/p&gt;

&lt;p&gt;The important property is that all three produce the same kind of URL. Your Puppeteer code doesn't change between them, which is why migrating from local to remote is usually a config change rather than a rewrite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting Puppeteer to a remote browser
&lt;/h2&gt;

&lt;p&gt;Here is the minimal TypeScript pattern. It uses Playwright's CDP entry point because it handles reconnection and typed sessions well, but the same endpoint works with &lt;code&gt;puppeteer.connect()&lt;/code&gt; directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;playwright&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;SessionInfo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;browserWSEndpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;cdpUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SessionInfo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Connect over CDP to the remote Chromium instance.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connectOverCDP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cdpUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// A remote session usually starts with one existing context.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contexts&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newContext&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;domcontentloaded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;title:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Do work here: fill forms, extract data, run assertions.&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Disconnect, do not kill the remote browser unless you own it.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details are easy to get wrong.&lt;/p&gt;

&lt;p&gt;First, &lt;code&gt;browser.close()&lt;/code&gt; on a CDP connection disconnects the client. Whether it also terminates the remote browser depends on the provider and the session semantics. With a hosted runtime, you typically want to disconnect and let the session expire or be explicitly stopped through the API. Check your provider's docs — for Remote Browser, session lifecycle is covered in the &lt;a href="https://remote-browser.dev/documentation" rel="noopener noreferrer"&gt;documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Second, if you're using Puppeteer directly rather than Playwright, the call is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;puppeteer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;browserWSEndpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;browserWSEndpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;defaultViewport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;defaultViewport: null&lt;/code&gt; matters when the remote browser already has a viewport configured. Forcing a viewport from the client can fight with the server-side setting and produce inconsistent screenshots.&lt;/p&gt;

&lt;h2&gt;
  
  
  Puppeteer browser versions and the install problem
&lt;/h2&gt;

&lt;p&gt;Locally, &lt;code&gt;puppeteer&lt;/code&gt; downloads a pinned Chrome build into a cache directory. That's what &lt;code&gt;puppeteer browsers install chrome&lt;/code&gt; does — it fetches a specific revision and records it. The &lt;code&gt;puppeteer browsers&lt;/code&gt; command family (also exposed through the &lt;code&gt;puppeteer&lt;/code&gt; npm package) manages that cache: list, install, and remove browser builds.&lt;/p&gt;

&lt;p&gt;This is convenient until it isn't. The problems are predictable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Image size.&lt;/strong&gt; Every CI runner and container needs the browser binary. That adds significant size to each image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version drift.&lt;/strong&gt; Your &lt;code&gt;package.json&lt;/code&gt; pins a Puppeteer version, which pins a Chrome revision. When you upgrade Puppeteer, you may silently change the browser your tests run against.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Platform mismatch.&lt;/strong&gt; The Chrome build that works on your laptop may not match the architecture or libc of your production container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold starts.&lt;/strong&gt; Downloading and unpacking Chrome on every fresh worker adds latency you can't easily hide.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A remote browser inverts this. The browser version is a property of the &lt;em&gt;server&lt;/em&gt;, not your client. You choose a version when you request a session, and your Puppeteer client just connects. There's no &lt;code&gt;puppeteer browsers install chrome&lt;/code&gt; step in your build, no cache to warm, and no binary in your image.&lt;/p&gt;

&lt;p&gt;The trade-off is that you now depend on the provider's version catalog. If you need a very specific Chrome build for a compatibility test, confirm it's available before you commit. For most automation and agent workloads, "a recent stable Chromium" is fine, and the operational savings dominate.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Local Puppeteer&lt;/th&gt;
&lt;th&gt;Remote browser Chrome&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Browser binary&lt;/td&gt;
&lt;td&gt;Downloaded per machine/image&lt;/td&gt;
&lt;td&gt;Runs server-side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Version control&lt;/td&gt;
&lt;td&gt;Pinned by Puppeteer release&lt;/td&gt;
&lt;td&gt;Selected per session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Launch args&lt;/td&gt;
&lt;td&gt;You pass them to &lt;code&gt;launch()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Set by the runtime at launch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scaling&lt;/td&gt;
&lt;td&gt;You manage processes and memory&lt;/td&gt;
&lt;td&gt;Provider manages capacity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session cleanup&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;browser.close()&lt;/code&gt; kills the process&lt;/td&gt;
&lt;td&gt;Disconnect; session ends via API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proxy / network&lt;/td&gt;
&lt;td&gt;Your infra&lt;/td&gt;
&lt;td&gt;Configurable per session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debugging&lt;/td&gt;
&lt;td&gt;Local DevTools&lt;/td&gt;
&lt;td&gt;Live viewer + CDP&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Puppeteer browser args when the browser is remote
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;puppeteer.launch({ args: [...] })&lt;/code&gt; is one of the most-used Puppeteer APIs, and it's the one that changes most when you go remote. Those args are passed to the Chrome process at startup. If the process is already running on someone else's machine, you can't inject new args from the client.&lt;/p&gt;

&lt;p&gt;In practice this means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Flags you can still influence from the client&lt;/strong&gt; are limited to CDP-level behavior — things like viewport, emulation, and network interception, which are set through the protocol rather than process flags.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flags that must be set at launch&lt;/strong&gt; — sandbox settings, headless mode, GPU flags, proxy configuration, and similar — have to be exposed by the runtime as session options.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the question to ask any remote browser provider is: &lt;em&gt;which launch args can I configure per session?&lt;/em&gt; A runtime that only gives you a fixed browser is fine for simple scraping but painful when you need a specific proxy, a particular user agent at the process level, or a non-default headless mode.&lt;/p&gt;

&lt;p&gt;Remote Browser exposes configurable browser settings per session, including proxy and network options, rather than a fixed binary. If you need a specific combination, verify it against the &lt;a href="https://remote-browser.dev/documentation" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; before designing around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production criteria for a remote Puppeteer setup
&lt;/h2&gt;

&lt;p&gt;Once the connection works, the interesting questions are operational. Here's a checklist that separates a demo from something you'd run on a schedule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Session isolation.&lt;/strong&gt; Each task should get its own browser context or its own browser, so cookies and storage from one job don't leak into another. Shared browsers with shared profiles are a common source of flaky, hard-to-reproduce bugs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Persistent profiles when you need them.&lt;/strong&gt; Some workflows — logged-in dashboards, multi-step flows — need state to survive across sessions. Others need a clean slate every time. A good runtime lets you choose, rather than forcing one model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reconnection behavior.&lt;/strong&gt; WebSocket connections drop. Your client should handle a closed CDP socket by reconnecting or failing fast, not by hanging. Set explicit timeouts on &lt;code&gt;connectOverCDP&lt;/code&gt; and on navigation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Observability.&lt;/strong&gt; When a remote task fails, you want to see what the browser saw. A live viewer and session recordings turn "it failed" into "here's the frame where the selector didn't match." See &lt;a href="https://remote-browser.dev/blog/remote-control-browser" rel="noopener noreferrer"&gt;Remote control browser&lt;/a&gt; for how live session control fits into debugging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost model.&lt;/strong&gt; Remote browsers are usually billed by session time. That means idle sessions cost money, and a task that hangs on a &lt;code&gt;waitForSelector&lt;/code&gt; with no timeout is a billing bug as much as a logic bug. Always set timeouts. Current rates are on the &lt;a href="https://remote-browser.dev/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Network egress.&lt;/strong&gt; If your automation pulls large payloads, egress can matter as much as browser time. Know what's metered.&lt;/p&gt;

&lt;h2&gt;
  
  
  When local Puppeteer is still the right call
&lt;/h2&gt;

&lt;p&gt;Remote isn't automatically better. Local Puppeteer is the right choice when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're developing and iterating on selectors, where the feedback loop of a local browser is faster.&lt;/li&gt;
&lt;li&gt;You need a browser build that isn't available remotely.&lt;/li&gt;
&lt;li&gt;Your workload is small, infrequent, and runs on a machine that already has Chrome.&lt;/li&gt;
&lt;li&gt;You're debugging a CDP-level issue and want full control of the process.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The migration path that works well is to keep local Puppeteer for development and point the same code at a remote endpoint in CI and production. Because the only difference is the connection URL, you can gate it on an environment variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BROWSER_WS_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;endpoint&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;browserWSEndpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;endpoint&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;headless&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single branch lets you develop locally and run remotely without maintaining two code paths.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits for AI agents
&lt;/h2&gt;

&lt;p&gt;Puppeteer remote browser setups are increasingly the substrate for AI browser agents rather than hand-written scripts. The reason is the same as above: agents need browsers that start fast, isolate cleanly, and can be observed when they go wrong. An agent that runs a hundred steps and fails at step ninety is only debuggable if you can replay the session.&lt;/p&gt;

&lt;p&gt;If that's your use case, the Puppeteer mechanics in this post still apply — the agent framework ultimately drives a CDP connection — but the runtime requirements are stricter. Persistent profiles, proxy configuration, and live viewing move from "nice to have" to "required." &lt;a href="https://remote-browser.dev/blog/remote-web-browser" rel="noopener noreferrer"&gt;Remote web browser&lt;/a&gt; covers the runtime side of that in more depth.&lt;/p&gt;

&lt;p&gt;The practical takeaway: learn &lt;code&gt;puppeteer.connect()&lt;/code&gt; and &lt;code&gt;browserWSEndpoint&lt;/code&gt; once, and you can point your automation at a local Chrome, a container you run, or a hosted runtime without changing the code that does the actual work. The browser becomes a connection string, and everything above it stays portable.&lt;/p&gt;

&lt;p&gt;For the CDP details underneath, the &lt;a href="https://chromedevtools.github.io/devtools-protocol/" rel="noopener noreferrer"&gt;Chrome DevTools Protocol documentation&lt;/a&gt; is the authoritative reference, and Playwright's &lt;code&gt;connectOverCDP&lt;/code&gt; behavior is documented in the &lt;a href="https://playwright.dev/docs/api/class-browsertype#browser-type-connect-over-cdp" rel="noopener noreferrer"&gt;Playwright docs&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>puppeteer</category>
      <category>remotebrowser</category>
      <category>chrome</category>
      <category>cdp</category>
    </item>
    <item>
      <title>Puppeteer Remote Browser Download: What You Install vs Connect</title>
      <dc:creator>RemoteBrowser</dc:creator>
      <pubDate>Sat, 19 Sep 2026 03:50:04 +0000</pubDate>
      <link>https://dev.to/remotebrowser2/puppeteer-remote-browser-download-what-you-install-vs-connect-2eg8</link>
      <guid>https://dev.to/remotebrowser2/puppeteer-remote-browser-download-what-you-install-vs-connect-2eg8</guid>
      <description>&lt;h1&gt;
  
  
  Puppeteer Remote Browser Download: What You Install vs Connect
&lt;/h1&gt;

&lt;p&gt;If you searched for a "puppeteer remote browser download," you probably want one of two things: the Chrome binary that Puppeteer needs locally, or a way to run Puppeteer against a browser that isn't on your machine. Those are different problems, and the commands people copy from each other often solve the wrong one. This guide separates them: what &lt;code&gt;puppeteer browsers install chrome&lt;/code&gt; downloads, what &lt;code&gt;browserWSEndpoint&lt;/code&gt; actually points at, and how to connect Puppeteer to a hosted Chromium session when local downloads stop being practical.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two meanings of "Puppeteer remote browser download"
&lt;/h2&gt;

&lt;p&gt;Puppeteer has always shipped with a browser download step. Historically, &lt;code&gt;npm install puppeteer&lt;/code&gt; pulled a pinned Chrome build into a cache directory. Since Puppeteer v19+, that behavior is more explicit: you can install Puppeteer as a library and manage browser binaries separately with the &lt;code&gt;@puppeteer/browsers&lt;/code&gt; CLI.&lt;/p&gt;

&lt;p&gt;That gives you two distinct workflows:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Local binary management.&lt;/strong&gt; You download Chrome/Chromium to disk and launch it as a child process. Puppeteer talks to it over a local pipe or WebSocket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remote connection.&lt;/strong&gt; You skip the download entirely and connect to a browser already running somewhere else — a container, a VM, or a hosted runtime — using &lt;code&gt;puppeteer.connect()&lt;/code&gt; with a WebSocket endpoint.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The phrase "remote browser download" blurs these. A remote browser isn't something you download; it's something you connect to. What you download is the &lt;em&gt;client&lt;/em&gt; — the Puppeteer library and, optionally, a local Chrome for development.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;puppeteer browsers install chrome&lt;/code&gt; actually does
&lt;/h2&gt;

&lt;p&gt;The modern CLI is &lt;code&gt;@puppeteer/browsers&lt;/code&gt;. It's a standalone package that manages browser binaries independently of the Puppeteer library version.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install the CLI&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; @puppeteer/browsers

&lt;span class="c"&gt;# Download a specific Chrome build into a cache dir&lt;/span&gt;
npx @puppeteer/browsers &lt;span class="nb"&gt;install &lt;/span&gt;chrome@stable

&lt;span class="c"&gt;# Or pin an exact version&lt;/span&gt;
npx @puppeteer/browsers &lt;span class="nb"&gt;install &lt;/span&gt;chrome@121.0.6167.85

&lt;span class="c"&gt;# List what's already cached&lt;/span&gt;
npx @puppeteer/browsers list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key behaviors worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Binaries land in a cache directory&lt;/strong&gt;, not &lt;code&gt;node_modules&lt;/code&gt;. On Linux that's typically &lt;code&gt;~/.cache/puppeteer&lt;/code&gt;; on macOS it's under &lt;code&gt;~/Library/Caches/puppeteer&lt;/code&gt;. This matters for Docker images and CI runners, where the cache path is often ephemeral.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;chrome&lt;/code&gt; and &lt;code&gt;chromium&lt;/code&gt; are different channels.&lt;/strong&gt; &lt;code&gt;chrome&lt;/code&gt; is the branded Google build; &lt;code&gt;chromium&lt;/code&gt; is the open-source build. They have different version cadences and slightly different feature sets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;chrome-headless-shell&lt;/code&gt; is a separate download.&lt;/strong&gt; If you only need headless automation, this is a smaller binary than full Chrome. Puppeteer can use it, but some features (extensions, certain rendering paths) differ.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version pinning is explicit.&lt;/strong&gt; &lt;code&gt;chrome@stable&lt;/code&gt; tracks the stable channel; an exact version string locks you to a build. Reproducible CI usually wants the exact version.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're using the full &lt;code&gt;puppeteer&lt;/code&gt; package rather than &lt;code&gt;puppeteer-core&lt;/code&gt;, the install script handles this for you. &lt;code&gt;puppeteer-core&lt;/code&gt; never downloads a browser — it expects you to supply one, either locally or via a connection URL.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the download step causes problems in production
&lt;/h3&gt;

&lt;p&gt;The download is fine on a laptop. In production it creates friction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Image size.&lt;/strong&gt; A full Chrome build adds a large amount of disk to a container image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold-start latency.&lt;/strong&gt; Downloading on first boot adds seconds to every fresh instance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version drift.&lt;/strong&gt; Different services pin different Chrome versions, and a shared cache can serve the wrong one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing system libraries.&lt;/strong&gt; Chrome needs a set of shared libraries (&lt;code&gt;libnss3&lt;/code&gt;, &lt;code&gt;libatk&lt;/code&gt;, &lt;code&gt;libgbm&lt;/code&gt;, and friends). Minimal base images don't have them, and the failure mode is a cryptic launch error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sandbox requirements.&lt;/strong&gt; Chrome's sandbox needs specific kernel capabilities. Many container runtimes require &lt;code&gt;--no-sandbox&lt;/code&gt;, which weakens isolation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are fatal, but together they're why teams eventually ask whether they need to download a browser at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting instead of downloading: &lt;code&gt;browserWSEndpoint&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Puppeteer's remote story is &lt;code&gt;puppeteer.connect()&lt;/code&gt;. You give it a WebSocket URL, and it attaches to an already-running browser over the Chrome DevTools Protocol (CDP).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;puppeteer-core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;browserWSEndpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wss://your-endpoint.example/devtools/browser/&amp;lt;id&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;defaultViewport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;networkidle2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Disconnect without killing the remote browser&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details that trip people up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;disconnect()&lt;/code&gt; vs &lt;code&gt;close()&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;disconnect()&lt;/code&gt; detaches your client and leaves the browser running. &lt;code&gt;close()&lt;/code&gt; sends a command that shuts the browser down. For a hosted session you usually want &lt;code&gt;disconnect()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;puppeteer-core&lt;/code&gt; is the right package here.&lt;/strong&gt; The full &lt;code&gt;puppeteer&lt;/code&gt; package bundles a download step you don't need when connecting remotely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The endpoint is a capability.&lt;/strong&gt; Anyone with the WebSocket URL can drive that browser. Treat it like a credential — don't log it, don't commit it, and rotate it per session where the runtime supports it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;browserWSEndpoint&lt;/code&gt; you get from a hosted runtime is typically per-session and short-lived. That's a feature: it means a leaked URL has a bounded blast radius.&lt;/p&gt;

&lt;h2&gt;
  
  
  Puppeteer vs Playwright for remote connections
&lt;/h2&gt;

&lt;p&gt;Both libraries speak CDP, but their remote ergonomics differ. If you're choosing a client for a hosted runtime, this table is the practical summary.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Puppeteer&lt;/th&gt;
&lt;th&gt;Playwright&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Remote connect API&lt;/td&gt;
&lt;td&gt;&lt;code&gt;puppeteer.connect({ browserWSEndpoint })&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;chromium.connectOverCDP(endpoint)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Package for remote-only use&lt;/td&gt;
&lt;td&gt;&lt;code&gt;puppeteer-core&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;playwright-core&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser download on install&lt;/td&gt;
&lt;td&gt;Yes, with full &lt;code&gt;puppeteer&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes, with full &lt;code&gt;playwright&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-browser support&lt;/td&gt;
&lt;td&gt;Chrome/Chromium/Firefox (limited)&lt;/td&gt;
&lt;td&gt;Chromium, Firefox, WebKit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto-waiting&lt;/td&gt;
&lt;td&gt;Manual (&lt;code&gt;waitForSelector&lt;/code&gt;, etc.)&lt;/td&gt;
&lt;td&gt;Built into locators&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CDP session access&lt;/td&gt;
&lt;td&gt;&lt;code&gt;page.target().createCDPSession()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;context.newCDPSession(page)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best fit&lt;/td&gt;
&lt;td&gt;Chrome-centric automation, existing Puppeteer code&lt;/td&gt;
&lt;td&gt;Cross-browser testing, newer agent code&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Neither is wrong. If you have a working Puppeteer codebase, &lt;code&gt;puppeteer.connect()&lt;/code&gt; is a two-line change. If you're starting fresh and want cross-browser coverage, Playwright's &lt;code&gt;connectOverCDP&lt;/code&gt; is the equivalent path — see the &lt;a href="https://playwright.dev/docs/api/class-browsertype#browser-type-connect-over-cdp" rel="noopener noreferrer"&gt;Playwright CDP documentation&lt;/a&gt; for the exact semantics, including the Chromium-only constraint.&lt;/p&gt;

&lt;h2&gt;
  
  
  A TypeScript example: Playwright over CDP to a hosted session
&lt;/h2&gt;

&lt;p&gt;Most teams that outgrow local downloads end up on Playwright for new code, because the auto-waiting behavior reduces flaky selectors. Here's a typed example that connects to a hosted Chromium session over CDP, creates a context, and cleans up properly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Browser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;BrowserContext&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playwright-core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;SessionInfo&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;cdpUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SessionInfo&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Browser&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BrowserContext&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// connectOverCDP attaches to an existing browser; it does not launch one.&lt;/span&gt;
    &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connectOverCDP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cdpUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// A hosted session usually exposes one default context.&lt;/span&gt;
    &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contexts&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newContext&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;domcontentloaded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// Locators auto-wait; no manual sleep needed.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;heading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;h1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;innerText&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Close the page/context you created, then detach.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SessionInfo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;cdpUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;REMOTE_BROWSER_CDP_URL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;REMOTE_BROWSER_SESSION_ID&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nf"&gt;runTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;heading:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;task failed:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exitCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes on the code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;connectOverCDP&lt;/code&gt; is Chromium-only.&lt;/strong&gt; It won't attach to Firefox or WebKit. If you need those, you need a different transport.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;browser.close()&lt;/code&gt; on a CDP connection&lt;/strong&gt; closes the connection and, depending on the runtime, may terminate the remote browser. If you want to preserve session state, detach instead and let the runtime reap the session on its own schedule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timeouts are explicit.&lt;/strong&gt; Remote connections add network latency that local pipes don't have. Default timeouts that work locally often need to be raised.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;playwright-core&lt;/code&gt; avoids the browser download.&lt;/strong&gt; Same reasoning as &lt;code&gt;puppeteer-core&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Browser args and version pinning on remote sessions
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;puppeteer browser args&lt;/code&gt; and &lt;code&gt;puppeteer browser version&lt;/code&gt; are common follow-up searches, and they behave differently when the browser isn't yours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Locally&lt;/strong&gt;, you pass args at launch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--no-sandbox&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--disable-dev-shm-usage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--window-size=1280,800&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Remotely&lt;/strong&gt;, you can't pass launch args — the browser is already running. Instead, the runtime exposes configurable browser settings: viewport, locale, timezone, user agent, proxy configuration, and similar session-level options. If you need a specific Chrome version, you select it when creating the session rather than pinning it in your client code.&lt;/p&gt;

&lt;p&gt;This is a real trade-off. You lose fine-grained control over the process, and you gain consistency: every session starts from the same known configuration, and you're not debugging why one container has a different &lt;code&gt;libgbm&lt;/code&gt; version than another.&lt;/p&gt;

&lt;p&gt;For a deeper look at how session configuration and profiles work on a hosted runtime, see &lt;a href="https://remote-browser.dev/blog/remote-browser-for-ai-agents" rel="noopener noreferrer"&gt;Remote Browser for AI agents&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to download, when to connect
&lt;/h2&gt;

&lt;p&gt;A decision table, since this is the actual question behind the search.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Download locally&lt;/th&gt;
&lt;th&gt;Connect to remote&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Local development and debugging&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;Optional&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One-off scripts on your laptop&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;Overkill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI with a stable base image&lt;/td&gt;
&lt;td&gt;✅ (cached)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serverless / short-lived functions&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Horizontal scale beyond a few instances&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sessions that must survive worker restarts&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need for persistent profiles across runs&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strict reproducibility across environments&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Air-gapped or offline environments&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern: local downloads are fine when the environment is stable and the browser's lifetime matches the process's lifetime. Remote connections win when the browser needs to outlive the worker, or when you're running enough instances that per-instance browser management becomes a maintenance burden.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production criteria for a remote browser runtime
&lt;/h2&gt;

&lt;p&gt;If you're evaluating hosted Chromium for Puppeteer or Playwright, these are the things that actually matter in production — not the marketing bullets.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CDP compatibility.&lt;/strong&gt; Does it expose a standard &lt;code&gt;browserWSEndpoint&lt;/code&gt; that &lt;code&gt;puppeteer.connect()&lt;/code&gt; and &lt;code&gt;chromium.connectOverCDP()&lt;/code&gt; accept without patched clients? If you need a custom SDK, that's a lock-in signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session isolation.&lt;/strong&gt; Are sessions isolated from each other at the process or container level? Shared browser processes leak state between tenants.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistent profiles.&lt;/strong&gt; Can a session retain cookies and local storage across runs? This is essential for authenticated workflows and impossible with ephemeral local Chrome.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live viewer.&lt;/strong&gt; Can you watch a session in real time when something fails? Debugging a headless remote browser without a viewer is painful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proxy and network controls.&lt;/strong&gt; Per-session proxy configuration, and clarity about which egress IPs you'll see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Usage controls.&lt;/strong&gt; Per-session time limits, concurrency caps, and a way to see what you're spending. Check &lt;a href="https://remote-browser.dev/pricing" rel="noopener noreferrer"&gt;/pricing&lt;/a&gt; for current rates rather than trusting a blog post.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability.&lt;/strong&gt; Session logs, CDP event access, and the ability to attach a debugger mid-session.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a broader comparison of hosted versus self-managed browser infrastructure, &lt;a href="https://remote-browser.dev/blog/remote-browser-online" rel="noopener noreferrer"&gt;Remote Browser online&lt;/a&gt; covers the operational trade-offs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating an existing Puppeteer script
&lt;/h2&gt;

&lt;p&gt;The migration is smaller than it looks. The typical diff:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- const browser = await puppeteer.launch({
-   headless: 'new',
-   args: ['--no-sandbox'],
- });
&lt;/span&gt;&lt;span class="gi"&gt;+ const browser = await puppeteer.connect({
+   browserWSEndpoint: process.env.REMOTE_BROWSER_WS_ENDPOINT,
+   defaultViewport: null,
+ });
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Swap &lt;code&gt;puppeteer&lt;/code&gt; for &lt;code&gt;puppeteer-core&lt;/code&gt;&lt;/strong&gt; in &lt;code&gt;package.json&lt;/code&gt; to drop the download step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remove &lt;code&gt;--no-sandbox&lt;/code&gt; and other launch args.&lt;/strong&gt; They're the runtime's concern now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move viewport and user-agent settings&lt;/strong&gt; from launch options to session creation or &lt;code&gt;page.setViewport()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replace &lt;code&gt;browser.close()&lt;/code&gt; with &lt;code&gt;browser.disconnect()&lt;/code&gt;&lt;/strong&gt; if you want the session to persist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raise timeouts.&lt;/strong&gt; Network hops add latency; 30s defaults may be too tight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handle reconnection.&lt;/strong&gt; Remote sessions can drop. Wrap your task in retry logic that creates a fresh session rather than assuming the old endpoint is still valid.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 6 is the one people skip, and it's the one that causes production incidents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Remote Browser fits
&lt;/h2&gt;

&lt;p&gt;Remote Browser provides hosted Chromium sessions with CDP access, so &lt;code&gt;puppeteer.connect()&lt;/code&gt; and &lt;code&gt;chromium.connectOverCDP()&lt;/code&gt; work without a custom client. Sessions support persistent profiles, configurable browser settings including proxy configuration, a live viewer for debugging, and per-session usage controls.&lt;/p&gt;

&lt;p&gt;The practical upshot for the "download" question: you install &lt;code&gt;puppeteer-core&lt;/code&gt; or &lt;code&gt;playwright-core&lt;/code&gt;, you never download a browser binary, and you connect to a session that's already running. That removes the image-size, cold-start, and version-drift problems described earlier — at the cost of a network hop and a dependency on the runtime's availability.&lt;/p&gt;

&lt;p&gt;Start with the &lt;a href="https://remote-browser.dev/documentation" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; for connection details and session lifecycle, and check &lt;a href="https://remote-browser.dev/pricing" rel="noopener noreferrer"&gt;/pricing&lt;/a&gt; for current usage rates. If you're still deciding between local and remote, &lt;a href="https://remote-browser.dev/blog/remote-web-browser" rel="noopener noreferrer"&gt;Remote web browser&lt;/a&gt; walks through the runtime model in more depth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;puppeteer browsers install chrome&lt;/code&gt; downloads a Chrome binary to a local cache. It does not give you a remote browser.&lt;/li&gt;
&lt;li&gt;A remote browser is something you &lt;em&gt;connect&lt;/em&gt; to via &lt;code&gt;puppeteer.connect({ browserWSEndpoint })&lt;/code&gt; or &lt;code&gt;chromium.connectOverCDP()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;puppeteer-core&lt;/code&gt; / &lt;code&gt;playwright-core&lt;/code&gt; when connecting remotely — they skip the download entirely.&lt;/li&gt;
&lt;li&gt;Launch args don't apply to remote sessions; session-level configuration replaces them.&lt;/li&gt;
&lt;li&gt;Download locally for development and stable CI; connect remotely for serverless, horizontal scale, persistent profiles, and sessions that outlive workers.&lt;/li&gt;
&lt;li&gt;The migration is a handful of lines plus retry logic. The retry logic is the part that matters.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>puppeteer</category>
      <category>remotebrowser</category>
      <category>cdp</category>
      <category>browserautomation</category>
    </item>
    <item>
      <title>Playwright Chrome Recorder: Record, Export, and Run Remotely</title>
      <dc:creator>RemoteBrowser</dc:creator>
      <pubDate>Fri, 18 Sep 2026 03:28:24 +0000</pubDate>
      <link>https://dev.to/remotebrowser2/playwright-chrome-recorder-record-export-and-run-remotely-4k7a</link>
      <guid>https://dev.to/remotebrowser2/playwright-chrome-recorder-record-export-and-run-remotely-4k7a</guid>
      <description>&lt;h1&gt;
  
  
  Playwright Chrome Recorder: Record, Export, and Run Remotely
&lt;/h1&gt;

&lt;p&gt;The Playwright Chrome Recorder is a browser extension that captures clicks, typing, and navigation in a real Chrome tab and exports them as a Playwright test script. It is the fastest way to get a working selector for a stubborn element, and a reasonable way to bootstrap a test you will later harden. It is not a test generator you can ship unedited, and it does not run anything for you — it produces code you still have to execute somewhere.&lt;/p&gt;

&lt;p&gt;That last point matters more than it used to. Once a recording exists, the question shifts from "how do I capture this flow" to "where does this script actually run." Locally, you are managing Chrome versions, driver binaries, and a machine that has to stay awake. On a hosted runtime, you connect over CDP and the browser lives somewhere else. This guide covers the recorder itself, what its output is worth, and how to point the resulting script at a remote Chromium session.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Playwright Chrome Recorder Actually Does
&lt;/h2&gt;

&lt;p&gt;The recorder ships as a Chrome extension. You open it against the tab you want to capture, hit record, and interact with the page normally. Each action becomes a line in a generated script. When you stop, you can copy the output as a Playwright test, a Puppeteer script, or a set of raw actions.&lt;/p&gt;

&lt;p&gt;The extension is maintained alongside Playwright, so the generated code tracks current API conventions rather than the older &lt;code&gt;page.$eval&lt;/code&gt; style you see in stale tutorials. Selectors are the interesting part: the recorder prefers role-based and text-based locators (&lt;code&gt;getByRole&lt;/code&gt;, &lt;code&gt;getByLabel&lt;/code&gt;, &lt;code&gt;getByText&lt;/code&gt;) over brittle CSS paths, which is a meaningful improvement over naive recording tools that emit &lt;code&gt;div &amp;gt; div:nth-child(3) &amp;gt; span&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What it captures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Clicks and taps&lt;/strong&gt; on interactive elements, with a best-effort accessible-name selector.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text input&lt;/strong&gt;, including per-field values as you type them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Navigation&lt;/strong&gt;, including form submissions that trigger page loads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assertions&lt;/strong&gt;, if you manually add them through the recorder UI — visibility, text content, and value checks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it does not capture: network interception, multi-tab or multi-context flows, file uploads in every case, iframe interactions reliably, and anything involving authentication state you set up outside the recorded tab. It also records what you did, not what you meant. If you clicked a button twice because the first click was slow, you get two clicks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the Generated Script Critically
&lt;/h2&gt;

&lt;p&gt;A recorded script is a draft. Treat the output as a starting point and expect to edit it before it goes into CI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Selectors degrade.&lt;/strong&gt; The recorder picks the best locator it can find at record time. If the page had a stable &lt;code&gt;data-testid&lt;/code&gt;, great. If it fell back to visible text, that text is now a coupling point — a copy change breaks your test. Review every locator and replace text-based ones with stable attributes where the application exposes them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timing is implicit.&lt;/strong&gt; Recorded scripts rely on Playwright's auto-waiting, which handles most cases but not all. Flows with debounced search, optimistic UI updates, or animations that settle late will need explicit &lt;code&gt;expect(...).toBeVisible()&lt;/code&gt; assertions rather than a bare click.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;State is missing.&lt;/strong&gt; The recording starts from whatever state your browser was in. If you were logged in, the script assumes you are logged in. There is no &lt;code&gt;storageState&lt;/code&gt; setup, no fixture, no teardown. You have to add that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Values are hardcoded.&lt;/strong&gt; Credentials, search terms, and IDs get baked in as literals. Parameterize them before the script touches a shared environment.&lt;/p&gt;

&lt;p&gt;A practical workflow: record the flow, extract the selectors you could not figure out by hand, then rewrite the script around your own fixtures and page objects. The recorder's real value is selector discovery, not test authoring.&lt;/p&gt;

&lt;h2&gt;
  
  
  From Recording to Execution: Where the Script Runs
&lt;/h2&gt;

&lt;p&gt;Here is where most teams hit friction. A recorded script is a Playwright script, and Playwright scripts need a browser. Three options, with real trade-offs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Setup cost&lt;/th&gt;
&lt;th&gt;Isolation&lt;/th&gt;
&lt;th&gt;Scaling&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Local Chrome + &lt;code&gt;playwright install&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Low initially, recurring per machine&lt;/td&gt;
&lt;td&gt;None — shares your desktop session&lt;/td&gt;
&lt;td&gt;Manual, one machine&lt;/td&gt;
&lt;td&gt;Debugging, one-off selector capture&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted Playwright in containers&lt;/td&gt;
&lt;td&gt;High — image builds, driver pinning, orchestration&lt;/td&gt;
&lt;td&gt;Per container&lt;/td&gt;
&lt;td&gt;Requires scheduler and capacity planning&lt;/td&gt;
&lt;td&gt;Teams with existing infra and ops headcount&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosted Chromium over CDP&lt;/td&gt;
&lt;td&gt;Low — one connection string&lt;/td&gt;
&lt;td&gt;Per session&lt;/td&gt;
&lt;td&gt;Handled by the provider&lt;/td&gt;
&lt;td&gt;CI, agents, parallel test runs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The local path is fine for recording. It stops being fine when the script needs to run on a schedule, in CI, or from a machine that is not your laptop. The self-hosted path works but the maintenance is real: Chromium updates break driver compatibility, container images drift, and you end up owning a browser fleet.&lt;/p&gt;

&lt;p&gt;The hosted path replaces all of that with a WebSocket endpoint. You call &lt;code&gt;chromium.connectOverCDP(endpoint)&lt;/code&gt; instead of &lt;code&gt;chromium.launch()&lt;/code&gt;, and the rest of your script is unchanged. Remote Browser exposes exactly this — hosted Chromium sessions with CDP access, Playwright/Puppeteer/Selenium compatibility, persistent profiles, and a live viewer for debugging. You can see the connection model in the &lt;a href="https://remote-browser.dev/documentation" rel="noopener noreferrer"&gt;documentation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting a Recorded Script to Remote Chromium
&lt;/h2&gt;

&lt;p&gt;The migration from local to remote is a two-line change. Here is a TypeScript example that takes a recorded flow and runs it against a hosted session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Browser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playwright&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CDP_ENDPOINT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;REMOTE_BROWSER_WS&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runRecordedFlow&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// connectOverCDP attaches to an existing browser instead of launching one.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connectOverCDP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CDP_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// A remote session may already have a context; reuse it if so.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contexts&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newContext&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Locators below are what the recorder would emit, hardened by hand.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TEST_EMAIL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Password&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TEST_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Sign in&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Replace implicit waits with explicit assertions.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;heading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Dashboard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;waitFor&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;link&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Reports&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Export CSV&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;download&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;download&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Saved to:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;download&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Close the connection, not the remote browser, unless you own its lifecycle.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;runRecordedFlow&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details worth noting. First, &lt;code&gt;connectOverCDP&lt;/code&gt; is Chromium-only — it will not attach to Firefox or WebKit, so if your recorded flow needs cross-browser coverage you will run the local-launch path for those engines. Second, &lt;code&gt;browser.contexts()[0]&lt;/code&gt; may already exist on a remote session; creating a second context is fine but changes your isolation semantics. Third, closing the browser object closes the CDP connection. Whether that tears down the remote session depends on the provider's lifecycle rules, so check before you rely on it.&lt;/p&gt;

&lt;p&gt;If you are still deciding between attaching to an existing browser and launching a fresh one, the trade-offs are covered in &lt;a href="https://remote-browser.dev/blog/remote-web-browser" rel="noopener noreferrer"&gt;Playwright attach to existing browser&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production Criteria for Recorded Flows
&lt;/h2&gt;

&lt;p&gt;A recording that works once is not a test. Before a recorded flow runs unattended, it needs to survive these conditions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authentication.&lt;/strong&gt; Recorded flows assume a logged-in state. Move login into a setup step that writes &lt;code&gt;storageState&lt;/code&gt;, then reuse it across tests. On a hosted runtime, persistent profiles let you keep that state across sessions without re-authenticating every run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Parallelism.&lt;/strong&gt; Recorded scripts often share a single browser context, which serializes them. Give each test its own context or session so runs do not collide on cookies or local storage. Session isolation is a runtime property, not something you can patch in the script.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flakiness.&lt;/strong&gt; The most common cause is a selector that matched something incidental at record time. The second most common is a missing wait. Both are fixable by editing the script, not by adding retries — retries hide the problem and inflate your runtime bill.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Observability.&lt;/strong&gt; When a recorded flow fails in CI, you need the trace, the screenshot, and ideally a live view of the session. Playwright's trace viewer covers the first two. For the third, a live viewer attached to the remote session lets you watch the run as it happens rather than reconstructing it afterward.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost control.&lt;/strong&gt; Browser time is metered on hosted runtimes. A recorded flow with generous &lt;code&gt;waitForTimeout&lt;/code&gt; calls burns session minutes doing nothing. Replace fixed sleeps with event-based waits and your bill drops accordingly. Current rates are on the &lt;a href="https://remote-browser.dev/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recorder vs. Hand-Written vs. Agent-Driven
&lt;/h2&gt;

&lt;p&gt;The recorder is one of three ways to produce a browser script, and it is not always the right one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use the recorder when&lt;/strong&gt; you are reverse-engineering a complex UI, the selectors are non-obvious, or you need a quick smoke test for a flow you already understand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write by hand when&lt;/strong&gt; the flow has branching logic, data-driven steps, or assertions that depend on computed values. Recorded scripts do not express conditionals well, and retrofitting them is more work than starting clean.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use an agent when&lt;/strong&gt; the flow is exploratory or changes frequently — the agent reads the page and decides what to click, so selector drift does not break it the way it breaks a recorded script. Agents still need a browser to run in, and the runtime requirements are similar: CDP access, session isolation, and a way to observe what happened. That overlap is why the same hosted runtime serves both recorded tests and agent workloads; see &lt;a href="https://remote-browser.dev/blog/remote-browser-for-ai-agents" rel="noopener noreferrer"&gt;Remote Browser for AI agents&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A hybrid works well in practice: record once to discover selectors, hand-write the production test around them, and let an agent handle the flows that are too volatile to script.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Setup Notes
&lt;/h2&gt;

&lt;p&gt;A few things that save time when you wire the recorder into a real workflow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pin the extension version&lt;/strong&gt; in your team's browser profile so everyone generates code against the same Playwright release.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Record against a staging environment&lt;/strong&gt;, not production, so your captured values are not real customer data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strip credentials immediately.&lt;/strong&gt; The recorder captures what you type. If you typed a real password, it is in the output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep recordings out of version control&lt;/strong&gt; until they are rewritten. A raw recording in a PR invites review comments about selectors that will not survive the first edit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the remote connection before the flow.&lt;/strong&gt; A &lt;code&gt;connectOverCDP&lt;/code&gt; call that fails on a bad endpoint looks like a test failure if you do not separate the two.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the connection layer specifically, the &lt;a href="https://playwright.dev/docs/api/class-browsertype#browser-type-connect-over-cdp" rel="noopener noreferrer"&gt;Playwright CDP documentation&lt;/a&gt; is the authoritative reference on &lt;code&gt;connectOverCDP&lt;/code&gt; behavior, including the Chromium-only constraint and how contexts are exposed on an attached browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Leaves You
&lt;/h2&gt;

&lt;p&gt;The Playwright Chrome Recorder solves a narrow, real problem: turning a manual interaction into a locator you can trust. It does not solve execution, isolation, or maintenance, and treating it as a test-generation tool leads to brittle suites.&lt;/p&gt;

&lt;p&gt;The productive pattern is to use the recorder for what it is good at — selector discovery — and invest your effort in the runtime and the test structure around it. If your scripts need to run on a schedule, in parallel, or from a machine that is not your laptop, a hosted Chromium session removes the browser-management work without changing your Playwright code. Start with the &lt;a href="https://remote-browser.dev/documentation" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; to see the connection model, and check &lt;a href="https://remote-browser.dev/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt; before you plan capacity.&lt;/p&gt;

</description>
      <category>playwright</category>
      <category>chromerecorder</category>
      <category>cdp</category>
      <category>browserautomation</category>
    </item>
    <item>
      <title>Playwright connectOverCDP GitHub: Connect to Remote Chrome</title>
      <dc:creator>RemoteBrowser</dc:creator>
      <pubDate>Fri, 18 Sep 2026 03:28:23 +0000</pubDate>
      <link>https://dev.to/remotebrowser2/playwright-connectovercdp-github-connect-to-remote-chrome-1m28</link>
      <guid>https://dev.to/remotebrowser2/playwright-connectovercdp-github-connect-to-remote-chrome-1m28</guid>
      <description>&lt;h1&gt;
  
  
  Playwright connectOverCDP GitHub: Connect to Remote Chrome
&lt;/h1&gt;

&lt;p&gt;If you searched for &lt;strong&gt;playwright connectovercdp github&lt;/strong&gt;, you probably want one of two things: a working code example, or a repo that shows how to attach Playwright to a Chrome instance that is already running somewhere else. The short answer is that &lt;code&gt;browserType.connectOverCDP()&lt;/code&gt; takes a WebSocket debugger URL and returns a &lt;code&gt;Browser&lt;/code&gt; object you drive exactly like a locally launched one. The longer answer is that most GitHub examples stop at &lt;code&gt;localhost:9222&lt;/code&gt;, and that is not the part that breaks in production.&lt;/p&gt;

&lt;p&gt;This guide covers the API contract, what real GitHub examples get right and wrong, and how to point &lt;code&gt;connectOverCDP&lt;/code&gt; at a hosted Chromium session so you are not maintaining Chrome, drivers, and profiles on every worker.&lt;/p&gt;

&lt;h2&gt;
  
  
  What connectOverCDP actually does
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;connectOverCDP&lt;/code&gt; is a method on &lt;code&gt;BrowserType&lt;/code&gt; in Playwright. It opens a connection to a browser over the Chrome DevTools Protocol and returns a &lt;code&gt;Browser&lt;/code&gt; instance. From that point on, the Playwright API is the same: &lt;code&gt;browser.contexts()&lt;/code&gt;, &lt;code&gt;context.newPage()&lt;/code&gt;, &lt;code&gt;page.click()&lt;/code&gt;, and so on.&lt;/p&gt;

&lt;p&gt;The important distinction is that you are not launching a browser. You are attaching to one that already exists. That means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You do not control the browser binary version through Playwright's install step.&lt;/li&gt;
&lt;li&gt;You do not get a fresh profile unless the remote side gives you one.&lt;/li&gt;
&lt;li&gt;You inherit whatever contexts and pages are already open.&lt;/li&gt;
&lt;li&gt;Lifecycle is shared — closing the connection is not the same as killing the browser.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is why &lt;code&gt;connectOverCDP&lt;/code&gt; is the right primitive for remote and hosted browsers, and why it behaves differently from &lt;code&gt;chromium.launch()&lt;/code&gt; in ways that matter once you leave your laptop.&lt;/p&gt;

&lt;h3&gt;
  
  
  The API shape
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connectOverCDP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;endpointURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;endpointURL&lt;/code&gt; is either an HTTP URL (Playwright will discover the WebSocket URL from &lt;code&gt;/json/version&lt;/code&gt;) or a &lt;code&gt;ws://&lt;/code&gt; / &lt;code&gt;wss://&lt;/code&gt; debugger URL directly. &lt;code&gt;options&lt;/code&gt; accepts things like &lt;code&gt;headers&lt;/code&gt;, &lt;code&gt;timeout&lt;/code&gt;, and &lt;code&gt;slowMo&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A minimal working example against a remote endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Browser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BrowserContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playwright&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cdpEndpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connectOverCDP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cdpEndpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// A hosted session usually hands you one context already.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BrowserContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contexts&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newContext&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;domcontentloaded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Detach. Do not assume this kills the remote browser.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;runTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CDP_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth internalizing. First, &lt;code&gt;browser.contexts()[0]&lt;/code&gt; — a remote session often arrives with a context already created, and calling &lt;code&gt;newContext()&lt;/code&gt; blindly can leave you with an orphaned blank context. Second, &lt;code&gt;browser.close()&lt;/code&gt; on a CDP connection disconnects; whether the remote browser shuts down depends on the provider. Check that behavior before you build cleanup logic around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What GitHub examples get right and wrong
&lt;/h2&gt;

&lt;p&gt;Searching GitHub for &lt;code&gt;connectOverCDP&lt;/code&gt; returns a lot of near-identical snippets. They are useful as a starting point and misleading as a production reference. Here is the pattern you will see most often:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connectOverCDP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:9222&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That works if you launched Chrome with &lt;code&gt;--remote-debugging-port=9222&lt;/code&gt; on the same machine. It tells you nothing about authentication, TLS, session lifetime, or what happens when the endpoint is behind a load balancer.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Typical GitHub snippet&lt;/th&gt;
&lt;th&gt;Production requirement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Endpoint&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://localhost:9222&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;wss://&lt;/code&gt; URL with auth, from a session API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chrome binary&lt;/td&gt;
&lt;td&gt;Assumed installed&lt;/td&gt;
&lt;td&gt;Managed by the runtime, versioned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Profile&lt;/td&gt;
&lt;td&gt;Default user profile&lt;/td&gt;
&lt;td&gt;Isolated per session, optionally persistent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Token or signed URL in headers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lifecycle&lt;/td&gt;
&lt;td&gt;&lt;code&gt;browser.close()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Explicit session teardown via API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concurrency&lt;/td&gt;
&lt;td&gt;One browser&lt;/td&gt;
&lt;td&gt;Pooled sessions, per-session isolation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failure mode&lt;/td&gt;
&lt;td&gt;Crash&lt;/td&gt;
&lt;td&gt;Reconnect, retry, or new session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observability&lt;/td&gt;
&lt;td&gt;&lt;code&gt;console.log&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Live viewer, session logs, traces&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The gap is not the Playwright call. The gap is everything around it. If you are evaluating a GitHub repo as a template, check whether it handles the right column at all. Most do not, because the authors were demonstrating the API, not operating it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The &lt;code&gt;--remote-debugging-port&lt;/code&gt; trap
&lt;/h3&gt;

&lt;p&gt;A common GitHub pattern is to shell out to Chrome with &lt;code&gt;--remote-debugging-port=9222&lt;/code&gt; and then connect. This works locally and fails in almost every hosted environment for three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Binding.&lt;/strong&gt; Chrome binds the debug port to &lt;code&gt;127.0.0.1&lt;/code&gt; by default. Exposing it requires &lt;code&gt;--remote-debugging-address=0.0.0.0&lt;/code&gt;, which is a security decision, not a convenience flag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No auth.&lt;/strong&gt; The DevTools Protocol has no built-in authentication. Anyone who can reach the port can drive the browser. On a public network that is a full compromise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version drift.&lt;/strong&gt; The Chrome you install on a worker is not the Chrome the next worker installs. CDP surface area changes between versions.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Hosted runtimes solve this by giving you a &lt;code&gt;wss://&lt;/code&gt; endpoint with a token, a pinned Chromium build, and a session that is destroyed on teardown. That is the model to look for when you graduate from a GitHub snippet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring connectOverCDP to a hosted runtime
&lt;/h2&gt;

&lt;p&gt;The practical flow with a hosted Chromium runtime is: create a session over HTTP, receive a CDP endpoint, connect Playwright to it, run your task, then release the session. The Playwright code does not change — only where the endpoint comes from.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playwright&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;cdpUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// wss://... provided by the runtime&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createSession&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Session&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.remote-browser.dev/v1/sessions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;REMOTE_BROWSER_TOKEN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="c1"&gt;// configurable browser settings, not hardcoded fingerprints&lt;/span&gt;
      &lt;span class="na"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;persistent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;checkout-agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1440&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;900&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`session create failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;releaseSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://api.remote-browser.dev/v1/sessions/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DELETE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;REMOTE_BROWSER_TOKEN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connectOverCDP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cdpUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contexts&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newContext&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ... your task&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;releaseSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The endpoint shape and session API differ by provider. What matters is that the endpoint is authenticated, the session has a defined lifetime, and teardown is explicit. You can read more about the runtime model in &lt;a href="https://remote-browser.dev/blog/remote-browser-for-ai-agents" rel="noopener noreferrer"&gt;Remote Browser for AI agents&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why persistent profiles matter here
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;connectOverCDP&lt;/code&gt;, the profile is whatever the remote browser already has. If you want cookies, localStorage, and logged-in state to survive across sessions, the runtime has to persist them. If you want a clean slate every time, it has to isolate them. Either way, that is a runtime decision, not a Playwright one — &lt;code&gt;connectOverCDP&lt;/code&gt; gives you no profile control beyond what the endpoint exposes.&lt;/p&gt;

&lt;p&gt;For agents that log in once and then run many tasks, persistent profiles cut a large amount of redundant work. For agents that must not leak state between tenants, isolation is the requirement. Both are configuration, not code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production criteria for a CDP endpoint
&lt;/h2&gt;

&lt;p&gt;Once you move past GitHub snippets, evaluate the endpoint against these criteria:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Transport.&lt;/strong&gt; &lt;code&gt;wss://&lt;/code&gt; with TLS, not plain &lt;code&gt;ws://&lt;/code&gt;. The DevTools Protocol is fully privileged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth.&lt;/strong&gt; A token or signed URL per session. Rotate it. Do not share one endpoint across tenants.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolation.&lt;/strong&gt; One browser process per session, or at minimum one context per tenant with no shared storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version pinning.&lt;/strong&gt; A known Chromium build so CDP behavior is stable across deploys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lifecycle.&lt;/strong&gt; A session ID you can query and destroy. &lt;code&gt;browser.close()&lt;/code&gt; alone is not enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability.&lt;/strong&gt; A live viewer or session recording so you can debug a failed run without reproducing it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network controls.&lt;/strong&gt; Proxy configuration and browser settings you can set per session, since IP reputation affects success rates on protected sites.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconnect semantics.&lt;/strong&gt; What happens if the WebSocket drops mid-task. A good runtime lets you reconnect to the same session.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a provider cannot answer these, you are back to running Chrome on your own workers — which is fine, but then you own the &lt;code&gt;--remote-debugging-port&lt;/code&gt; problem described above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common failure modes and how to debug them
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;connectOverCDP&lt;/code&gt; times out.&lt;/strong&gt; Usually the endpoint is reachable but the WebSocket upgrade is blocked by a proxy or firewall. Test with &lt;code&gt;curl -i &amp;lt;http-endpoint&amp;gt;/json/version&lt;/code&gt; first; if that returns JSON, the HTTP side is fine and the issue is the upgrade path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Connection succeeds but &lt;code&gt;browser.contexts()&lt;/code&gt; is empty.&lt;/strong&gt; The remote browser has no context yet. Call &lt;code&gt;newContext()&lt;/code&gt;. Do not assume a context exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pages close unexpectedly.&lt;/strong&gt; Something else is driving the same browser. With CDP, multiple clients can attach. If your runtime allows it, avoid sharing a session across processes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Navigation hangs.&lt;/strong&gt; Check whether the remote browser has network access to the target. A hosted session may have different egress rules than your local machine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;browser.close()&lt;/code&gt; does not stop billing.&lt;/strong&gt; It should not — closing a CDP connection is a detach. Release the session through the provider's API. See &lt;a href="https://remote-browser.dev/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt; for how sessions are metered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version mismatch errors on newer CDP methods.&lt;/strong&gt; The remote Chromium is older than your Playwright version expects. Pin both, or use a runtime that tracks recent Chromium releases.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use connectOverCDP vs launch
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;chromium.launch()&lt;/code&gt; when the browser runs on the same machine as your code and you control the environment. Use &lt;code&gt;connectOverCDP&lt;/code&gt; when the browser runs somewhere else — a container, a VM, or a hosted runtime.&lt;/p&gt;

&lt;p&gt;The trade-off is control versus operational cost. &lt;code&gt;launch()&lt;/code&gt; gives you full control over flags, binary, and profile, at the cost of installing and maintaining Chrome everywhere your code runs. &lt;code&gt;connectOverCDP&lt;/code&gt; gives you a stable endpoint and offloads that maintenance, at the cost of depending on the endpoint's behavior and lifecycle.&lt;/p&gt;

&lt;p&gt;For AI agents and long-running automation, the second model usually wins because the hard part is not the Playwright call — it is keeping browsers alive, isolated, and debuggable across many concurrent tasks. That is the problem a hosted runtime solves. You can see the broader picture in &lt;a href="https://remote-browser.dev/blog/remote-browser-online" rel="noopener noreferrer"&gt;Remote Browser online&lt;/a&gt; and &lt;a href="https://remote-browser.dev/blog/remote-control-browser" rel="noopener noreferrer"&gt;remote control browser&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical checklist
&lt;/h2&gt;

&lt;p&gt;Before you ship a &lt;code&gt;connectOverCDP&lt;/code&gt; integration:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Confirm the endpoint is &lt;code&gt;wss://&lt;/code&gt; and authenticated.&lt;/li&gt;
&lt;li&gt;Handle the case where &lt;code&gt;browser.contexts()&lt;/code&gt; is empty.&lt;/li&gt;
&lt;li&gt;Treat &lt;code&gt;browser.close()&lt;/code&gt; as a detach, not a teardown.&lt;/li&gt;
&lt;li&gt;Release sessions explicitly through the provider API.&lt;/li&gt;
&lt;li&gt;Pin your Playwright version and know the remote Chromium version.&lt;/li&gt;
&lt;li&gt;Add a reconnect path for dropped WebSockets.&lt;/li&gt;
&lt;li&gt;Log the session ID with every task so failures are traceable.&lt;/li&gt;
&lt;li&gt;Test against the actual target sites, not just &lt;code&gt;example.com&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Playwright API is the easy part. The runtime is where reliability is won or lost. For the API reference, see the &lt;a href="https://playwright.dev/docs/api/class-browsertype#browser-type-connect-over-cdp" rel="noopener noreferrer"&gt;Playwright CDP documentation&lt;/a&gt; and the &lt;a href="https://chromedevtools.github.io/devtools-protocol/" rel="noopener noreferrer"&gt;Chrome DevTools Protocol spec&lt;/a&gt;. For the runtime side, start with the &lt;a href="https://remote-browser.dev/documentation" rel="noopener noreferrer"&gt;documentation&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>playwright</category>
      <category>cdp</category>
      <category>remotebrowser</category>
      <category>browserautomation</category>
    </item>
    <item>
      <title>Playwright MCP Connect to Existing Browser: A Practical Guide</title>
      <dc:creator>RemoteBrowser</dc:creator>
      <pubDate>Thu, 17 Sep 2026 03:35:19 +0000</pubDate>
      <link>https://dev.to/remotebrowser2/playwright-mcp-connect-to-existing-browser-a-practical-guide-21db</link>
      <guid>https://dev.to/remotebrowser2/playwright-mcp-connect-to-existing-browser-a-practical-guide-21db</guid>
      <description>&lt;h1&gt;
  
  
  Playwright MCP Connect to Existing Browser: A Practical Guide
&lt;/h1&gt;

&lt;p&gt;If you want Playwright MCP to connect to an existing browser instead of launching its own, the answer is CDP. The Playwright MCP server drives a browser over the Chrome DevTools Protocol, and &lt;code&gt;browserType.connectOverCDP()&lt;/code&gt; is the entry point that lets it attach to a Chrome or Chromium instance that is already running — locally, on another host, or inside a hosted runtime. This guide covers how the connection actually works, what breaks in practice, and how to point MCP at a remote Chromium session you don't have to babysit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "connect to an existing browser" means for Playwright MCP
&lt;/h2&gt;

&lt;p&gt;Playwright MCP is a Model Context Protocol server that exposes browser actions — navigate, click, type, snapshot — as tools an LLM can call. By default it launches its own Chromium process. That default is fine for a laptop demo and wrong for almost everything else: the browser dies when the process exits, the profile is throwaway, and you can't see what the agent is doing.&lt;/p&gt;

&lt;p&gt;Connecting to an existing browser changes the ownership model. Instead of MCP spawning Chromium, you give it a WebSocket endpoint that a running browser is already listening on. MCP attaches, takes over the page context, and drives it. The browser keeps running whether or not MCP is alive.&lt;/p&gt;

&lt;p&gt;There are two ways to get that endpoint:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Launch Chrome yourself with &lt;code&gt;--remote-debugging-port&lt;/code&gt;.&lt;/strong&gt; You control the flags, the profile directory, and the lifecycle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a hosted runtime that exposes a CDP endpoint.&lt;/strong&gt; You get a URL, paste it into your config, and the runtime handles process management, isolation, and cleanup.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second option is what most production agent stacks converge on, because the first one means you're now operating Chrome fleets.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the CDP handshake works
&lt;/h2&gt;

&lt;p&gt;When Playwright calls &lt;code&gt;connectOverCDP()&lt;/code&gt;, it opens a WebSocket to the browser's DevTools endpoint, typically &lt;code&gt;ws://host:port/devtools/browser/&amp;lt;id&amp;gt;&lt;/code&gt;. Over that socket it speaks the &lt;a href="https://chromedevtools.github.io/devtools-protocol/" rel="noopener noreferrer"&gt;Chrome DevTools Protocol&lt;/a&gt; — the same protocol Chrome DevTools itself uses. Playwright then wraps the connection in its normal &lt;code&gt;Browser&lt;/code&gt; object, so &lt;code&gt;browser.contexts()&lt;/code&gt;, &lt;code&gt;page.goto()&lt;/code&gt;, and locators all work as usual.&lt;/p&gt;

&lt;p&gt;Two details matter and are easy to get wrong:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;connectOverCDP&lt;/code&gt; is Chromium-only.&lt;/strong&gt; Firefox and WebKit do not implement the CDP surface Playwright needs here. If your MCP config points at a Firefox endpoint, the connection will fail or behave unpredictably. Use Chromium.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You attach to existing contexts, not new ones.&lt;/strong&gt; With &lt;code&gt;connectOverCDP&lt;/code&gt;, &lt;code&gt;browser.newContext()&lt;/code&gt; is not available in the way it is for a launched browser. You work with &lt;code&gt;browser.contexts()[0]&lt;/code&gt; or create pages on the default context. Code that assumes a fresh context per task will need adjusting.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The official Playwright reference for this method is &lt;code&gt;browserType.connectOverCDP()&lt;/code&gt; in the &lt;a href="https://playwright.dev/docs/api/class-browsertype#browser-type-connect-over-cdp" rel="noopener noreferrer"&gt;Playwright API docs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minimal TypeScript example
&lt;/h2&gt;

&lt;p&gt;Here is the shape of a connection you can adapt for an MCP server or a standalone script. It assumes you already have a CDP WebSocket URL.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Browser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BrowserContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playwright&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CDP_ENDPOINT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CDP_WS_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ws://.../devtools/browser/&amp;lt;id&amp;gt;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;attach&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Browser&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BrowserContext&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connectOverCDP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CDP_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// connectOverCDP attaches to existing contexts; do not call newContext().&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contexts&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;No browser context available on the CDP endpoint&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="c1"&gt;// Confirm the connection is live before handing it to an agent.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;about:blank&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;attach&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="c1"&gt;// Close the Playwright connection, not the remote browser.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CDP attach failed:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important line is &lt;code&gt;browser.close()&lt;/code&gt;. Over CDP, that closes the Playwright connection. Whether the underlying browser process terminates depends on how it was started — a hosted runtime typically keeps the session alive until you explicitly release it, which is what you want when a second tool or a human viewer needs to stay attached.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuring Playwright MCP to use an existing browser
&lt;/h2&gt;

&lt;p&gt;Playwright MCP accepts a CDP endpoint through its configuration. The exact flag name depends on your MCP client and server version, but the pattern is consistent: you supply a WebSocket URL instead of letting the server launch Chromium. A typical config block looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"playwright"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"@playwright/mcp@latest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"--cdp-endpoint"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"wss://your-runtime.example/devtools/browser/abc123"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the current flag against your installed server version before you commit it to a repo — MCP server options have moved around, and a stale flag fails silently in some clients. If you're running against a hosted runtime, the endpoint is usually handed to you per session, so the config is generated at runtime rather than hardcoded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local Chrome vs hosted Chromium for MCP
&lt;/h2&gt;

&lt;p&gt;The decision is not really about Playwright. It's about who owns the browser process.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Local Chrome + &lt;code&gt;--remote-debugging-port&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Hosted Chromium runtime&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Setup&lt;/td&gt;
&lt;td&gt;Manual flags, profile dir, port management&lt;/td&gt;
&lt;td&gt;Paste a CDP URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lifecycle&lt;/td&gt;
&lt;td&gt;Dies with your shell or machine&lt;/td&gt;
&lt;td&gt;Survives client restarts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Isolation&lt;/td&gt;
&lt;td&gt;Shared profile, shared cookies&lt;/td&gt;
&lt;td&gt;Session-scoped, isolated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concurrency&lt;/td&gt;
&lt;td&gt;One browser, one port, contention&lt;/td&gt;
&lt;td&gt;Multiple sessions, separate endpoints&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observability&lt;/td&gt;
&lt;td&gt;DevTools on localhost only&lt;/td&gt;
&lt;td&gt;Live viewer, remote access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proxies / network&lt;/td&gt;
&lt;td&gt;Your machine's IP&lt;/td&gt;
&lt;td&gt;Configurable per session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scaling&lt;/td&gt;
&lt;td&gt;You operate the fleet&lt;/td&gt;
&lt;td&gt;Runtime operates the fleet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debugging&lt;/td&gt;
&lt;td&gt;Attach DevTools locally&lt;/td&gt;
&lt;td&gt;Attach DevTools or use the viewer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Local Chrome is the right call when you're debugging a selector or reproducing a bug on your own machine. It stops being the right call the moment the agent needs to run unattended, run more than one session at a time, or run from a machine that isn't yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common failure modes
&lt;/h2&gt;

&lt;p&gt;Most "Playwright MCP won't connect" reports trace back to a small set of causes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The endpoint is an HTTP URL, not a WebSocket URL.&lt;/strong&gt; &lt;code&gt;connectOverCDP&lt;/code&gt; wants &lt;code&gt;ws://&lt;/code&gt; or &lt;code&gt;wss://&lt;/code&gt;. An &lt;code&gt;http://host:9222&lt;/code&gt; value will fail. Fetch &lt;code&gt;http://host:9222/json/version&lt;/code&gt; and read the &lt;code&gt;webSocketDebuggerUrl&lt;/code&gt; field if you need to discover it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chrome wasn't started with remote debugging enabled.&lt;/strong&gt; Without &lt;code&gt;--remote-debugging-port&lt;/code&gt;, there is no endpoint to connect to. If you're launching Chrome yourself, that flag is mandatory. If you're using a hosted runtime, the endpoint is created for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The browser is bound to localhost.&lt;/strong&gt; A CDP port on &lt;code&gt;127.0.0.1&lt;/code&gt; is unreachable from another container or host. Hosted runtimes solve this by exposing a routable endpoint; self-hosted setups need &lt;code&gt;--remote-debugging-address=0.0.0.0&lt;/code&gt; plus network controls, which is a security decision, not just a config tweak.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The connection drops mid-session.&lt;/strong&gt; CDP WebSockets are long-lived and fragile across networks. Production code should treat a dropped connection as recoverable: reconnect, re-resolve the page, and continue. Don't assume the socket outlives the task.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You called &lt;code&gt;newContext()&lt;/code&gt;.&lt;/strong&gt; As noted above, &lt;code&gt;connectOverCDP&lt;/code&gt; attaches to existing contexts. Code ported from a &lt;code&gt;launch()&lt;/code&gt; flow will throw here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version skew.&lt;/strong&gt; Playwright's CDP client tracks a specific protocol surface. A browser far ahead of your Playwright version can expose methods Playwright doesn't expect. Pin both sides.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why hosted Chromium fits MCP better than local Chrome
&lt;/h2&gt;

&lt;p&gt;MCP servers are usually spawned by a client — an IDE, a chat app, an agent framework — and that client restarts constantly. If the browser is a child process of the MCP server, every restart destroys the session: cookies gone, login gone, half-finished task gone.&lt;/p&gt;

&lt;p&gt;Decoupling the browser from the MCP process fixes this. The runtime holds the Chromium session; MCP holds a connection to it. Restart the client, reconnect to the same endpoint, and the page is still where you left it. That's the same architectural argument behind &lt;a href="https://remote-browser.dev/blog/remote-browser-for-ai-agents" rel="noopener noreferrer"&gt;remote browsers for AI agents&lt;/a&gt;, and it applies whether your agent is a coding assistant or a scheduled scraper.&lt;/p&gt;

&lt;p&gt;The other practical win is the live viewer. When an agent is driving a browser over CDP, you can't see anything unless you attach DevTools. A hosted runtime that exposes a viewer lets you watch the session in a browser tab while the agent works — which turns "the agent did something weird" from a log-reading exercise into a thirty-second observation. This is the same reason &lt;a href="https://remote-browser.dev/blog/remote-control-browser" rel="noopener noreferrer"&gt;remote control of a browser&lt;/a&gt; matters for debugging agent runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production criteria before you commit
&lt;/h2&gt;

&lt;p&gt;If you're choosing between self-hosted Chrome and a hosted runtime for MCP, evaluate against these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Session persistence.&lt;/strong&gt; Does the browser survive an MCP client restart? If not, you'll rebuild auth state constantly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolation.&lt;/strong&gt; Can two concurrent agents share a browser without leaking cookies or storage between them? They shouldn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Endpoint stability.&lt;/strong&gt; Is the CDP URL stable for the life of a session, or does it rotate in ways your reconnect logic must handle?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network controls.&lt;/strong&gt; Can you route traffic through a specific proxy per session? Many sites behave differently by IP, and this is a per-session concern, not a global one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability.&lt;/strong&gt; Can a human watch the session without SSH access to the host?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cleanup guarantees.&lt;/strong&gt; When a task ends or crashes, does the session get released? Orphaned Chromium processes are the default failure mode of self-hosted setups.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost model.&lt;/strong&gt; Browser time is metered differently across providers. Check &lt;a href="https://remote-browser.dev/pricing" rel="noopener noreferrer"&gt;current pricing&lt;/a&gt; rather than assuming a per-task or per-hour model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are Playwright problems. They're runtime problems, and they're the reason the "connect to an existing browser" pattern exists in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  A reasonable default
&lt;/h2&gt;

&lt;p&gt;For local development, launch Chrome with &lt;code&gt;--remote-debugging-port=9222&lt;/code&gt;, point Playwright MCP at the discovered WebSocket URL, and iterate. You get fast feedback and full DevTools.&lt;/p&gt;

&lt;p&gt;For anything that runs unattended, move the browser off your machine. Start a hosted Chromium session, take the CDP endpoint it returns, and configure MCP to attach. Keep the reconnect logic, keep the &lt;code&gt;browser.close()&lt;/code&gt; semantics straight, and treat the session as a resource with a lifecycle rather than a process your script happens to own.&lt;/p&gt;

&lt;p&gt;The connection mechanics are the same in both cases — a WebSocket, a protocol, a &lt;code&gt;Browser&lt;/code&gt; object. What changes is who's responsible when the browser dies at 3 a.m. That's the part worth getting right before you ship.&lt;/p&gt;

</description>
      <category>playwright</category>
      <category>mcp</category>
      <category>cdp</category>
      <category>browserautomation</category>
    </item>
    <item>
      <title>Puppeteer In Browser: Run Puppeteer Against Hosted Chromium</title>
      <dc:creator>RemoteBrowser</dc:creator>
      <pubDate>Thu, 17 Sep 2026 03:35:18 +0000</pubDate>
      <link>https://dev.to/remotebrowser2/puppeteer-in-browser-run-puppeteer-against-hosted-chromium-2hc3</link>
      <guid>https://dev.to/remotebrowser2/puppeteer-in-browser-run-puppeteer-against-hosted-chromium-2hc3</guid>
      <description>&lt;h1&gt;
  
  
  Puppeteer In Browser: Run Puppeteer Against Hosted Chromium
&lt;/h1&gt;

&lt;p&gt;Running Puppeteer in browser environments usually means one of two things: you want Puppeteer to drive a browser that isn't on your machine, or you want to run Puppeteer itself somewhere other than your laptop. Both cases come down to the same mechanism — the Chrome DevTools Protocol (CDP). Puppeteer talks to Chrome over a WebSocket, and that WebSocket doesn't have to point at &lt;code&gt;localhost&lt;/code&gt;. Point it at a hosted Chromium session and your script runs unchanged.&lt;/p&gt;

&lt;p&gt;This guide covers what "Puppeteer in browser" actually means in practice, how &lt;code&gt;puppeteer.connect()&lt;/code&gt; differs from &lt;code&gt;puppeteer.launch()&lt;/code&gt;, what breaks when you move to remote Chrome, and how to structure a production setup on a hosted runtime like Remote Browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Puppeteer in browser" actually means
&lt;/h2&gt;

&lt;p&gt;Puppeteer is a Node library that controls Chrome or Chromium. It has two entry points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;puppeteer.launch()&lt;/code&gt; — spawns a local Chrome process and connects to it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;puppeteer.connect({ browserWSEndpoint })&lt;/code&gt; — attaches to a Chrome instance that is already running, anywhere reachable over the network.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second form is what makes Puppeteer usable in browser-adjacent contexts: serverless functions, containers without a bundled Chrome, CI runners, and agent runtimes that need a browser they don't own. You're not embedding Puppeteer &lt;em&gt;inside&lt;/em&gt; a browser tab — you're running Puppeteer as a client and pointing it at a browser that lives elsewhere.&lt;/p&gt;

&lt;p&gt;That distinction matters because a lot of confusion comes from conflating three separate problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No local Chrome.&lt;/strong&gt; You don't want to run &lt;code&gt;puppeteer browsers install chrome&lt;/code&gt; on every worker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No persistent state.&lt;/strong&gt; You need cookies, logins, and localStorage to survive across runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No isolation.&lt;/strong&gt; You need each job to get its own browser context so sessions don't bleed into each other.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A hosted Chromium runtime addresses all three. Puppeteer stays your client library; the browser becomes infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  connect() vs launch(): the trade-off
&lt;/h2&gt;

&lt;p&gt;If you've only ever used &lt;code&gt;puppeteer.launch()&lt;/code&gt;, the mental model shift is small but the operational consequences are large.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;puppeteer.launch()&lt;/code&gt; (local)&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;puppeteer.connect()&lt;/code&gt; (remote)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chrome binary&lt;/td&gt;
&lt;td&gt;Installed per machine/container&lt;/td&gt;
&lt;td&gt;Managed by the runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Startup cost&lt;/td&gt;
&lt;td&gt;Process spawn + cold start per run&lt;/td&gt;
&lt;td&gt;Attach to a warm session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session persistence&lt;/td&gt;
&lt;td&gt;Manual userDataDir management&lt;/td&gt;
&lt;td&gt;Profile handled by the runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scaling&lt;/td&gt;
&lt;td&gt;Bound by host CPU/RAM&lt;/td&gt;
&lt;td&gt;Bound by your session quota&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debugging&lt;/td&gt;
&lt;td&gt;Local DevTools&lt;/td&gt;
&lt;td&gt;Live viewer + CDP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Version drift&lt;/td&gt;
&lt;td&gt;You pin and patch Chrome&lt;/td&gt;
&lt;td&gt;Runtime pins the build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network egress&lt;/td&gt;
&lt;td&gt;Your IP&lt;/td&gt;
&lt;td&gt;Configurable proxy settings&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The trade-off is control versus operational burden. Local &lt;code&gt;launch()&lt;/code&gt; gives you total control over flags, extensions, and the exact Chrome build — and you pay for it with install scripts, container images, and a Chrome version you have to keep patched. Remote &lt;code&gt;connect()&lt;/code&gt; gives up some of that control in exchange for not managing any of it.&lt;/p&gt;

&lt;p&gt;For a single developer running one script, local launch is fine. For anything that runs on a schedule, across multiple workers, or inside an agent loop, the install-and-patch cycle becomes the dominant cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting Puppeteer to hosted Chromium
&lt;/h2&gt;

&lt;p&gt;The connection flow is the same whether you're pointing at a local Chrome started with &lt;code&gt;--remote-debugging-port&lt;/code&gt; or a hosted session. You need a WebSocket debugger URL, and you pass it to &lt;code&gt;connect()&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Browser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;puppeteer-core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// The runtime returns a CDP WebSocket endpoint for the session.&lt;/span&gt;
&lt;span class="c1"&gt;// Treat this like a credential — it grants full control of the browser.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browserWSEndpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;REMOTE_BROWSER_WS_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runTask&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Browser&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;browserWSEndpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="c1"&gt;// Reuse the session's existing tab instead of opening a new one.&lt;/span&gt;
      &lt;span class="na"&gt;defaultViewport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;domcontentloaded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Create an isolated context for a second job on the same browser.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createBrowserContext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isolated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;isolated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com/account&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Disconnect, do not close — the runtime owns the browser lifecycle.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;runTask&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details in that snippet are worth calling out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;puppeteer-core&lt;/code&gt;, not &lt;code&gt;puppeteer&lt;/code&gt;.&lt;/strong&gt; The full &lt;code&gt;puppeteer&lt;/code&gt; package downloads a Chrome build on install. Since you're connecting to a remote browser, you don't need it. &lt;code&gt;puppeteer-core&lt;/code&gt; is the client-only package and skips the download entirely — which is exactly the &lt;code&gt;puppeteer browsers install chrome&lt;/code&gt; step you're trying to avoid.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Call &lt;code&gt;disconnect()&lt;/code&gt;, not &lt;code&gt;close()&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;browser.close()&lt;/code&gt; terminates the Chrome process. On a hosted runtime, that's the runtime's job. Disconnecting releases your client connection and lets the session be reclaimed according to the runtime's policy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;createBrowserContext()&lt;/code&gt; for isolation.&lt;/strong&gt; Multiple pages in the same context share cookies and storage. If you're running concurrent jobs, give each one its own context so a login in one task can't leak into another.&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks when you go remote
&lt;/h2&gt;

&lt;p&gt;Moving from local to remote Chrome surfaces a handful of failure modes that don't exist locally. Knowing them ahead of time saves a lot of debugging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version mismatch between Puppeteer and Chrome.&lt;/strong&gt; Puppeteer's protocol bindings are generated against a specific Chrome version. A hosted runtime pins its own Chromium build. If the two drift far enough apart, some CDP domains behave unexpectedly. Pin your &lt;code&gt;puppeteer-core&lt;/code&gt; version and check the runtime's Chromium version rather than assuming &lt;code&gt;latest&lt;/code&gt; works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--no-sandbox&lt;/code&gt; and launch flags.&lt;/strong&gt; Flags like &lt;code&gt;--no-sandbox&lt;/code&gt;, &lt;code&gt;--disable-dev-shm-usage&lt;/code&gt;, and &lt;code&gt;--window-size&lt;/code&gt; are passed at launch. When you &lt;code&gt;connect()&lt;/code&gt;, you can't pass them — the browser is already running. If you need specific launch arguments, they have to be configured on the runtime side, not in your Puppeteer code. This is the single most common source of "it worked locally" bugs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timeouts on &lt;code&gt;goto&lt;/code&gt;.&lt;/strong&gt; Remote navigation includes network latency between your client and the browser, plus the browser's own page load. Set explicit timeouts rather than relying on defaults, and prefer &lt;code&gt;domcontentloaded&lt;/code&gt; over &lt;code&gt;networkidle0&lt;/code&gt; for pages with long-polling or analytics beacons that never fully settle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File downloads.&lt;/strong&gt; &lt;code&gt;page.waitForEvent('download')&lt;/code&gt; works, but the file lands on the &lt;em&gt;remote&lt;/em&gt; filesystem. You need a way to retrieve it — either a runtime-provided download API or a shared object store. Don't assume the path is local.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stale sessions.&lt;/strong&gt; If your process crashes without disconnecting, the session may linger until the runtime's idle timeout reclaims it. Wrap your work in &lt;code&gt;try/finally&lt;/code&gt; and always disconnect.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use Puppeteer vs Playwright vs raw CDP
&lt;/h2&gt;

&lt;p&gt;Puppeteer isn't the only client for a hosted Chromium session, and it isn't always the right one.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Client&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Puppeteer&lt;/td&gt;
&lt;td&gt;Chrome-only automation, existing Puppeteer codebases&lt;/td&gt;
&lt;td&gt;Mature, Chrome-focused, &lt;code&gt;puppeteer-core&lt;/code&gt; avoids downloads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Playwright&lt;/td&gt;
&lt;td&gt;Cross-browser, richer auto-waiting, test runners&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;connectOverCDP&lt;/code&gt; for Chromium; broader API surface&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raw CDP&lt;/td&gt;
&lt;td&gt;Fine-grained control, custom protocols, non-Node languages&lt;/td&gt;
&lt;td&gt;No abstraction — you manage sessions and targets yourself&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your codebase is already Puppeteer, there's no reason to rewrite it just to go remote. If you're starting fresh and need Firefox or WebKit, Playwright is the better fit — see &lt;a href="https://playwright.dev/docs/api/class-browsertype#browser-type-connect-over-cdp" rel="noopener noreferrer"&gt;Playwright connectOverCDP&lt;/a&gt; for the equivalent attach flow. And if you're building an agent that needs to inspect network traffic or manipulate the protocol directly, raw CDP over a WebSocket is the lowest-level option.&lt;/p&gt;

&lt;p&gt;For agent workloads specifically, the client library is usually the least interesting decision. What matters is whether the runtime gives you persistent profiles, session isolation, and a way to observe what the agent is doing. That's covered in more depth in &lt;a href="https://remote-browser.dev/blog/remote-browser-for-ai-agents" rel="noopener noreferrer"&gt;Remote Browser for AI agents&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production criteria for a hosted Puppeteer runtime
&lt;/h2&gt;

&lt;p&gt;If you're evaluating runtimes to point Puppeteer at, these are the questions that actually determine whether it works in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does it expose a CDP WebSocket endpoint?&lt;/strong&gt; This is non-negotiable for Puppeteer. Some services only offer a REST API or a proprietary SDK. If there's no &lt;code&gt;browserWSEndpoint&lt;/code&gt;, &lt;code&gt;puppeteer.connect()&lt;/code&gt; isn't an option.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How are sessions isolated?&lt;/strong&gt; Separate browser processes are stronger than separate contexts, which are stronger than separate tabs. Ask which one you get, because it determines whether a crash in one job can take down another.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens to state between sessions?&lt;/strong&gt; Persistent profiles let you keep logins and cookies across runs. Ephemeral sessions start clean every time. Both are valid — but you need to know which you're getting, because it changes how you handle authentication.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can you observe a running session?&lt;/strong&gt; A live viewer that streams the browser's screen is the difference between debugging in minutes and debugging in hours. Without it, a failed selector is just a stack trace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the network egress story?&lt;/strong&gt; Proxies, IP reputation, and geographic location all affect whether a site serves you a real page or a challenge. The runtime should let you configure this rather than forcing a single shared egress.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How is usage metered?&lt;/strong&gt; Browser time, network traffic, and session count are the usual axes. Check &lt;a href="https://remote-browser.dev/pricing" rel="noopener noreferrer"&gt;pricing&lt;/a&gt; for the current model rather than assuming a flat rate.&lt;/p&gt;

&lt;p&gt;Remote Browser exposes CDP endpoints for hosted Chromium sessions, so Puppeteer, Playwright, and Selenium clients all attach the same way. Sessions support persistent profiles, configurable browser settings, and a live viewer for debugging. The &lt;a href="https://remote-browser.dev/documentation" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; covers the connection flow end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical migration path
&lt;/h2&gt;

&lt;p&gt;If you have a working local Puppeteer script and want to move it to a hosted runtime, the sequence is short.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Swap &lt;code&gt;puppeteer&lt;/code&gt; for &lt;code&gt;puppeteer-core&lt;/code&gt;.&lt;/strong&gt; Remove the bundled Chrome download. Your code doesn't change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replace &lt;code&gt;launch()&lt;/code&gt; with &lt;code&gt;connect()&lt;/code&gt;.&lt;/strong&gt; Pass the runtime's WebSocket endpoint. Move any launch flags you were using into runtime configuration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add explicit timeouts.&lt;/strong&gt; Remote navigation has more latency than local. Set &lt;code&gt;timeout&lt;/code&gt; on &lt;code&gt;goto&lt;/code&gt;, &lt;code&gt;waitForSelector&lt;/code&gt;, and &lt;code&gt;waitForFunction&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrap in &lt;code&gt;try/finally&lt;/code&gt; with &lt;code&gt;disconnect()&lt;/code&gt;.&lt;/strong&gt; Never leave a session dangling on a crash path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add context isolation for concurrent jobs.&lt;/strong&gt; One &lt;code&gt;createBrowserContext()&lt;/code&gt; per job if you're running in parallel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify downloads and file paths.&lt;/strong&gt; Anything that touched the local filesystem needs a remote-aware replacement.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most scripts migrate in an afternoon. The parts that take longer are the ones that depended on local Chrome flags or local filesystem access — those need a runtime-side equivalent, not a code change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits
&lt;/h2&gt;

&lt;p&gt;Puppeteer in browser contexts is really a question about where Chrome lives. Once you accept that the browser can be remote and Puppeteer is just a CDP client, the architecture simplifies: your code stays portable, your workers stay thin, and the browser becomes a managed resource with its own lifecycle, profiles, and observability.&lt;/p&gt;

&lt;p&gt;That's the same shift that makes agent workloads tractable. An AI agent driving a browser needs the same things a test suite does — isolation, persistence, and a way to see what happened — plus a runtime that can hold a session open across many steps. If you want to see what that looks like without managing Chrome yourself, start with &lt;a href="https://remote-browser.dev/blog/remote-browser-online" rel="noopener noreferrer"&gt;Remote Browser online&lt;/a&gt; or connect a session directly from the &lt;a href="https://remote-browser.dev/documentation" rel="noopener noreferrer"&gt;documentation&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>puppeteer</category>
      <category>cdp</category>
      <category>browserautomation</category>
      <category>hostedchromium</category>
    </item>
    <item>
      <title>Playwright Chrome Extension: What It Is and What to Use Instead</title>
      <dc:creator>RemoteBrowser</dc:creator>
      <pubDate>Wed, 16 Sep 2026 03:50:47 +0000</pubDate>
      <link>https://dev.to/remotebrowser2/playwright-chrome-extension-what-it-is-and-what-to-use-instead-3d6k</link>
      <guid>https://dev.to/remotebrowser2/playwright-chrome-extension-what-it-is-and-what-to-use-instead-3d6k</guid>
      <description>&lt;h1&gt;
  
  
  Playwright Chrome Extension: What It Is and What to Use Instead
&lt;/h1&gt;

&lt;p&gt;If you searched for a "Playwright Chrome Extension," you're probably trying to do one of two things: drive your existing Chrome browser with Playwright, or find a browser extension that adds Playwright-style control to Chrome. There is no official Playwright Chrome Extension on the Chrome Web Store. Playwright is a Node/Python/.NET/Java library that launches or connects to browsers over the Chrome DevTools Protocol (CDP) — it does not ship as a browser add-on. What you actually want is one of three things: &lt;code&gt;connectOverCDP&lt;/code&gt; to an existing Chrome instance, a remote browser runtime you connect to over CDP, or a third-party extension that wraps CDP for you. This guide covers all three, with the trade-offs that matter in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why there's no official Playwright Chrome Extension
&lt;/h2&gt;

&lt;p&gt;Playwright's architecture is deliberately outside the browser. It speaks CDP (for Chromium) and its own protocol (for Firefox and WebKit) from a driver process. That design gives it capabilities a browser extension can't match: multi-context isolation, network interception at the protocol layer, tracing, and deterministic waits. A Chrome extension runs inside a single browser profile, is subject to extension permission limits, and cannot spawn isolated contexts.&lt;/p&gt;

&lt;p&gt;So when people say "Playwright Chrome Extension," they usually mean one of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Connect Playwright to my running Chrome&lt;/strong&gt; — via &lt;code&gt;chromium.connectOverCDP()&lt;/code&gt; against a Chrome launched with &lt;code&gt;--remote-debugging-port&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A remote browser I can drive with Playwright&lt;/strong&gt; — a hosted Chromium session exposing a CDP endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Chrome extension that automates the browser&lt;/strong&gt; — e.g. recorder-style tools, which are a different category entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want the conceptual background on why agents and test harnesses need a dedicated runtime rather than a local browser, see &lt;a href="https://remote-browser.dev/blog/remote-browser-for-ai-agents" rel="noopener noreferrer"&gt;Remote Browser for AI Agents&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 1: Connect Playwright to your existing Chrome via CDP
&lt;/h2&gt;

&lt;p&gt;This is the closest thing to a "Playwright Chrome Extension" workflow. You launch Chrome with a debugging port, then attach Playwright to it. Your existing cookies, extensions, and logged-in sessions stay intact.&lt;/p&gt;

&lt;h3&gt;
  
  
  Launch Chrome with a debugging port
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# macOS&lt;/span&gt;
/Applications/Google&lt;span class="se"&gt;\ &lt;/span&gt;Chrome.app/Contents/MacOS/Google&lt;span class="se"&gt;\ &lt;/span&gt;Chrome &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--remote-debugging-port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;9222 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-data-dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/chrome-playwright-profile

&lt;span class="c"&gt;# Linux&lt;/span&gt;
google-chrome &lt;span class="nt"&gt;--remote-debugging-port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;9222 &lt;span class="nt"&gt;--user-data-dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/chrome-playwright-profile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--user-data-dir&lt;/code&gt; flag matters. Without it, Chrome may refuse to open the debugging port if another Chrome instance is already running with the default profile.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attach with connectOverCDP
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Browser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BrowserContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playwright&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;attachToChrome&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// connectOverCDP attaches to an already-running Chromium/Chrome.&lt;/span&gt;
  &lt;span class="c1"&gt;// It does NOT launch a new browser.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connectOverCDP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://127.0.0.1:9222&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Existing contexts are exposed; there is no default context to create.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;contexts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BrowserContext&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contexts&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BrowserContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;contexts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newContext&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="c1"&gt;// Do NOT call browser.close() — that would kill the user's Chrome.&lt;/span&gt;
  &lt;span class="c1"&gt;// Instead, disconnect the Playwright client:&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;attachToChrome&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CDP attach failed:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things trip people up here. First, &lt;code&gt;connectOverCDP&lt;/code&gt; is Chromium-only — it does not work with Firefox or WebKit. Second, &lt;code&gt;browser.close()&lt;/code&gt; on a CDP-attached browser closes the underlying browser, not just the connection. If you're attaching to a user's Chrome, you want to disconnect without killing it. The Playwright docs on &lt;a href="https://playwright.dev/docs/api/class-browsertype#browser-type-connect-over-cdp" rel="noopener noreferrer"&gt;BrowserType.connectOverCDP&lt;/a&gt; cover the exact semantics.&lt;/p&gt;

&lt;h3&gt;
  
  
  When this approach breaks down
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CI and containers&lt;/strong&gt; — there's no "existing Chrome" to attach to unless you launch one first, at which point you've just reimplemented &lt;code&gt;launch()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrency&lt;/strong&gt; — one Chrome instance with one profile is not a multi-tenant runtime. Parallel jobs need isolated contexts or separate browsers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State drift&lt;/strong&gt; — a long-lived local Chrome accumulates cookies, extensions, and memory. Tests become non-reproducible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remote access&lt;/strong&gt; — exposing port 9222 to the network is a security problem. CDP has no authentication by default.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Option 2: A remote browser runtime you connect to over CDP
&lt;/h2&gt;

&lt;p&gt;This is where most production teams land. Instead of managing Chrome on every worker, you connect Playwright to a hosted Chromium session that already exposes a CDP endpoint. The connection code is nearly identical to Option 1 — only the URL changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playwright&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wsEndpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;REMOTE_BROWSER_CDP_URL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// e.g. wss://.../cdp&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connectOverCDP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wsEndpoint&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newContext&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="c1"&gt;// Configurable browser settings are applied server-side:&lt;/span&gt;
  &lt;span class="c1"&gt;// viewport, locale, timezone, proxy, and session isolation.&lt;/span&gt;
  &lt;span class="na"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shot.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// closes the remote session, not a local browser&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The practical differences from local Chrome:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Local Chrome + CDP&lt;/th&gt;
&lt;th&gt;Hosted Chromium runtime&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Setup&lt;/td&gt;
&lt;td&gt;Install Chrome, manage flags, profiles&lt;/td&gt;
&lt;td&gt;Paste a CDP URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Isolation&lt;/td&gt;
&lt;td&gt;One profile per instance&lt;/td&gt;
&lt;td&gt;Session-isolated contexts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concurrency&lt;/td&gt;
&lt;td&gt;Manual process management&lt;/td&gt;
&lt;td&gt;Sessions provisioned per job&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proxies&lt;/td&gt;
&lt;td&gt;Per-launch flags&lt;/td&gt;
&lt;td&gt;Configurable per session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live debugging&lt;/td&gt;
&lt;td&gt;Local DevTools only&lt;/td&gt;
&lt;td&gt;Live viewer + CDP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistence&lt;/td&gt;
&lt;td&gt;Local profile dir&lt;/td&gt;
&lt;td&gt;Persistent profiles across sessions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost model&lt;/td&gt;
&lt;td&gt;Your infra + ops time&lt;/td&gt;
&lt;td&gt;Metered per browser-hour (&lt;a href="https://remote-browser.dev/pricing" rel="noopener noreferrer"&gt;see pricing&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you're weighing self-hosting against a managed runtime, the trade-offs are covered in &lt;a href="https://remote-browser.dev/blog/remote-browser-online" rel="noopener noreferrer"&gt;Remote Browser Online&lt;/a&gt;. The short version: self-hosting is cheaper at low volume and much more expensive once you factor in ops, proxy management, and session cleanup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 3: Third-party Chrome extensions that wrap CDP
&lt;/h2&gt;

&lt;p&gt;There are Chrome extensions that expose a CDP-like control surface, and there are extensions that record and replay browser actions. These are not Playwright. They typically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run inside a single profile with extension permissions.&lt;/li&gt;
&lt;li&gt;Cannot create isolated browser contexts.&lt;/li&gt;
&lt;li&gt;Cannot intercept network at the protocol layer the way Playwright can.&lt;/li&gt;
&lt;li&gt;Break when Chrome updates its extension APIs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your goal is a quick recorder for a manual workflow, an extension is fine. If your goal is reproducible automation, tests, or agent workloads, you want Playwright (or Puppeteer) talking to a real browser over CDP. The same logic applies to Puppeteer — see how &lt;a href="https://remote-browser.dev/blog/remote-control-browser" rel="noopener noreferrer"&gt;Puppeteer connects to existing browsers&lt;/a&gt; for the parallel workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Playwright launch options that matter in production
&lt;/h2&gt;

&lt;p&gt;Whether you launch locally or connect remotely, the &lt;code&gt;launchOptions&lt;/code&gt; you pass shape reliability. The ones that matter most:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;args&lt;/code&gt;&lt;/strong&gt; — Chromium flags like &lt;code&gt;--disable-dev-shm-usage&lt;/code&gt; (containers), &lt;code&gt;--no-sandbox&lt;/code&gt; (only when you understand the security trade-off), &lt;code&gt;--disable-gpu&lt;/code&gt; (headless servers).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;headless&lt;/code&gt;&lt;/strong&gt; — headless is faster and more stable in CI; headed is useful for debugging via a live viewer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;proxy&lt;/code&gt;&lt;/strong&gt; — route traffic through a specific proxy. In a hosted runtime this is usually configured per session rather than per launch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;channel&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;chrome&lt;/code&gt;, &lt;code&gt;msedge&lt;/code&gt;, or bundled Chromium. Bundled Chromium is the most reproducible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;timeout&lt;/code&gt;&lt;/strong&gt; — how long to wait for the browser to start. Remote sessions may need a higher value.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A common production pattern is to keep launch options minimal and push environment-specific settings (proxy, viewport, locale) into the session configuration on the runtime side. That keeps your test code portable between local and hosted execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing between the three approaches
&lt;/h2&gt;

&lt;p&gt;Use this as a decision rule:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Attaching to your own Chrome&lt;/strong&gt; — good for debugging a logged-in session, scraping behind an auth wall you already cleared, or one-off scripts. Bad for CI, concurrency, and anything you need to reproduce.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosted Chromium over CDP&lt;/strong&gt; — good for CI, agent workloads, parallel jobs, and anything that needs isolation, proxies, or persistent profiles. This is the default for production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chrome extensions&lt;/strong&gt; — good for manual recording and personal productivity. Not a substitute for a driver library.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mistake to avoid is treating a local Chrome attach as a production runtime. It works until you need a second concurrent job, a clean profile, or a machine that isn't your laptop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to look for in a hosted runtime
&lt;/h2&gt;

&lt;p&gt;If you go the hosted route, evaluate on these criteria rather than marketing claims:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;CDP compatibility&lt;/strong&gt; — does &lt;code&gt;connectOverCDP&lt;/code&gt; work unmodified, or do you need a custom SDK?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session isolation&lt;/strong&gt; — are contexts truly isolated per job, or shared?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistent profiles&lt;/strong&gt; — can you keep cookies and storage across sessions when you need to?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proxy and network controls&lt;/strong&gt; — per-session proxy configuration, not global.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live debugging&lt;/strong&gt; — a viewer or CDP access so you can see what the agent saw.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Usage controls&lt;/strong&gt; — clear metering so you can predict cost. Check &lt;a href="https://remote-browser.dev/pricing" rel="noopener noreferrer"&gt;/pricing&lt;/a&gt; for current rates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Playwright/Puppeteer/Selenium support&lt;/strong&gt; — you shouldn't have to rewrite your driver code.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Remote Browser exposes hosted Chromium sessions with CDP access, Playwright/Puppeteer/Selenium compatibility, a live viewer, persistent profiles, configurable browser settings, and session isolation. The connection pattern is the &lt;code&gt;connectOverCDP&lt;/code&gt; example above — no proprietary driver required. Full API details are in the &lt;a href="https://remote-browser.dev/documentation" rel="noopener noreferrer"&gt;/documentation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on "Playwright MCP Chrome Extension"
&lt;/h2&gt;

&lt;p&gt;A related search is "Playwright MCP Chrome extension." MCP (Model Context Protocol) servers for browser control are a separate layer — they expose browser actions as tools to an LLM, and they typically sit on top of Playwright or CDP. They are not Chrome extensions either. If you're building an agent that needs browser tools, the runtime underneath the MCP server is what determines reliability: isolation, proxies, and session lifecycle. A local Chrome attach will not scale to concurrent agent tasks; a hosted runtime will. See &lt;a href="https://remote-browser.dev/blog/remote-web-browser" rel="noopener noreferrer"&gt;Remote Web Browser&lt;/a&gt; for how that runtime layer fits into an agent stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;There is no official Playwright Chrome Extension. The real options are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;chromium.connectOverCDP()&lt;/code&gt; to attach to a local Chrome you launched with &lt;code&gt;--remote-debugging-port&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;chromium.connectOverCDP()&lt;/code&gt; to a hosted Chromium session for production, CI, and agent workloads.&lt;/li&gt;
&lt;li&gt;A third-party extension if you only need manual recording.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For anything beyond a one-off script, the hosted runtime path gives you isolation, proxies, persistent profiles, and live debugging without managing Chrome on every worker. Start with the &lt;a href="https://remote-browser.dev/documentation" rel="noopener noreferrer"&gt;/documentation&lt;/a&gt; to see the CDP connection flow, and check &lt;a href="https://remote-browser.dev/pricing" rel="noopener noreferrer"&gt;/pricing&lt;/a&gt; for current usage rates.&lt;/p&gt;

</description>
      <category>playwright</category>
      <category>chromeextension</category>
      <category>cdp</category>
      <category>browserautomation</category>
    </item>
    <item>
      <title>Playwright MCP Chrome Extension: What It Is and What to Use</title>
      <dc:creator>RemoteBrowser</dc:creator>
      <pubDate>Wed, 16 Sep 2026 03:50:46 +0000</pubDate>
      <link>https://dev.to/remotebrowser2/playwright-mcp-chrome-extension-what-it-is-and-what-to-use-19p2</link>
      <guid>https://dev.to/remotebrowser2/playwright-mcp-chrome-extension-what-it-is-and-what-to-use-19p2</guid>
      <description>&lt;h1&gt;
  
  
  Playwright MCP Chrome Extension: What It Is and What to Use
&lt;/h1&gt;

&lt;p&gt;If you searched for a "Playwright MCP Chrome extension," you are probably trying to give an AI agent control of a real browser. The naming is confusing, because there is no official Playwright-branded Chrome extension that does this. What exists is the &lt;strong&gt;Playwright MCP server&lt;/strong&gt; — a Model Context Protocol server that exposes Playwright browser actions as tools an LLM can call — plus a set of ways to attach that server to Chrome, including a real Chrome extension that bridges your own logged-in browser session.&lt;/p&gt;

&lt;p&gt;This post separates the three things people conflate: the MCP server, the Chrome extension bridge, and the underlying browser connection (CDP or a launched Chromium). Then it covers where each breaks down in production and how to move to a hosted runtime when you need sessions that survive past your laptop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short answer
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Playwright MCP&lt;/strong&gt; is a server that lets an LLM drive a browser through Playwright. It is not a Chrome extension.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Chrome extension&lt;/strong&gt; in this stack is a bridge. It lets the MCP server talk to a Chrome instance you already have open, using your existing profile and logins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The connection underneath&lt;/strong&gt; is either a launched Chromium process or a CDP endpoint. That distinction determines whether your automation is reproducible or tied to one machine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you only need an agent to click around a site you are already logged into, the extension bridge is convenient. If you need agents that run on a schedule, in CI, or across many concurrent tasks, the extension is the wrong layer — you want a remote browser with a CDP endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Playwright MCP server actually does
&lt;/h2&gt;

&lt;p&gt;MCP (Model Context Protocol) is a standard for exposing tools to LLM clients. A Playwright MCP server wraps Playwright's API — navigate, click, type, snapshot the accessibility tree, take screenshots — and publishes them as callable tools. The LLM client (Claude Desktop, an IDE agent, a custom harness) decides which tool to call; the server executes it against a browser.&lt;/p&gt;

&lt;p&gt;Two design choices matter:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accessibility-tree snapshots vs. screenshots.&lt;/strong&gt; Most Playwright MCP implementations return a structured accessibility snapshot rather than pixels. That is cheaper for the model and more deterministic than vision-based clicking, but it means the agent sees the DOM's semantic structure, not the rendered page. Canvas apps, heavily styled widgets, and anything behind a shadow DOM boundary can be invisible to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Session lifetime.&lt;/strong&gt; The server holds a browser context for the duration of the conversation. When the client disconnects, that context typically dies. There is no built-in persistence unless you configure a user data directory or connect to an external browser.&lt;/p&gt;

&lt;p&gt;That last point is where the Chrome extension enters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Chrome extension bridge: what it solves
&lt;/h2&gt;

&lt;p&gt;A plain Playwright MCP server launches its own Chromium. That browser has no cookies, no logins, no extensions, and a fresh fingerprint. For a lot of real tasks — checking an internal dashboard, operating a SaaS tool you pay for, testing a page behind auth — that is a blocker.&lt;/p&gt;

&lt;p&gt;The extension bridge solves it by inverting the connection. Instead of the server launching a browser, the server connects to a browser you already have open. The extension runs inside that browser and relays CDP-level commands between the MCP server and the live tab.&lt;/p&gt;

&lt;p&gt;What you get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your existing cookies, sessions, and logged-in state&lt;/li&gt;
&lt;li&gt;Your real profile, including installed extensions&lt;/li&gt;
&lt;li&gt;The ability to watch the agent work in a tab you can see&lt;/li&gt;
&lt;li&gt;No separate browser download or install step&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What you give up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reproducibility.&lt;/strong&gt; The browser state is whatever you happened to have open.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolation.&lt;/strong&gt; The agent shares a profile with your personal browsing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrency.&lt;/strong&gt; One extension, one browser, effectively one agent at a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Headless operation.&lt;/strong&gt; The browser has to be running, which usually means a desktop session.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For interactive, human-in-the-loop work, that trade is fine. For anything that runs unattended, it is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the pieces connect: CDP is the common denominator
&lt;/h2&gt;

&lt;p&gt;Whether you launch Chromium, attach to a running Chrome, or connect to a cloud browser, the wire protocol is the same: the &lt;a href="https://chromedevtools.github.io/devtools-protocol/" rel="noopener noreferrer"&gt;Chrome DevTools Protocol&lt;/a&gt;. Playwright's &lt;code&gt;connectOverCDP&lt;/code&gt; is the entry point.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Browser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BrowserContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playwright&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;RemoteTarget&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;cdpUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// e.g. wss://&amp;lt;host&amp;gt;/cdp or http://127.0.0.1:9222&lt;/span&gt;
  &lt;span class="nl"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// provider-specific session handle&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;attach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RemoteTarget&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Browser&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BrowserContext&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connectOverCDP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cdpUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// A remote browser usually exposes one persistent context.&lt;/span&gt;
  &lt;span class="c1"&gt;// Reuse it so cookies and storage survive across calls.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contexts&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newContext&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="c1"&gt;// Fail fast if the endpoint is stale rather than hanging on the first action.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;about:blank&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;domcontentloaded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Usage against a hosted endpoint:&lt;/span&gt;
&lt;span class="c1"&gt;// const { browser, page } = await attach({ cdpUrl: process.env.CDP_URL! });&lt;/span&gt;
&lt;span class="c1"&gt;// await page.goto('https://example.com');&lt;/span&gt;
&lt;span class="c1"&gt;// await browser.close(); // closes the client connection, not the remote session&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details in that snippet are worth calling out, because they are where most &lt;code&gt;connectOverCDP&lt;/code&gt; implementations go wrong:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;connectOverCDP&lt;/code&gt; is Chromium-only.&lt;/strong&gt; It does not work against Firefox or WebKit. If your test matrix includes those engines, you need a different connection strategy — Playwright's own browser server, not CDP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;browser.close()&lt;/code&gt; semantics differ.&lt;/strong&gt; On a CDP connection, closing the browser object disconnects the client. Whether the remote session terminates depends on the provider. Read the docs for your endpoint before assuming cleanup happened.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context reuse is not automatic.&lt;/strong&gt; &lt;code&gt;browser.contexts()[0]&lt;/code&gt; is a convention, not a guarantee. Some providers return an empty array until you create a context. Handle both branches.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you are comparing this to a locally launched browser, the relevant Playwright launch flags are in the &lt;a href="https://remote-browser.dev/blog/playwright-browser-launch-options" rel="noopener noreferrer"&gt;browser launch options guide&lt;/a&gt; — &lt;code&gt;--remote-debugging-port&lt;/code&gt;, &lt;code&gt;--user-data-dir&lt;/code&gt;, and &lt;code&gt;--headless&lt;/code&gt; are the ones that matter for attach-style workflows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison: extension bridge vs. launched Chromium vs. hosted runtime
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Chrome extension bridge&lt;/th&gt;
&lt;th&gt;Local launched Chromium&lt;/th&gt;
&lt;th&gt;Hosted remote browser&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Auth state&lt;/td&gt;
&lt;td&gt;Your live profile&lt;/td&gt;
&lt;td&gt;Fresh, or a seeded profile dir&lt;/td&gt;
&lt;td&gt;Persistent profile per session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Isolation&lt;/td&gt;
&lt;td&gt;None — shares your profile&lt;/td&gt;
&lt;td&gt;Process-level&lt;/td&gt;
&lt;td&gt;Session-level, per agent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concurrency&lt;/td&gt;
&lt;td&gt;~1 agent&lt;/td&gt;
&lt;td&gt;Limited by local RAM/CPU&lt;/td&gt;
&lt;td&gt;Scales with the provider&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Headless / CI&lt;/td&gt;
&lt;td&gt;No (needs a desktop browser)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reproducibility&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;High (pinned image, fixed config)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debugging&lt;/td&gt;
&lt;td&gt;Watch the tab live&lt;/td&gt;
&lt;td&gt;Trace files, video&lt;/td&gt;
&lt;td&gt;Live viewer + traces&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup cost&lt;/td&gt;
&lt;td&gt;Install extension, run server&lt;/td&gt;
&lt;td&gt;&lt;code&gt;npx playwright install&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Paste a CDP URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Interactive, logged-in tasks&lt;/td&gt;
&lt;td&gt;Local dev, unit tests&lt;/td&gt;
&lt;td&gt;Agents, CI, scheduled jobs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The extension column is not "worse" — it is optimized for a different job. The mistake is using it for the job in the last row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the extension approach breaks in production
&lt;/h2&gt;

&lt;p&gt;Four failure modes show up repeatedly once you move past a demo:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Session death on disconnect.&lt;/strong&gt; The MCP client closes, the browser context goes with it. Any multi-step task that spans a client restart loses its place. Persistent profiles fix this, but a local Chrome profile is not designed for programmatic lifecycle management.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No concurrency.&lt;/strong&gt; One extension, one browser. Ten parallel agent tasks need ten browsers, which means ten desktop sessions. That does not fit on a CI runner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;State drift.&lt;/strong&gt; Because the browser is your real one, its state changes underneath the agent — you close a tab, log out of a site, install an extension. The agent's assumptions go stale silently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No resource controls.&lt;/strong&gt; A runaway agent loop on a local browser consumes your machine. There is no per-session CPU, memory, or time budget to enforce.&lt;/p&gt;

&lt;p&gt;None of these are fatal for interactive use. All of them are fatal for unattended workloads.&lt;/p&gt;

&lt;h2&gt;
  
  
  The production path: hosted Chromium with a CDP endpoint
&lt;/h2&gt;

&lt;p&gt;The pattern that scales is the same one the extension uses — connect to an existing browser over CDP — except the browser is not on your desk. It is a hosted Chromium session with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A CDP endpoint you connect to with &lt;code&gt;connectOverCDP&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A persistent profile so logins survive across sessions&lt;/li&gt;
&lt;li&gt;Session isolation so concurrent agents do not share cookies&lt;/li&gt;
&lt;li&gt;A live viewer for debugging without screen-sharing your desktop&lt;/li&gt;
&lt;li&gt;Configurable browser settings, including proxy routing, applied at session creation&lt;/li&gt;
&lt;li&gt;Usage controls so a stuck agent cannot burn budget indefinitely&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The migration from an extension-based setup is mostly deleting code. You stop managing a local Chrome process and a bridge; you pass a CDP URL to the same Playwright client you already wrote.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: local Chrome + extension bridge&lt;/span&gt;
&lt;span class="c1"&gt;// const browser = await chromium.connectOverCDP('http://127.0.0.1:9222');&lt;/span&gt;

&lt;span class="c1"&gt;// After: hosted session, same client API&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connectOverCDP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CDP_URL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contexts&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://app.example.com/dashboard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Export&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything downstream — locators, assertions, retries — is unchanged. That is the point of CDP as an interface: the browser's location is an implementation detail.&lt;/p&gt;

&lt;p&gt;For a fuller treatment of the runtime model, see &lt;a href="https://remote-browser.dev/blog/remote-browser-for-ai-agents" rel="noopener noreferrer"&gt;Remote Browser for AI agents&lt;/a&gt; and the &lt;a href="https://remote-browser.dev/blog/remote-web-browser" rel="noopener noreferrer"&gt;remote web browser overview&lt;/a&gt;. If you are specifically wiring an agent harness rather than a test suite, &lt;a href="https://remote-browser.dev/blog/remote-control-browser" rel="noopener noreferrer"&gt;remote control browser&lt;/a&gt; covers the control-plane side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing between them
&lt;/h2&gt;

&lt;p&gt;Use the &lt;strong&gt;extension bridge&lt;/strong&gt; when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The task requires a login you cannot or should not script&lt;/li&gt;
&lt;li&gt;A human is watching and may need to intervene&lt;/li&gt;
&lt;li&gt;You are prototyping and want to see the agent's actions in a real tab&lt;/li&gt;
&lt;li&gt;The workload is one-off and interactive&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use a &lt;strong&gt;hosted remote browser&lt;/strong&gt; when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The workload runs unattended, on a schedule, or in CI&lt;/li&gt;
&lt;li&gt;You need more than one agent running at once&lt;/li&gt;
&lt;li&gt;Sessions must survive client restarts&lt;/li&gt;
&lt;li&gt;You need per-session resource limits and audit trails&lt;/li&gt;
&lt;li&gt;You want the same code path in dev and production&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A reasonable migration is to prototype with the extension, then move to a CDP endpoint once the task definition stabilizes. The Playwright code barely changes; what changes is who owns the browser lifecycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical setup notes
&lt;/h2&gt;

&lt;p&gt;A few things that save time regardless of which path you pick:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pin your Playwright version.&lt;/strong&gt; CDP compatibility drifts between Playwright releases and Chrome versions. A version bump in CI that silently changes attach behavior is a bad afternoon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set an explicit connect timeout.&lt;/strong&gt; The default can hang for a long time on a dead endpoint. Thirty seconds is generous.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Health-check before the first real action.&lt;/strong&gt; A &lt;code&gt;goto('about:blank')&lt;/code&gt; costs nothing and fails fast on a stale session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log the session ID.&lt;/strong&gt; When something goes wrong at 3 a.m., the session handle is the first thing you will want.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not assume &lt;code&gt;browser.close()&lt;/code&gt; kills the remote session.&lt;/strong&gt; Verify against your provider's docs, and use an explicit session-terminate call if one exists.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Current session limits, concurrency, and pricing for hosted sessions are on the &lt;a href="https://remote-browser.dev/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt;. The &lt;a href="https://remote-browser.dev/documentation" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; covers CDP connection details, profile persistence, and the live viewer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;There is no Playwright MCP Chrome extension in the sense most people mean. There is an MCP server, and there is an extension that bridges it to your own browser. That combination is genuinely useful for interactive, logged-in work — and genuinely wrong for anything that needs to run unattended, concurrently, or reproducibly.&lt;/p&gt;

&lt;p&gt;The underlying connection is CDP either way. Once you internalize that, the decision stops being about extensions and starts being about where the browser lives. For production agent workloads, that answer is usually a hosted session with a persistent profile and a CDP URL you can paste into the Playwright client you already have.&lt;/p&gt;

</description>
      <category>playwright</category>
      <category>mcp</category>
      <category>chromeextension</category>
      <category>browserautomation</category>
    </item>
  </channel>
</rss>
