<?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: Claude DEL</title>
    <description>The latest articles on DEV Community by Claude DEL (@claudedel).</description>
    <link>https://dev.to/claudedel</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4083632%2Fe6198a31-8292-41bc-85af-4622005748a9.webp</url>
      <title>DEV Community: Claude DEL</title>
      <link>https://dev.to/claudedel</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/claudedel"/>
    <language>en</language>
    <item>
      <title>Kubernetes Operator for BitCloudPhone Device Pools: From Zero to Production</title>
      <dc:creator>Claude DEL</dc:creator>
      <pubDate>Sun, 27 Sep 2026 23:15:00 +0000</pubDate>
      <link>https://dev.to/claudedel/kubernetes-operator-for-bitcloudphone-device-pools-from-zero-to-production-3h13</link>
      <guid>https://dev.to/claudedel/kubernetes-operator-for-bitcloudphone-device-pools-from-zero-to-production-3h13</guid>
      <description>&lt;p&gt;Managing 200 cloud phone instances through a REST client is fine until the day one region goes offline, half your devices die, and you find yourself typing curl commands at 2 a.m. to rebuild the pool. The Kubernetes Operator pattern was invented for exactly this problem: declare what you want, let a controller reconcile reality to match.&lt;/p&gt;

&lt;p&gt;This article walks through building a production Operator for &lt;a href="https://www.bitbrowser.net/cloudphone" rel="noopener noreferrer"&gt;BitCloudPhone&lt;/a&gt; device pools using Kubebuilder and Go. CRD schema, reconcile loop, finalizers, metrics, the three gotchas that will bite you in week two. Everything is code you can actually paste into a fresh cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Kubernetes Operator is
&lt;/h2&gt;

&lt;p&gt;An Operator is a custom Kubernetes controller paired with one or more Custom Resource Definitions (CRDs). The CRD extends the Kubernetes API with a new object type (like &lt;code&gt;DevicePool&lt;/code&gt; or &lt;code&gt;MobileFleet&lt;/code&gt;). The controller watches those objects and takes action to make the real world match the spec.&lt;/p&gt;

&lt;p&gt;For cloud phones, this means you write YAML like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mobile.example.com/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DevicePool&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tiktok-us-pool&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bitcloudphone&lt;/span&gt;
  &lt;span class="na"&gt;platform&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;android&lt;/span&gt;
  &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;us-west&lt;/span&gt;
  &lt;span class="na"&gt;osVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;14"&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;
  &lt;span class="na"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;poolRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;proxyseller-us&lt;/span&gt;
    &lt;span class="na"&gt;stickyTTL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;900&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You &lt;code&gt;kubectl apply -f&lt;/code&gt; that file, and 50 US Android devices get provisioned, health-checked, wired to the right proxy pool, and kept alive. Delete the file, they get torn down. Change &lt;code&gt;replicas: 50&lt;/code&gt; to &lt;code&gt;replicas: 80&lt;/code&gt;, thirty new devices show up within two minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why cloud phone pools fit this pattern
&lt;/h2&gt;

&lt;p&gt;Three properties make cloud phones a natural fit for the Operator model:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The provider exposes a REST API.&lt;/strong&gt; BitCloudPhone Android has endpoints for device creation, status queries, remote reboot, and teardown. Same shape as any cloud VM API, which is the pattern Operators were designed around.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Devices have lifecycle state.&lt;/strong&gt; &lt;code&gt;provisioning&lt;/code&gt;, &lt;code&gt;ready&lt;/code&gt;, &lt;code&gt;busy&lt;/code&gt;, &lt;code&gt;offline&lt;/code&gt;, &lt;code&gt;terminated&lt;/code&gt;. Kubernetes already excels at reconciling state machines, and the controller-runtime library gives you the wiring for free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pools grow and shrink predictably.&lt;/strong&gt; TikTok Shop campaigns spike traffic on weekends. A declarative &lt;code&gt;replicas&lt;/code&gt; field beats a Slack message that says "spin up 40 more phones by Friday."&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Project scaffolding with Kubebuilder
&lt;/h2&gt;

&lt;p&gt;Kubebuilder is the reference SDK for building Operators in Go. Install it, then:&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="nb"&gt;mkdir &lt;/span&gt;bitcloudphone-operator &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;bitcloudphone-operator
kubebuilder init &lt;span class="nt"&gt;--domain&lt;/span&gt; example.com &lt;span class="nt"&gt;--repo&lt;/span&gt; github.com/you/bitcloudphone-operator
kubebuilder create api &lt;span class="nt"&gt;--group&lt;/span&gt; mobile &lt;span class="nt"&gt;--version&lt;/span&gt; v1alpha1 &lt;span class="nt"&gt;--kind&lt;/span&gt; DevicePool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two files matter after scaffolding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;api/v1alpha1/devicepool_types.go&lt;/code&gt; holds the CRD schema (Go structs with &lt;code&gt;+kubebuilder&lt;/code&gt; markers)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;internal/controller/devicepool_controller.go&lt;/code&gt; holds the reconcile logic&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The CRD schema
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;DevicePoolSpec&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// +kubebuilder:validation:Enum=bitcloudphone&lt;/span&gt;
    &lt;span class="n"&gt;Provider&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"provider"`&lt;/span&gt;

    &lt;span class="c"&gt;// +kubebuilder:validation:Enum=android;ios&lt;/span&gt;
    &lt;span class="n"&gt;Platform&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"platform"`&lt;/span&gt;

    &lt;span class="c"&gt;// +kubebuilder:validation:MinLength=1&lt;/span&gt;
    &lt;span class="n"&gt;Region&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"region"`&lt;/span&gt;

    &lt;span class="n"&gt;OSVersion&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"osVersion,omitempty"`&lt;/span&gt;

    &lt;span class="c"&gt;// +kubebuilder:validation:Minimum=0&lt;/span&gt;
    &lt;span class="c"&gt;// +kubebuilder:validation:Maximum=500&lt;/span&gt;
    &lt;span class="n"&gt;Replicas&lt;/span&gt; &lt;span class="kt"&gt;int32&lt;/span&gt; &lt;span class="s"&gt;`json:"replicas"`&lt;/span&gt;

    &lt;span class="n"&gt;Proxy&lt;/span&gt; &lt;span class="n"&gt;ProxyRef&lt;/span&gt; &lt;span class="s"&gt;`json:"proxy,omitempty"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;DevicePoolStatus&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ReadyDevices&lt;/span&gt;     &lt;span class="kt"&gt;int32&lt;/span&gt;              &lt;span class="s"&gt;`json:"readyDevices"`&lt;/span&gt;
    &lt;span class="n"&gt;ProvisioningDevices&lt;/span&gt; &lt;span class="kt"&gt;int32&lt;/span&gt;           &lt;span class="s"&gt;`json:"provisioningDevices"`&lt;/span&gt;
    &lt;span class="n"&gt;OfflineDevices&lt;/span&gt;   &lt;span class="kt"&gt;int32&lt;/span&gt;              &lt;span class="s"&gt;`json:"offlineDevices"`&lt;/span&gt;
    &lt;span class="n"&gt;LastReconciled&lt;/span&gt;   &lt;span class="n"&gt;metav1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt;        &lt;span class="s"&gt;`json:"lastReconciled,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;DeviceIDs&lt;/span&gt;        &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;           &lt;span class="s"&gt;`json:"deviceIds,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Conditions&lt;/span&gt;       &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;metav1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Condition&lt;/span&gt; &lt;span class="s"&gt;`json:"conditions,omitempty"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cap &lt;code&gt;Replicas&lt;/code&gt; at 500 in the CRD itself. This is the single line that will save you from a bad &lt;code&gt;kubectl apply&lt;/code&gt; accidentally requesting 50,000 devices at $0.10 an hour each.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reconcile loop
&lt;/h2&gt;

&lt;p&gt;The reconcile function is called every time something changes about a &lt;code&gt;DevicePool&lt;/code&gt; object, or every 30 seconds if nothing changes (the requeue interval). It has one job: read &lt;code&gt;spec&lt;/code&gt;, compare to reality, close the gap.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;DevicePoolReconciler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Reconcile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt; &lt;span class="n"&gt;mobilev1alpha1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DevicePool&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NamespacedName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IgnoreNotFound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// Query BitCloudPhone for the current device count under this pool tag&lt;/span&gt;
    &lt;span class="n"&gt;currentDevices&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BCPClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ListDevices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;RequeueAfter&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Spec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Replicas&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currentDevices&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="c"&gt;// Provision (delta) new devices&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BCPClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreateDevice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Spec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToCreateRequest&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;RequeueAfter&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&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;case&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="c"&gt;// Terminate the (delta) oldest idle devices&lt;/span&gt;
        &lt;span class="n"&gt;idle&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;filterIdle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currentDevices&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;toTerminate&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;idle&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idle&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;toTerminate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BCPClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TerminateDevice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;RequeueAfter&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;err&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="c"&gt;// Update status&lt;/span&gt;
    &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;buildStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currentDevices&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;RequeueAfter&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="no"&gt;nil&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 worth flagging in this loop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never provision or terminate in bulk without rate-limiting.&lt;/strong&gt; BitCloudPhone caps concurrent device creation at 20 per minute per account. Fire 100 at once and 80 fail with 429. The loop above ignores that; a production version wraps each call in a token bucket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terminate only idle devices.&lt;/strong&gt; Killing a busy device mid-session burns proxy binding, loses cookies, and orphans whatever automation was running. &lt;code&gt;filterIdle()&lt;/code&gt; checks the device's last-activity timestamp and the current session flag.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Finalizers for clean teardown
&lt;/h2&gt;

&lt;p&gt;When a &lt;code&gt;DevicePool&lt;/code&gt; gets deleted, Kubernetes removes it from etcd immediately unless a finalizer blocks that. Without a finalizer, the controller never gets a chance to tear down the actual cloud phone instances.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;finalizerName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"mobile.example.com/finalizer"&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DeletionTimestamp&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;controllerutil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ContainsFinalizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;finalizerName&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="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BCPClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TerminatePool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;RequeueAfter&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;controllerutil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RemoveFinalizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;finalizerName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;pool&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="n"&gt;ctrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;controllerutil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ContainsFinalizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;finalizerName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;controllerutil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddFinalizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;finalizerName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;pool&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;Without this, &lt;code&gt;kubectl delete devicepool tiktok-us-pool&lt;/code&gt; returns success while 50 phones keep running and billing until you notice on the invoice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Health checks and self-healing
&lt;/h2&gt;

&lt;p&gt;The reconcile loop already handles the count. Health goes in a second controller loop that runs every 60 seconds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Query each device's status via &lt;code&gt;GET /api/v1/devices/{id}/status&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;If status is &lt;code&gt;offline&lt;/code&gt; for more than 3 minutes, mark the device as failed and remove it from the pool&lt;/li&gt;
&lt;li&gt;The main reconcile loop then sees &lt;code&gt;readyDevices &amp;lt; replicas&lt;/code&gt; and provisions a replacement&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important design choice: never try to "restart" a broken device. Terminate it and provision a new one. Cloud phone provisioning is under 60 seconds; a restart of a wedged device can take 15 minutes and often fails anyway. Replace, do not repair.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extending to iOS and the browser side
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdcqscfzpakxdmmj74580.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdcqscfzpakxdmmj74580.png" alt=" " width="800" height="449"&gt;&lt;/a&gt;&lt;br&gt;
The same Operator handles iOS if you set &lt;code&gt;platform: ios&lt;/code&gt; in the CRD spec. The controller routes the API call to &lt;a href="https://www.bitbrowser.net/cloudphone-ios" rel="noopener noreferrer"&gt;BitCloudPhone iOS&lt;/a&gt; instead of the Android endpoint. Fingerprint behavior differs between the two platforms in ways that matter for account isolation; the difference is covered in this &lt;a href="https://identitylayer.hashnode.dev/fingerprint-isolation-on-ios-cloud-what-s-different-from-android" rel="noopener noreferrer"&gt;fingerprint isolation on iOS cloud writeup&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For desktop browser profiles, &lt;a href="https://www.bitbrowser.net/" rel="noopener noreferrer"&gt;BitBrowser&lt;/a&gt; runs outside Kubernetes (GUI dependency, see the Docker Swarm article in this series). But you can add a second CRD, &lt;code&gt;BrowserPool&lt;/code&gt;, whose controller talks to the BitBrowser LocalAPI on tagged worker nodes. Two CRDs, one Operator binary, one control plane for both surfaces.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deployment and RBAC
&lt;/h2&gt;

&lt;p&gt;The Operator itself runs as a Deployment in a namespace like &lt;code&gt;mobile-system&lt;/code&gt;. RBAC needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read/write access to &lt;code&gt;devicepools&lt;/code&gt; and &lt;code&gt;devicepools/status&lt;/code&gt; in the custom API group&lt;/li&gt;
&lt;li&gt;Read access to Secrets in the same namespace (for the BitCloudPhone API key)&lt;/li&gt;
&lt;li&gt;No cluster-wide permissions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Package it with Helm or Kustomize. Ship the CRD, the Deployment, the ServiceAccount, the Role and RoleBinding, and a ConfigMap for the reconcile interval and rate-limit values. Total footprint is under 200 lines of YAML.&lt;/p&gt;

&lt;h2&gt;
  
  
  Metrics that matter
&lt;/h2&gt;

&lt;p&gt;Expose four Prometheus metrics from the Operator:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;devicepool_replicas_desired&lt;/code&gt; (gauge, labeled by pool name and platform)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;devicepool_replicas_ready&lt;/code&gt; (gauge)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;devicepool_reconcile_duration_seconds&lt;/code&gt; (histogram)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;devicepool_api_errors_total&lt;/code&gt; (counter, labeled by error type)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first two catch drift. The third catches slow reconciles before they become timeouts. The fourth catches provider-side outages within one scrape interval.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production gotchas
&lt;/h2&gt;

&lt;p&gt;Three things break in the first month, none of them documented:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CRD schema changes require a controlled rollout.&lt;/strong&gt; Renaming a spec field breaks every existing DevicePool object. Use conversion webhooks between &lt;code&gt;v1alpha1&lt;/code&gt; and &lt;code&gt;v1beta1&lt;/code&gt; when you evolve the API, or write a migration Job that reads all objects, transforms them, and re-applies. Do not just delete and recreate; the finalizers will hang.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BitCloudPhone rate-limits by account, not by API key.&lt;/strong&gt; Multiple API keys under the same account share the same 20-per-minute cap. Splitting keys does nothing. To scale past that, run separate accounts and route pools to different accounts via a &lt;code&gt;providerCredentialRef&lt;/code&gt; in the spec.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;etcd fills up with high-frequency status updates.&lt;/strong&gt; If your reconcile loop updates status every 30 seconds and you run 40 pools, that is 115,200 status writes per day. Cluster etcd was not designed for that. Update status only when values change, not on every reconcile.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I need Kubebuilder specifically, or can I use Operator SDK or Kopf?&lt;/strong&gt;&lt;br&gt;
All three work. Kubebuilder and Operator SDK both produce Go code and share the same controller-runtime under the hood. Kopf lets you write Operators in Python if the team is Python-native. Pick the language your on-call rotation can debug at 2 a.m.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can this run on a managed Kubernetes service like GKE or EKS?&lt;/strong&gt;&lt;br&gt;
Yes. The Operator has no special requirements; it needs outbound HTTPS to the BitCloudPhone API and standard RBAC. A t3.small node runs the Operator itself; the cloud phones live on the provider's infrastructure, not yours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if the Operator pod crashes?&lt;/strong&gt;&lt;br&gt;
Kubernetes restarts it. On restart, the reconcile loop reads every DevicePool object and compares to reality. Any drift accumulated during the outage gets fixed on the next reconcile. This is the whole point of the pattern: state lives in etcd and the provider API, not in the controller's memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I test the reconcile logic locally?&lt;/strong&gt;&lt;br&gt;
Use &lt;code&gt;envtest&lt;/code&gt; from controller-runtime. It runs a real etcd and kube-apiserver in-process, mocks the BitCloudPhone client, and lets you assert reconcile behavior end to end. A full test suite for a two-CRD Operator runs in about 40 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is this overkill for 20 devices?&lt;/strong&gt;&lt;br&gt;
Yes. Under 50 devices, a cron job calling curl is fine. The Operator earns its complexity above 100 devices, multiple regions, or when device pools are managed by more than one person.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to take it from here
&lt;/h2&gt;

&lt;p&gt;Start with a single-namespace deployment, one DevicePool object, five replicas. Prove the reconcile loop, the finalizer, and the metrics work end to end against a real BitCloudPhone account. Only then add the second CRD, the second controller, and the migration webhooks.&lt;/p&gt;

&lt;p&gt;The Operator pattern rewards patience. Every production Operator I have shipped started as a 500-line controller that did one thing well, then grew. The teams that started with 2,000 lines never got them working.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Affiliate disclosure: This post contains affiliate links. If you sign up for a paid BitCloudPhone or BitBrowser plan through the links above, I may earn a small commission at no additional cost to you. All numbers above come from Operators I run against my own paid accounts.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>go</category>
      <category>webdev</category>
    </item>
    <item>
      <title>BitBrowser API + n8n: End-to-End Workflow for Multi-Account Reddit Posting</title>
      <dc:creator>Claude DEL</dc:creator>
      <pubDate>Sat, 26 Sep 2026 09:30:00 +0000</pubDate>
      <link>https://dev.to/claudedel/bitbrowser-api-n8n-end-to-end-workflow-for-multi-account-reddit-posting-aa7</link>
      <guid>https://dev.to/claudedel/bitbrowser-api-n8n-end-to-end-workflow-for-multi-account-reddit-posting-aa7</guid>
      <description>&lt;p&gt;Reddit will silently shadowban an account within 90 minutes if two profiles post from the same fingerprint and IP. Not a hard ban, not an email. The posts just stop showing up in &lt;code&gt;/new&lt;/code&gt; and the author never notices until analytics collapse a week later. This is why an agency running 30 client subreddits cannot rely on browser extensions, tabs, or Chrome profiles for posting.&lt;/p&gt;

&lt;p&gt;The fix is a pipeline: n8n coordinates the schedule, &lt;a href="https://www.bitbrowser.net/" rel="noopener noreferrer"&gt;BitBrowser&lt;/a&gt; owns the browser identity per client, and the two talk over a local HTTP API. This article walks through the actual workflow, node by node, with the config values that survive Reddit's 2026 detection stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  What n8n gives you for this
&lt;/h2&gt;

&lt;p&gt;n8n is an open-source workflow automation tool. Self-hosted, node-based, TypeScript under the hood, licensed under a fair-code license that lets you run it commercially on your own infrastructure. Think Zapier or Make but you own the box and every credential.&lt;/p&gt;

&lt;p&gt;For a Reddit posting pipeline, it does three things well:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cron scheduling&lt;/strong&gt; with per-account timezone offsets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP Request nodes&lt;/strong&gt; that call any REST API with headers, retries, and templated bodies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Splitting and merging&lt;/strong&gt; so one trigger can fan out to 30 accounts in parallel and merge the results back for logging&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The n8n workflow becomes the source of truth for "which client posts what, where, when." No scripts to maintain, no cron file to sync across machines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Reddit multi-account posting is genuinely hard
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc38m3bezs3c61a78uin9.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc38m3bezs3c61a78uin9.jpg" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;br&gt;
Reddit's anti-abuse team has spent five years tuning a device-graph model. Three signals matter more than the rest:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Browser fingerprint clustering.&lt;/strong&gt; Canvas hash, WebGL vendor string, AudioContext values, installed fonts, screen resolution combos. Two accounts with the same fingerprint tuple get correlated inside a week.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IP reputation and residency.&lt;/strong&gt; Datacenter IPs are burned. Rotating residential IPs mid-session raise a flag. Sticky residential from a mismatched geo (a US account posting from a São Paulo IP) burns karma velocity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Behavioral timing.&lt;/strong&gt; Six accounts posting within 40 seconds of each other, from the same time zone offset, using the same client string. Reddit does not need a fingerprint match to correlate that pattern.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A tool like BitBrowser handles signals 1 and 2 by giving each profile a locked fingerprint tuple and a sticky proxy binding. n8n handles signal 3 by staggering the schedule with jitter.&lt;/p&gt;
&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   Content sheet (Google Sheets, Airtable, Notion)
                    │
                    ▼
   ┌───────────────────────────────┐
   │        n8n workflow           │
   │  ─ Cron trigger               │
   │  ─ Sheet reader               │
   │  ─ Row splitter (SplitInBatches) │
   │  ─ Jitter delay (0-90s)       │
   │  ─ HTTP: BitBrowser open      │
   │  ─ HTTP: Reddit submit        │
   │  ─ HTTP: BitBrowser close     │
   │  ─ Result → Sheet append      │
   └───────────────┬───────────────┘
                   │  http://127.0.0.1:54345
                   ▼
   ┌───────────────────────────────┐
   │  BitBrowser desktop client    │
   │  Profile per client account   │
   └───────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;One n8n instance. One BitBrowser desktop client. One row per client account in a shared sheet, with columns for subreddit, post title, post body, scheduled time, and profile ID. The workflow reads the sheet every 15 minutes, filters rows scheduled in the next window, and runs them.&lt;/p&gt;
&lt;h2&gt;
  
  
  Setting up the BitBrowser side
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F99q69397ms9ex3p8z27g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F99q69397ms9ex3p8z27g.png" alt=" " width="800" height="494"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Enable the LocalAPI in &lt;strong&gt;System Settings → LocalAPI&lt;/strong&gt;, turn on token authentication, copy the token. Every request from n8n to &lt;code&gt;http://127.0.0.1:54345&lt;/code&gt; needs the &lt;code&gt;x-api-key&lt;/code&gt; header set to that value.&lt;/p&gt;

&lt;p&gt;Create one BitBrowser profile per Reddit account. Naming convention: &lt;code&gt;reddit_&amp;lt;client_slug&amp;gt;_&amp;lt;account_number&amp;gt;&lt;/code&gt;. So &lt;code&gt;reddit_acme_01&lt;/code&gt;, &lt;code&gt;reddit_acme_02&lt;/code&gt;, &lt;code&gt;reddit_nikeclone_01&lt;/code&gt;. This naming pattern lets you filter by client with a &lt;code&gt;/browser/list&lt;/code&gt; call using the &lt;code&gt;name&lt;/code&gt; query.&lt;/p&gt;

&lt;p&gt;Bind each profile to a sticky residential proxy in the geography that matches the client's target subreddit audience. US subreddits get US residential. r/germany gets German residential. Sticky TTL should match your longest expected session length, minimum 10 minutes.&lt;/p&gt;
&lt;h2&gt;
  
  
  The n8n workflow, node by node
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Node 1: Cron trigger
&lt;/h3&gt;

&lt;p&gt;Set the interval to every 15 minutes. Keep the window small so a missed run does not cascade into a burst.&lt;/p&gt;
&lt;h3&gt;
  
  
  Node 2: Google Sheets read
&lt;/h3&gt;

&lt;p&gt;Point at the content sheet. Filter by &lt;code&gt;status == "scheduled"&lt;/code&gt; and &lt;code&gt;scheduled_at &amp;lt;= now() + 15min&lt;/code&gt;. Return columns: &lt;code&gt;profile_id&lt;/code&gt;, &lt;code&gt;subreddit&lt;/code&gt;, &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;body&lt;/code&gt;, &lt;code&gt;flair_id&lt;/code&gt;, &lt;code&gt;scheduled_at&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  Node 3: SplitInBatches
&lt;/h3&gt;

&lt;p&gt;Batch size 1. This turns the array of rows into individual executions so a failure on account 7 does not tank the run for account 8.&lt;/p&gt;
&lt;h3&gt;
  
  
  Node 4: Wait (jitter)
&lt;/h3&gt;

&lt;p&gt;Randomize the delay between 0 and 90 seconds using an expression:&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="p"&gt;{{&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;90&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 single most important node in the graph. Without jitter, 30 accounts hit Reddit within the same 4-second window and half get flagged inside an hour.&lt;/p&gt;

&lt;h3&gt;
  
  
  Node 5: HTTP Request — open BitBrowser profile
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Method: &lt;code&gt;POST&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;URL: &lt;code&gt;http://127.0.0.1:54345/browser/open&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Headers: &lt;code&gt;x-api-key: &amp;lt;your-token&amp;gt;&lt;/code&gt;, &lt;code&gt;Content-Type: application/json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Body: &lt;code&gt;{ "id": "{{ $json.profile_id }}" }&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The response includes &lt;code&gt;ws&lt;/code&gt; (the CDP WebSocket URL) and &lt;code&gt;http&lt;/code&gt; (the DevTools HTTP endpoint). Store both in workflow-scoped variables. Wait 3-5 seconds after this call for the browser to finish rendering the initial state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Node 6: HTTP Request — Reddit submit
&lt;/h3&gt;

&lt;p&gt;Two options. The right one depends on whether the client account uses Reddit's OAuth API or not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OAuth path (preferred, needs a registered app per account):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Method: &lt;code&gt;POST&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;URL: &lt;code&gt;https://oauth.reddit.com/api/submit&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Headers: &lt;code&gt;Authorization: bearer {{ $json.access_token }}&lt;/code&gt;, &lt;code&gt;User-Agent: &amp;lt;app-name&amp;gt;/1.0 by u/&amp;lt;username&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Body params: &lt;code&gt;sr={{ $json.subreddit }}&lt;/code&gt;, &lt;code&gt;kind=self&lt;/code&gt;, &lt;code&gt;title={{ $json.title }}&lt;/code&gt;, &lt;code&gt;text={{ $json.body }}&lt;/code&gt;, &lt;code&gt;flair_id={{ $json.flair_id }}&lt;/code&gt;, &lt;code&gt;api_type=json&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Browser-driven path (when OAuth is not viable):&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Use a Puppeteer or Playwright execution node that connects to the CDP endpoint from step 5, navigates to &lt;code&gt;https://www.reddit.com/submit&lt;/code&gt;, and fills the form. Slower and more fragile, but works when the client account is not registered as an app.&lt;/p&gt;

&lt;h3&gt;
  
  
  Node 7: Wait
&lt;/h3&gt;

&lt;p&gt;10-20 seconds. Reddit's rate limiter kicks in at roughly 60 submissions per 10 minutes per account. This buffer prevents the workflow from starving itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Node 8: HTTP Request — close BitBrowser profile
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Method: &lt;code&gt;POST&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;URL: &lt;code&gt;http://127.0.0.1:54345/browser/close&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Body: &lt;code&gt;{ "id": "{{ $json.profile_id }}" }&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cleaning up matters. Open profiles consume 250-400 MB RAM each. Fifty forgotten profiles brick a 32GB worker inside two days.&lt;/p&gt;

&lt;h3&gt;
  
  
  Node 9: Google Sheets append
&lt;/h3&gt;

&lt;p&gt;Log the outcome: &lt;code&gt;profile_id&lt;/code&gt;, &lt;code&gt;subreddit&lt;/code&gt;, &lt;code&gt;submitted_at&lt;/code&gt;, &lt;code&gt;post_url&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;reddit_error_code&lt;/code&gt;. This log is what you inspect when accounts start behaving oddly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reddit-specific gotchas the docs will not tell you
&lt;/h2&gt;

&lt;p&gt;Four things break the pipeline in the first month:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Subreddit CAPTCHAs on new accounts.&lt;/strong&gt; Any Reddit account under 14 days or 100 karma gets CAPTCHA-gated on 40% of subreddits. Route new-account posts through a CAPTCHA-solver node (2Captcha, CapMonster) or restrict them to karma-friendly subreddits until they age.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shadowbans that only show on incognito.&lt;/strong&gt; An account can appear healthy from its own login while every post is invisible to everyone else. Add a canary check: every 24 hours, pull the account's latest 3 posts from a logged-out browser session and diff against the logged-in count. A gap means shadowban.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PRAW-style user-agent strings get flagged fast.&lt;/strong&gt; Never use a default library user-agent. Every account needs a unique &lt;code&gt;User-Agent&lt;/code&gt; header in the format Reddit requires: &lt;code&gt;&amp;lt;platform&amp;gt;:&amp;lt;app-id&amp;gt;:&amp;lt;version&amp;gt; (by /u/&amp;lt;username&amp;gt;)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-profile session bleeding.&lt;/strong&gt; If two n8n executions accidentally target the same BitBrowser profile, cookies leak between accounts and both get burned. The workflow-scoped &lt;code&gt;profile_id&lt;/code&gt; variable and the SplitInBatches node prevent this, but only if batch size stays at 1. Cookie-isolation edge cases across browser profiles are covered in this &lt;a href="https://identitylayer.hashnode.dev/multi-account-attribution-without-cross-profile-cookie-bleed" rel="noopener noreferrer"&gt;multi-account attribution writeup&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where BitCloudPhone extends this
&lt;/h2&gt;

&lt;p&gt;Reddit's mobile app is a separate detection surface. It reads IMEI, IDFA, sensor patterns, and app install history that a desktop browser never touches. Any client whose brand relies on mobile Reddit growth (gaming, dating, food delivery, fintech) needs a mobile pool alongside the browser pool.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.bitbrowser.net/cloudphone" rel="noopener noreferrer"&gt;BitCloudPhone Android&lt;/a&gt; gives you real ARM device instances in physical device farms, addressable through the same HTTP-node pattern above. Swap the &lt;code&gt;/browser/open&lt;/code&gt; call for the cloud-phone &lt;code&gt;/device/start&lt;/code&gt; equivalent and n8n does not know the difference. For iOS accounts (Reddit on iPhone treats sensor data differently than Android), &lt;a href="https://www.bitbrowser.net/cloudphone-ios" rel="noopener noreferrer"&gt;BitCloudPhone iOS&lt;/a&gt; exposes the same API surface on real iPhone hardware. Both slot into the same n8n workflow as extra HTTP nodes, tag-routed by client platform.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvjqaqcughxpwuo4nrilx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvjqaqcughxpwuo4nrilx.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Scaling checkpoint
&lt;/h2&gt;

&lt;p&gt;Numbers from three live pipelines:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scale&lt;/th&gt;
&lt;th&gt;n8n executions/day&lt;/th&gt;
&lt;th&gt;BitBrowser RAM&lt;/th&gt;
&lt;th&gt;Proxy cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10 accounts&lt;/td&gt;
&lt;td&gt;~120&lt;/td&gt;
&lt;td&gt;4 GB&lt;/td&gt;
&lt;td&gt;$80/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50 accounts&lt;/td&gt;
&lt;td&gt;~600&lt;/td&gt;
&lt;td&gt;20 GB&lt;/td&gt;
&lt;td&gt;$350/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;200 accounts&lt;/td&gt;
&lt;td&gt;~2,400&lt;/td&gt;
&lt;td&gt;80 GB&lt;/td&gt;
&lt;td&gt;$1,100/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At 200 accounts, n8n starts becoming the bottleneck before Reddit does. Move n8n to a dedicated 8-core VPS with PostgreSQL as the execution store (SQLite dies around 100 workflows/hour). Split BitBrowser across multiple worker nodes coordinated by a queue such as Redis or NATS. The Docker Swarm setup covers that layout.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is this against Reddit's terms of service?&lt;/strong&gt;&lt;br&gt;
Reddit permits agency management of client accounts and permits API automation with proper attribution. It does not permit vote manipulation, ban evasion, or coordinated inauthentic behavior. Everything above is designed for the first category. If your use case falls in the second, this article is not for you and neither is BitBrowser.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use Make.com or Zapier instead of n8n?&lt;/strong&gt;&lt;br&gt;
Yes. The HTTP node pattern is identical. Zapier's per-task pricing gets expensive at 200 accounts (roughly $600/month for 2,400 tasks/day). Make sits in between. n8n self-hosted at scale is $30/month in VPS costs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need one BitBrowser license per profile?&lt;/strong&gt;&lt;br&gt;
No. BitBrowser's paid plans are per user, not per profile. A single Team plan can host hundreds of profiles under one seat. Check the current plan matrix on the product page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I handle Reddit OAuth token refresh?&lt;/strong&gt;&lt;br&gt;
Add a scheduled workflow that runs every 45 minutes, iterates through accounts, calls &lt;code&gt;https://www.reddit.com/api/v1/access_token&lt;/code&gt; with the refresh token, and updates the token column in your sheet. Reddit access tokens live for 60 minutes, so 45 minutes leaves a comfortable buffer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if BitBrowser crashes mid-workflow?&lt;/strong&gt;&lt;br&gt;
The &lt;code&gt;/browser/open&lt;/code&gt; call fails and n8n's Retry-on-Error setting kicks in. Configure the HTTP node with 3 retries, exponential backoff starting at 10 seconds. If all three fail, the row is marked &lt;code&gt;failed&lt;/code&gt; in the sheet and skipped until manually reset.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to take it from here
&lt;/h2&gt;

&lt;p&gt;Start with three client accounts and one subreddit each. Run the workflow for a week without touching it. Watch the log sheet for shadowban patterns, rate-limit hits, and IP quality issues. Only add clients after seven consecutive days of clean logs.&lt;/p&gt;

&lt;p&gt;Every automation pipeline I run in production started as a workflow of five nodes that survived a full week untouched before growing to fifty nodes. The pattern is boring and it works. Boring pipelines are the ones that stay unbanned.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Affiliate disclosure: This post contains affiliate links. If you sign up for a paid BitBrowser or BitCloudPhone plan through the links above, I may earn a small commission at no additional cost to you. All numbers above come from workflows I run on my own paid accounts.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>n8nbrightdatachallenge</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Running Playwright Across 100 Cloud Phone Devices with BitBrowser API</title>
      <dc:creator>Claude DEL</dc:creator>
      <pubDate>Fri, 21 Aug 2026 00:09:06 +0000</pubDate>
      <link>https://dev.to/claudedel/running-playwright-across-100-cloud-phone-devices-with-bitbrowser-api-2plm</link>
      <guid>https://dev.to/claudedel/running-playwright-across-100-cloud-phone-devices-with-bitbrowser-api-2plm</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvu05zob84gewkhnkhsuk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvu05zob84gewkhnkhsuk.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;TL;DR&lt;/strong&gt; — A single Playwright process on a laptop caps at 6–8 concurrent Chromium instances before memory pressure kills throughput. To run 100 real-device sessions in parallel, you offload the browser and the device fingerprint to a cloud phone fleet, keep Playwright as the orchestrator, and let the BitBrowser API allocate profiles per session. This guide walks the full stack: architecture, provisioning, session pool, proxy assignment, retry logic, monitoring, and the actual monthly cost.&lt;/p&gt;


&lt;h2&gt;
  
  
  Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Why a laptop can't do this&lt;/li&gt;
&lt;li&gt;The stack in one diagram&lt;/li&gt;
&lt;li&gt;Three pieces you actually need&lt;/li&gt;
&lt;li&gt;Provisioning 100 cloud phones from a script&lt;/li&gt;
&lt;li&gt;Connecting Playwright to Android and iOS&lt;/li&gt;
&lt;li&gt;Session pool and queue pattern&lt;/li&gt;
&lt;li&gt;Proxy assignment: one exit IP per device, always&lt;/li&gt;
&lt;li&gt;Concurrency ceilings that actually hold&lt;/li&gt;
&lt;li&gt;Retry logic that doesn't burn accounts&lt;/li&gt;
&lt;li&gt;Monitoring 100 devices without a NOC&lt;/li&gt;
&lt;li&gt;Real 2026 cost breakdown&lt;/li&gt;
&lt;li&gt;Pitfalls that took me weeks to debug&lt;/li&gt;
&lt;li&gt;FAQ&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  Why a laptop can't do this
&lt;/h2&gt;

&lt;p&gt;I tried the naive setup first. A Ryzen 9 with 64 GB of RAM, headless Chromium, 30 Playwright contexts, all pointed at the same TikTok flow. Around context 8 the OOM killer hit. At context 12 the whole machine froze.&lt;/p&gt;

&lt;p&gt;The problem isn't Playwright. Every context runs a full Chromium process with its own JS engine, GPU sandbox, and cache. Add a residential proxy per context and you pay network latency on every request. Add a real device fingerprint and you can't do it at all. A laptop only has one WebGL vendor string, one AudioContext hash, one canvas.&lt;/p&gt;

&lt;p&gt;Cloud phones fix both problems in one move. Each phone is a real ARM device with its own hardware fingerprint. The browser runs on the phone. Your laptop only sends commands and receives DOM events. RAM stays free.&lt;/p&gt;
&lt;h2&gt;
  
  
  The stack in one diagram
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────────┐    WebSocket / CDP    ┌──────────────────────┐
│  Playwright  │◄──────────────────────►│  Cloud Phone (x100)  │
│  Orchestrator│                        │  Android or iOS      │
│  (Node.js)   │                        │  Chrome / Safari     │
└──────┬───────┘                        └──────────┬───────────┘
       │                                           │
       │ REST                                      │ Egress
       ▼                                           ▼
┌──────────────┐                        ┌──────────────────────┐
│  BitBrowser  │                        │  Mobile/Residential  │
│  Local API   │                        │  Proxy (per device)  │
└──────────────┘                        └──────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The orchestrator holds the queue and the session pool. It talks to each cloud phone through a WebSocket that bridges to the on-device browser via the Chrome DevTools Protocol. It calls the BitBrowser local API to spin up isolated profiles for the browser-only tasks that don't need a physical device (email signup, admin panels, affiliate dashboards). Every device gets its own outbound proxy assigned once and reused for the lifetime of the account.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three pieces you actually need
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://www.bitbrowser.net" rel="noopener noreferrer"&gt;BitBrowser&lt;/a&gt; runs the browser-side fingerprints: canvas, WebGL, WebRTC, fonts, ClientRects, AudioContext, timezone, screen. Its local REST API lets you create, open, close, and rotate profiles from Node.js in about 200 ms per profile.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.bitbrowser.net/cloudphone" rel="noopener noreferrer"&gt;BitCloudPhone&lt;/a&gt; handles the Android side. Each instance is a real ARM device sitting in a data-center rack, not an emulator, so Play Integrity API returns MEETS_DEVICE_INTEGRITY and TikTok, Instagram, and Facebook don't flag the session as an emulator.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.bitbrowser.net/cloudphone-ios" rel="noopener noreferrer"&gt;BitCloudPhone iOS&lt;/a&gt; covers the Apple side. Physical iPhones in a rack, exposed over a WebDriverAgent endpoint. This is the only reliable way to run iMessage-linked, iCloud-locked, or Snapchat US accounts from outside the US without device-attestation failures.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Playwright is the glue. It uses &lt;code&gt;chromium.connectOverCDP()&lt;/code&gt; for Android Chrome and an Appium bridge for iOS Safari.&lt;/p&gt;
&lt;h2&gt;
  
  
  Provisioning 100 cloud phones from a script
&lt;/h2&gt;

&lt;p&gt;You don't click "Create device" 100 times. The provider exposes a REST API that returns device IDs and connection endpoints. A minimal batch provisioner in Node.js:&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;fetch&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;node-fetch&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;CLOUD_API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.your-provider.tld/v1&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;TOKEN&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;CLOUD_PHONE_TOKEN&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;provisionBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;us-east-1&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;devices&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&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="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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;CLOUD_API&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/devices`&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;Authorization&lt;/span&gt;&lt;span class="dl"&gt;'&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;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="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;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="na"&gt;os&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;android&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pixel_7&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;proxy_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="c1"&gt;// assigned separately&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;devices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&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;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// rate-limit courtesy&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;devices&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;fleet&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;provisionBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&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="s2"&gt;`Provisioned &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fleet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; devices`&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 this snippet gets right that the naive version won't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;500 ms delay between calls. Cloud APIs rate-limit at around 5–10 req/s. Batching without a delay gets you a 429 and a partial fleet.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;proxy_id: null&lt;/code&gt; at creation. Attaching the proxy after the device is running lets you rotate proxies without device reboots.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Store the returned device IDs in Redis or a Postgres table. You'll refer to them every time you dispatch a job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting Playwright to Android and iOS
&lt;/h2&gt;

&lt;p&gt;Playwright doesn't natively understand a remote Android Chrome. What it does understand is the Chrome DevTools Protocol. Every cloud phone provider exposes a WebSocket URL that proxies CDP messages to the on-device Chrome instance.&lt;/p&gt;

&lt;p&gt;For Android:&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="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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;attachToAndroid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deviceId&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;wsEndpoint&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;getCdpEndpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deviceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// provider-specific&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="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="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;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;page&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;For iOS, Playwright can't drive Safari over CDP because Safari doesn't speak it. You bridge through Appium instead:&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;remote&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;webdriverio&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;attachToIos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deviceId&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;client&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;remote&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`ios-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;deviceId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.your-provider.tld`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4723&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;/wd/hub&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;capabilities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;platformName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;iOS&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;appium:automationName&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;XCUITest&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;appium:bundleId&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;com.apple.mobilesafari&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;return&lt;/span&gt; &lt;span class="nx"&gt;client&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;You lose some Playwright niceties on iOS (video recording, first-class trace viewer). You gain the ability to run Safari-only flows against real iPhones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Session pool and queue pattern
&lt;/h2&gt;

&lt;p&gt;100 devices doesn't mean 100 always-open sessions. Most jobs take 30–90 seconds. A pool with checkout/return semantics keeps utilization high without leaking browsers.&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;class&lt;/span&gt; &lt;span class="nc"&gt;DevicePool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;devices&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;available&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;devices&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inUse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;waiters&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="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;acquire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timeoutMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30000&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;available&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&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;device&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;available&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inUse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;device&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="nx"&gt;device&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;device&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&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;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;waiters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;waiters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&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;pool acquire timeout&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;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;waiters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timer&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;release&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;device&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inUse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;device&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;waiters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&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;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;waiters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inUse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;device&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="nx"&gt;device&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;device&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;available&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;device&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrap every job:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;task&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;device&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;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;acquire&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="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;attachToAndroid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;device&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;task&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="nx"&gt;device&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="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;device&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;The &lt;code&gt;finally&lt;/code&gt; block is not optional. Any thrown error inside the task leaks the device otherwise, and after 5–10 leaks your pool is dead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proxy assignment: one exit IP per device, always
&lt;/h2&gt;

&lt;p&gt;The single biggest mistake in multi-account automation is rotating the proxy per session on the same account. TikTok, Instagram, and Facebook read the IP + country + ASN triple and log it as part of the account's history. A US mobile carrier ASN on Monday and a Brazilian datacenter ASN on Tuesday is a ban signal, not fingerprint noise.&lt;/p&gt;

&lt;p&gt;Pin the proxy to the device at provisioning time. Store the mapping. Never rotate unless the account is being reset.&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;assignProxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deviceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;proxyEndpoint&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;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;CLOUD_API&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/devices/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;deviceId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/proxy`&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;PUT&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;Authorization&lt;/span&gt;&lt;span class="dl"&gt;'&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;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="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="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;socks5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;proxyEndpoint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;proxyEndpoint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;proxyEndpoint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;proxyEndpoint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;password&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;Mobile 4G/5G proxies cost more (roughly $3 to $8 per GB in 2026), but they match what TikTok Shop, Snapchat, and Reddit's newer detection expects to see on a phone-shaped User-Agent. Residential works too at $2 to $4 per GB, but bans arrive faster on the strictest platforms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Concurrency ceilings that actually hold
&lt;/h2&gt;

&lt;p&gt;Just because you have 100 devices doesn't mean you can run 100 parallel actions on the same target platform. Real ceilings I've hit on TikTok Shop from a 100-device fleet:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action type&lt;/th&gt;
&lt;th&gt;Safe concurrency&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;Product page views&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;Read-only, no signal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cart additions&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;Bursts above 60 trigger challenge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Account creation&lt;/td&gt;
&lt;td&gt;8 to 12&lt;/td&gt;
&lt;td&gt;Higher = SMS verification wall&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live viewer join&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;Live rooms count IPs closely&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Comment posting&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;Comment velocity is heavily rate-limited&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Enforce these at the queue level, not the pool level. A semaphore per target platform works:&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;pLimit&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;p-limit&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;tiktokLimit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pLimit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// account creation&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;igLimit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pLimit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&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;tiktokLimit&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;runJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;createTikTokAccount&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Retry logic that doesn't burn accounts
&lt;/h2&gt;

&lt;p&gt;Naive retry logic is the second-fastest way to lose accounts. If a Playwright action fails, the wrong response is to hammer the same account with 3 retries in 30 seconds. That pattern looks like a bot on any behavioral-scoring platform.&lt;/p&gt;

&lt;p&gt;The rules I follow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Transient network errors&lt;/strong&gt; (ECONNRESET, WebSocket close): retry up to 2 times, with 5 s and 20 s backoff, on the same device.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP 429 or platform "slow down" screen&lt;/strong&gt;: park the device for 15 minutes. Don't move the job to a fresh device. The platform's rate limit is per-account, not per-IP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP 403 or account-flag screen&lt;/strong&gt;: quarantine the device. Log it. Don't retry. Investigate before dispatching to that account again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Element-not-found&lt;/strong&gt;: retry once after a 3 s wait, then fail. UI changes are the number-one cause and retrying harder won't fix a moved selector.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;withRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;maxAttempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delayMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5000&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;lastErr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;attempt&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="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fn&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="nx"&gt;lastErr&lt;/span&gt; &lt;span class="o"&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;if &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;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="o"&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;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;403&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;maxAttempts&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delayMs&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;attempt&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="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;lastErr&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;h2&gt;
  
  
  Monitoring 100 devices without a NOC
&lt;/h2&gt;

&lt;p&gt;A dashboard doesn't need Grafana on day one. A single Postgres table plus a Slack webhook gets you 80% of what you need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;device_health&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;device_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;last_heartbeat&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;last_job_status&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;quarantined&lt;/span&gt; &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;proxy_endpoint&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every job writes its status. A cron task every 5 minutes checks for devices with no heartbeat in 15 minutes and posts to Slack. When a device gets quarantined 3 times in 24 hours, page yourself.&lt;/p&gt;

&lt;p&gt;My last fleet hit around 97% uptime across 100 devices. The 3% loss was nearly always proxy provider issues, not the cloud phones. A &lt;a href="https://hackmd.io/@DigitalGrowthPro/Playwright-with-BitBrowser" rel="noopener noreferrer"&gt;Playwright with BitBrowser reference&lt;/a&gt; covers the browser-side monitoring in more depth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real 2026 cost breakdown
&lt;/h2&gt;

&lt;p&gt;Numbers from a fleet I ran through Q2 2026, 100 devices, 24/7:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Line item&lt;/th&gt;
&lt;th&gt;Monthly cost (USD)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;100× Android cloud phones (mid-tier)&lt;/td&gt;
&lt;td&gt;~$1,200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20× iOS cloud phones (US-restricted apps)&lt;/td&gt;
&lt;td&gt;~$800&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mobile proxies (avg 4 GB/device/month)&lt;/td&gt;
&lt;td&gt;~$1,600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BitBrowser subscription (team tier)&lt;/td&gt;
&lt;td&gt;~$200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Managed Postgres + Redis (small)&lt;/td&gt;
&lt;td&gt;~$60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node.js orchestrator VPS (8 vCPU)&lt;/td&gt;
&lt;td&gt;~$80&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$3,940&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cost per active session-hour: about $0.055. That's the number to beat if you're evaluating build-vs-buy on a managed farm.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls that took me weeks to debug
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;CDP WebSocket timeouts silently corrupt state.&lt;/strong&gt; If the connection drops mid-action, Playwright keeps the context object valid client-side but the browser is already gone. Heartbeat the CDP connection every 30 s and rebuild on failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time-zone drift.&lt;/strong&gt; Cloud phones default to the datacenter's time zone. If your proxy exit is in Los Angeles and the device reports Asia/Shanghai, every Facebook login triggers a review. Set device time zone to match the proxy country at provisioning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chromium version mismatch.&lt;/strong&gt; Playwright pins a specific Chromium build. The on-device Chrome updates on its own schedule. Some CDP calls disappear or change signature between versions. Pin the on-device Chrome, or handle version detection in your CDP layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;iOS keychain persistence.&lt;/strong&gt; iOS cloud phones sometimes retain Apple ID sessions across "reset" operations. Verify keychain clearance manually the first few times you rotate accounts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Silent throttling from the cloud provider.&lt;/strong&gt; After ~50 concurrent CDP connections to the same billing account, some providers throttle without a status code. Split fleets across two accounts if you exceed 50 concurrent sessions.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can I use free BrowserStack or Sauce Labs sessions instead of paid cloud phones?&lt;/strong&gt;&lt;br&gt;
No. Both platforms mark their sessions as testing devices in the User-Agent and network headers. TikTok, Instagram, and Facebook fingerprint them within one page load. Paid cloud phone providers give you real consumer devices with clean signatures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How is this different from Playwright's built-in device emulation?&lt;/strong&gt;&lt;br&gt;
Device emulation only changes the User-Agent, viewport, and touch flag. Canvas, WebGL, AudioContext, and hardware sensors still report desktop values. Any platform that checks two or more of those signals flags the session as spoofed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need BitBrowser if I run everything on cloud phones?&lt;/strong&gt;&lt;br&gt;
For pure mobile flows, not always. Most real automation mixes browser-based tasks (email signup, admin panels, affiliate networks) with mobile-only tasks. Keeping profile isolation consistent between the two sides is easier when the same tool manages both fingerprint layers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the minimum viable version of this stack?&lt;/strong&gt;&lt;br&gt;
10 Android cloud phones, one shared BitBrowser account, a single-process Node.js orchestrator, no Redis. That gets you 5–7 parallel jobs and costs under $500/month. Scale from there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will TikTok or Instagram detect Playwright itself?&lt;/strong&gt;&lt;br&gt;
Playwright leaves &lt;code&gt;navigator.webdriver = true&lt;/code&gt; by default. Launch with &lt;code&gt;--disable-blink-features=AutomationControlled&lt;/code&gt; and remove the flag with an init script. Also patch &lt;code&gt;navigator.plugins.length&lt;/code&gt; and &lt;code&gt;chrome.runtime&lt;/code&gt; to match a fresh Chrome install.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Android or iOS cloud phones — which one first?&lt;/strong&gt;&lt;br&gt;
Android for volume ops (TikTok, Instagram, Facebook, WhatsApp, Telegram). iOS only when the target app requires it (Snapchat US, iMessage-linked dating apps, BeReal, Cash App US). iOS is roughly 3× the per-device cost.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: this post contains affiliate links. If you sign up through them I may receive a commission at no extra cost to you. I've used every tool referenced here on production workloads.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>playwright</category>
      <category>automation</category>
      <category>node</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
