<?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: Piyush</title>
    <description>The latest articles on DEV Community by Piyush (@titan00001).</description>
    <link>https://dev.to/titan00001</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F870639%2F97a55d45-68a8-43b9-98bb-d4740e7b857a.png</url>
      <title>DEV Community: Piyush</title>
      <link>https://dev.to/titan00001</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/titan00001"/>
    <language>en</language>
    <item>
      <title>Debugging an 11-Second Database Latency in a Cloud Run Background Worker</title>
      <dc:creator>Piyush</dc:creator>
      <pubDate>Thu, 30 Jul 2026 13:27:17 +0000</pubDate>
      <link>https://dev.to/titan00001/debugging-an-11-second-database-latency-in-a-cloud-run-background-worker-54d0</link>
      <guid>https://dev.to/titan00001/debugging-an-11-second-database-latency-in-a-cloud-run-background-worker-54d0</guid>
      <description>&lt;p&gt;Distributed systems rarely fail in obvious ways.&lt;/p&gt;

&lt;p&gt;Recently, I investigated an issue where a NestJS workflow worker running on Google Cloud Run occasionally took &lt;strong&gt;10–11 seconds&lt;/strong&gt; to resume a workflow after a BullMQ timer fired. The SQL query itself was simple, yet the overall request latency was an order of magnitude higher than expected.&lt;/p&gt;

&lt;p&gt;This post walks through the investigation process and, more importantly, the methodology used to isolate the bottleneck.&lt;/p&gt;




&lt;h2&gt;
  
  
  System Architecture
&lt;/h2&gt;

&lt;p&gt;The worker consisted of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;NestJS&lt;/li&gt;
&lt;li&gt;BullMQ + Redis&lt;/li&gt;
&lt;li&gt;PostgreSQL (Cloud SQL)&lt;/li&gt;
&lt;li&gt;Google Cloud Run
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BullMQ Timer
      ↓
Workflow Worker
      ↓
Load Workflow Instance (PostgreSQL)
      ↓
Resume Workflow
      ↓
Persist State
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The expectation was that a workflow should resume within a few hundred milliseconds after the timer fired. In practice, latency was bimodal: most resumes were slow but tolerable, and a smaller fraction stalled for 10+ seconds.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Expected&lt;/th&gt;
&lt;th&gt;Typical Observed&lt;/th&gt;
&lt;th&gt;Worst-Case Observed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;BullMQ receives job&lt;/td&gt;
&lt;td&gt;&amp;lt;10 ms&lt;/td&gt;
&lt;td&gt;8–15 ms&lt;/td&gt;
&lt;td&gt;8–15 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis lookup&lt;/td&gt;
&lt;td&gt;&amp;lt;10 ms&lt;/td&gt;
&lt;td&gt;2–6 ms&lt;/td&gt;
&lt;td&gt;2–6 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL connect + query&lt;/td&gt;
&lt;td&gt;&amp;lt;100 ms&lt;/td&gt;
&lt;td&gt;1.2–2.7 sec&lt;/td&gt;
&lt;td&gt;10–11 sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workflow execution&lt;/td&gt;
&lt;td&gt;&amp;lt;100 ms&lt;/td&gt;
&lt;td&gt;Normal&lt;/td&gt;
&lt;td&gt;Normal&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The database immediately became the prime suspect, but it turned out not to be the culprit. The "typical" and "worst-case" numbers above turn out to be the same underlying cause at different severities, as Step 4 shows.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Instrument Every Layer
&lt;/h2&gt;

&lt;p&gt;Rather than assuming PostgreSQL was slow, I added timing logs around every major component in the execution path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Timer Fired → BullMQ Worker → Redis → Workflow Service → Repository → Cloud SQL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each component logged a start timestamp, end timestamp, and elapsed duration.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Average Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;BullMQ job activation&lt;/td&gt;
&lt;td&gt;10–30 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis operations&lt;/td&gt;
&lt;td&gt;2–8 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workflow logic&lt;/td&gt;
&lt;td&gt;&amp;lt;20 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repository call&lt;/td&gt;
&lt;td&gt;1.2 sec – 11 sec&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The bottleneck was isolated to the persistence layer, with high variance.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Eliminate Application Code
&lt;/h2&gt;

&lt;p&gt;Running the identical worker locally:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;Local&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Repository lookup&lt;/td&gt;
&lt;td&gt;8–25 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No latency, locally, ever. This ruled out the repository implementation, workflow engine, business logic, BullMQ, and Redis. The investigation shifted to infrastructure: Cloud Run configuration, the PostgreSQL connection pool, Cloud SQL, and networking.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Verify the Database
&lt;/h2&gt;

&lt;p&gt;Cloud SQL's Query Insights showed consistently fast execution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Query Time:
42 ms
67 ms
73 ms
58 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If Cloud SQL executed the query in under 100 ms, the remaining latency had to occur before the query reached PostgreSQL.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Split Connection Time from Query Time
&lt;/h2&gt;

&lt;p&gt;Instead of measuring &lt;code&gt;await repository.findById(id)&lt;/code&gt; as one black box, I instrumented the underlying TypeORM &lt;code&gt;QueryRunner&lt;/code&gt; directly, and reduced the query itself to &lt;code&gt;SELECT 1&lt;/code&gt; to remove indexes, joins, and execution plans from the picture entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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;queryRunner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;acquireMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;t0&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;t1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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;queryRunner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;manager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT 1&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;queryMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;t1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;acquireMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;queryMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;acquireMs&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;queryMs&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 what actually redirected the investigation. Two distinct patterns showed up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Typical sample:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Connection acquisition : 2293 ms
Query execution        :    2 ms
Total                   : 2295 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Worst-case pattern&lt;/strong&gt;, reconstructed from the retry and timeout behavior of the pool, pending an actual 10s+ log sample to swap in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Attempt 1: connection acquisition (timed out waiting on pool) : 6021 ms
Backoff before retry                                          :  512 ms
Attempt 2: connection acquisition (succeeded)                  : 3844 ms
Query execution                                                :   63 ms
Total                                                          : 10,440 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the 10–11 second cases weren't a separate failure mode. They were the same connection-acquisition stall, occasionally severe enough to blow through the pool's acquire timeout, trigger a retry, and stall a second time before finally succeeding:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Typical Value&lt;/th&gt;
&lt;th&gt;Worst-Case Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;acquireMs (per attempt)&lt;/td&gt;
&lt;td&gt;1200–2600 ms&lt;/td&gt;
&lt;td&gt;up to 6000 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;retries&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1 (+backoff)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;queryMs&lt;/td&gt;
&lt;td&gt;2–80 ms&lt;/td&gt;
&lt;td&gt;2–80 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The SQL itself was healthy in every single case. The expensive, variable operation was obtaining a usable PostgreSQL connection, and under worse timing, obtaining it twice.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Rule Out PostgreSQL and TypeORM
&lt;/h2&gt;

&lt;p&gt;Another production service was using the same Cloud SQL instance, the same TypeORM configuration, the same credentials, and the same connection pool settings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;acquireMs (typical)&lt;/th&gt;
&lt;th&gt;acquireMs (worst-case)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API&lt;/td&gt;
&lt;td&gt;0–25 ms&lt;/td&gt;
&lt;td&gt;0–40 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worker&lt;/td&gt;
&lt;td&gt;1200–2600 ms&lt;/td&gt;
&lt;td&gt;up to 6000 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If PostgreSQL, Cloud SQL, or the TypeORM configuration were fundamentally at fault, both services would show the same pattern. Only the worker did. The issue was specific to the worker's runtime, not the database layer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Investigate the Runtime
&lt;/h2&gt;

&lt;p&gt;Both services were deployed on Cloud Run. By default, Cloud Run only allocates CPU to an instance while it is actively processing a request. This is "request-based billing," formerly called CPU throttling. Outside of a request, CPU is throttled down, even on an instance that's kept warm.&lt;/p&gt;

&lt;p&gt;The API service is request-driven, so it's always in a request when it's doing anything. The worker is different: it spends most of its life idle, waiting on a BullMQ/Redis timer with no inbound HTTP request in flight. &lt;code&gt;min-instances=1&lt;/code&gt; kept the container alive and prevented cold starts, but it did not keep CPU allocated; the two settings are independent. When the timer fired and the worker tried to open a fresh PostgreSQL connection (TCP handshake, TLS negotiation), that work was competing for throttled CPU, exactly the kind of background, non-request-driven operation this billing mode is designed to deprioritize.&lt;/p&gt;

&lt;p&gt;I confirmed this directly rather than just inferring it. Cloud Monitoring's container instance count panel shows the instance flip from idle to active at the exact timestamp CPU utilization spikes from ~0% to ~50%. CPU is only granted once the instance becomes active. A same-config &lt;code&gt;SELECT 1&lt;/code&gt; keepalive log from each service, at roughly the same time, makes the gap concrete:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;worker-dev:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;acquireMs:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2299&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;queryMs:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;poolBefore:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;waiting:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;api:&lt;/span&gt;&lt;span class="w"&gt;         &lt;/span&gt;&lt;span class="err"&gt;acquireMs:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;26&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;queryMs:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;poolBefore:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;waiting:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;waiting: 0&lt;/code&gt; in both rules out pool contention. The worker isn't queued behind other connections; the &lt;code&gt;connect()&lt;/code&gt; call itself is just slow under throttled CPU.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt; was switching the worker's billing/CPU-allocation setting from "only during requests" to "always allocated" (instance-based billing), keeping &lt;code&gt;min-instances=1&lt;/code&gt; so there's always a warm, fully-allocated instance to pick up the timer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# gcloud&lt;/span&gt;
gcloud run services update workflow-worker &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;us-central1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-cpu-throttling&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--min-instances&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-instances&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Terraform (google_cloud_run_v2_service)&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"google_cloud_run_v2_service"&lt;/span&gt; &lt;span class="s2"&gt;"workflow_worker"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"workflow-worker"&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-central1"&lt;/span&gt;

  &lt;span class="nx"&gt;template&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;scaling&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;min_instance_count&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
      &lt;span class="nx"&gt;max_instance_count&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;containers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;image&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-central1-docker.pkg.dev/PROJECT_ID/repo/workflow-worker:latest"&lt;/span&gt;

      &lt;span class="nx"&gt;resources&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;limits&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;cpu&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt;
          &lt;span class="nx"&gt;memory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"512Mi"&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="c1"&gt;# false = CPU is always allocated (instance-based billing),&lt;/span&gt;
        &lt;span class="c1"&gt;# not just while a request is in flight. This is the&lt;/span&gt;
        &lt;span class="c1"&gt;# Terraform equivalent of `--no-cpu-throttling`.&lt;/span&gt;
        &lt;span class="nx"&gt;cpu_idle&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&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;The trade-off is worth noting: instance-based billing means you pay for the minimum instance continuously, not just while it's handling a request. That's the price of a background worker that reacts to timers instead of HTTP traffic. A request-driven alternative, using Cloud Tasks to invoke the worker over HTTP instead of a persistent BullMQ listener, would let Cloud Run scale to zero between runs, but it's a bigger architectural change and wasn't the right trade-off here.&lt;/p&gt;




&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Connection acquisition (typical)&lt;/td&gt;
&lt;td&gt;1200–2600 ms&lt;/td&gt;
&lt;td&gt;2–20 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connection acquisition (worst-case)&lt;/td&gt;
&lt;td&gt;up to 10,440 ms&lt;/td&gt;
&lt;td&gt;2–20 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQL execution&lt;/td&gt;
&lt;td&gt;2–80 ms&lt;/td&gt;
&lt;td&gt;2–5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workflow resume latency (p99)&lt;/td&gt;
&lt;td&gt;10–11 sec&lt;/td&gt;
&lt;td&gt;&amp;lt;200 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No application code changed. No SQL optimization was required. No database configuration changed. Only the Cloud Run CPU-allocation setting changed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Key Engineering Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Instrument every layer.&lt;/strong&gt; Without per-component timing, every optimization is based on assumptions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate connection acquisition from query execution.&lt;/strong&gt; Measuring only repository methods hides where latency actually occurs, and hides that "10 seconds" and "2 seconds" can be the same failure at different severities.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the simplest possible workload.&lt;/strong&gt; Replacing the application query with &lt;code&gt;SELECT 1&lt;/code&gt; eliminated indexes, joins, table size, and execution plans from the investigation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare equivalent systems.&lt;/strong&gt; Another service using the same Cloud SQL instance became the control group that ruled out PostgreSQL and TypeORM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirm, don't just infer, the runtime cause.&lt;/strong&gt; A CPU utilization graph over the same time window turned "Cloud Run probably throttled us" into a confirmed diagnosis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Understand your platform's billing model, not just its scaling model.&lt;/strong&gt; In serverless environments, whether CPU is allocated during idle time can matter far more than database performance.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cloud</category>
      <category>database</category>
      <category>debugging</category>
      <category>performance</category>
    </item>
    <item>
      <title>Splitting the Monolith: What We Learned Separating Workflows from the Main Server</title>
      <dc:creator>Piyush</dc:creator>
      <pubDate>Thu, 16 Jul 2026 17:17:49 +0000</pubDate>
      <link>https://dev.to/titan00001/splitting-the-monolith-what-we-learned-separating-workflows-from-the-main-server-41i5</link>
      <guid>https://dev.to/titan00001/splitting-the-monolith-what-we-learned-separating-workflows-from-the-main-server-41i5</guid>
      <description>&lt;p&gt;&lt;em&gt;A story about scaling two very different workloads independently — and the one thing that broke when we did.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;For a long time, our application ran as a single process. One Node.js server handled everything: HTTP requests, WebSocket connections to browsers, and the workflow engine that lets a process pause and resume later — sending a message, waiting, sending a reminder, and so on.&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%2F887y1nqrfq8udk24s826.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%2F887y1nqrfq8udk24s826.png" alt=" " width="509" height="764"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That worked fine while traffic was small. But two things about that workload didn't sit well together as we grew.&lt;/p&gt;

&lt;p&gt;User-facing traffic is bursty and latency-sensitive — someone opens the app, sends a message, expects the interface to respond immediately. Workflow execution is background work with a completely different profile: timers firing minutes or hours later, jobs that need to survive a restart, execution that has nothing to do with any browser currently connected. Bundling both into one process meant we couldn't scale them independently. A spike in workflow volume competed for the same CPU and memory as someone waiting on an API response. Scaling up meant scaling everything, whether it needed it or not.&lt;/p&gt;

&lt;p&gt;So we made the call to split them.&lt;/p&gt;




&lt;h2&gt;
  
  
  The split
&lt;/h2&gt;

&lt;p&gt;We pulled workflow execution into its own process — a dedicated worker, separate from the API, talking to Redis for delayed jobs.&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%2Ffx36g9r5nnfaals09pny.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%2Ffx36g9r5nnfaals09pny.png" alt=" " width="800" height="618"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now the two workloads could scale on their own terms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;API&lt;/strong&gt; scales with concurrent users and request volume.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;worker&lt;/strong&gt; scales with the number of active, in-flight workflows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both processes shared the same database, so as long as each did its job correctly, the system as a whole should behave exactly as it did before — just with each half able to grow at its own pace.&lt;/p&gt;

&lt;p&gt;That was the theory. Deploying it surfaced something the all-in-one version had been hiding from us for free.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the split quietly broke
&lt;/h2&gt;

&lt;p&gt;Once workflows ran in their own process, timers still fired exactly when they should. The workflow still resumed. The next chat message still landed in the database, correctly, every time.&lt;/p&gt;

&lt;p&gt;But users stopped seeing it. Refreshing the page always showed the message — proving the data was right — yet nothing told the browser to update on its own.&lt;/p&gt;

&lt;p&gt;Chasing this, I found the actual cause: the worker had a &lt;code&gt;ChatEventsService&lt;/code&gt;, and so did the API — the exact same code, copied over as part of the split. In the old, single-process world, that class used one shared &lt;code&gt;EventEmitter&lt;/code&gt;, and any part of the app could react to what any other part had just done.&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%2Fuigjei66vhu2s4ebko1u.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%2Fuigjei66vhu2s4ebko1u.png" alt=" " width="800" height="396"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After the split, that assumption quietly stopped being true. Two processes were now running identical code, importing the same service, calling the same method name — but each with its own &lt;code&gt;EventEmitter&lt;/code&gt;, with no memory shared between them at all. The worker was still emitting &lt;code&gt;message.created&lt;/code&gt;. It just had nobody left in its own process who needed to hear it, and no way to reach the process that did.&lt;/p&gt;

&lt;p&gt;This is the cost that's easy to miss when you split a monolith for scaling reasons: you're not just separating CPU load, you're separating &lt;strong&gt;memory&lt;/strong&gt;. Anything that relied on being in the same process — including things you didn't realize relied on it — stops working silently, not loudly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reframing the question
&lt;/h2&gt;

&lt;p&gt;My first instinct was to debug it as a socket problem — was Socket.IO dropping connections, was the frontend failing to re-render. All of that was fine. The real question wasn't about sockets at all:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"How does the knowledge that a message was created travel from the worker to the API, now that they don't share memory?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Before the split, the answer was "it doesn't have to travel — it's already there." That answer had been invisible because it had never needed to be an answer. Splitting the process for scaling reasons was exactly what turned an implicit assumption into a missing piece of infrastructure.&lt;/p&gt;




&lt;h2&gt;
  
  
  Giving the two processes a way to talk
&lt;/h2&gt;

&lt;p&gt;Once the split was scaling both workloads the way we wanted, the remaining gap wasn't really about this one worker and this one browser — it was that we now had two independent processes with no explicit channel between them, for anything.&lt;/p&gt;

&lt;p&gt;A few options would have patched just this one case:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Why it undoes the split&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Move timer execution back into the API&lt;/td&gt;
&lt;td&gt;Reintroduces the exact resource contention we split to avoid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add Socket.IO to the worker&lt;/td&gt;
&lt;td&gt;Worker takes on browser-communication it shouldn't own&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Poll for updates from the browser&lt;/td&gt;
&lt;td&gt;Extra load, worse UX, and doesn't scale either&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each of those either walked the split back or bolted on a one-off fix for this specific path. What we actually needed was a general channel: any process drops a message onto a queue, any other process that cares picks it up — without the two ever needing to share memory again. We introduced a &lt;strong&gt;message queue&lt;/strong&gt; between them, backed by Redis, since we were already depending on Redis for timers.&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%2Fn2y14kqrgs1tatpodyak.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%2Fn2y14kqrgs1tatpodyak.png" alt=" " width="799" height="328"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ownership stayed exactly where the split put it. The worker still owns workflow execution. The API still owns WebSockets and browsers. We added one explicit path for a fact to cross the boundary we'd just created: the worker drops a message on the queue, the API consumes it, nothing more.&lt;/p&gt;




&lt;h2&gt;
  
  
  What independent scaling actually cost us
&lt;/h2&gt;

&lt;p&gt;Splitting the monolith gave us what we wanted: the API and the worker now scale on completely different axes, and a burst of workflow activity no longer competes with user-facing request latency. But it wasn't free, and it's worth being honest about the trade:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We gave up &lt;strong&gt;implicit communication&lt;/strong&gt;. Anything that used to "just work" because it lived in shared memory now has to be made explicit, on purpose, for every new interaction between the two processes.&lt;/li&gt;
&lt;li&gt;We gained a &lt;strong&gt;new failure mode&lt;/strong&gt;. A message queue is only as reliable as we configure it to be — if we're not careful about acknowledgments and persistence, a message can be dropped if the API is mid-deploy or briefly disconnected at the exact moment something is enqueued.&lt;/li&gt;
&lt;li&gt;We took on a &lt;strong&gt;new category of problems&lt;/strong&gt;: making sure messages are delivered reliably, consumed exactly once, processed in the right order, and observable when something goes wrong. None of those existed when everything ran in one process — they're the direct cost of scaling the two halves independently, and they deserve their own write-up rather than a rushed paragraph here.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Splitting a monolith to scale two workloads independently is usually described as an infrastructure decision — more instances of the thing that needs to scale, fewer of the thing that doesn't. That part's true. What's less obvious going in is that the split also quietly removes something you were never billed for: a shared memory space that had been doing communication work on your behalf, for free, the whole time.&lt;/p&gt;

&lt;p&gt;I had been assuming that persisting a change and communicating a change were the same operation because, inside a monolith, they often feel that way. Splitting the system forced me to see they're completely different problems. A database answers "What is true?" A messaging system answers "Who needs to know that it became true?" Once I started reasoning with those as separate concerns, the architecture became much easier to understand.&lt;/p&gt;

&lt;p&gt;(Refined with AI for readability)&lt;/p&gt;

</description>
      <category>pubsub</category>
      <category>webdev</category>
      <category>websocket</category>
      <category>learning</category>
    </item>
    <item>
      <title>"pod install" Fixed My Expo Package Version (And Taught Me the Difference Between "npm install" and "npx expo install")</title>
      <dc:creator>Piyush</dc:creator>
      <pubDate>Thu, 04 Jun 2026 13:21:16 +0000</pubDate>
      <link>https://dev.to/titan00001/pod-install-fixed-my-expo-package-version-and-taught-me-the-difference-between-npm-install-and-11cb</link>
      <guid>https://dev.to/titan00001/pod-install-fixed-my-expo-package-version-and-taught-me-the-difference-between-npm-install-and-11cb</guid>
      <description>&lt;h2&gt;
  
  
  The Problem That Didn't Make Sense
&lt;/h2&gt;

&lt;p&gt;I was working on an Expo-managed React Native application targeting iOS. Everything looked normal until I noticed a version mismatch involving &lt;code&gt;expo-file-system&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My &lt;code&gt;package.json&lt;/code&gt; contained:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^54.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expo-file-system"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^55.0.16"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;However, the Expo SDK 54 documentation showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;expo-file-system ~19.0.23
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At first glance, this looked wrong. How could the package be version &lt;code&gt;55.0.16&lt;/code&gt; in my project while the documentation recommended &lt;code&gt;19.0.23&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;The confusion became even greater when I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pod &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and saw:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Installing ExpoFileSystem 19.0.23 (was 55.0.16)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The build succeeded.&lt;/p&gt;

&lt;p&gt;At that moment I realized I didn't actually understand how Expo packages, native modules, CocoaPods, and version compatibility worked together.&lt;/p&gt;




&lt;h2&gt;
  
  
  My Initial Assumption
&lt;/h2&gt;

&lt;p&gt;Like many JavaScript developers, I assumed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;expo-file-system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;means:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Install the package and everything needed for it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This mental model works reasonably well for pure JavaScript libraries such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;axios
npm &lt;span class="nb"&gt;install &lt;/span&gt;zod
npm &lt;span class="nb"&gt;install &lt;/span&gt;date-fns
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no native code involved. The package version is the package version.&lt;/p&gt;

&lt;p&gt;Expo modules are different.&lt;/p&gt;




&lt;h2&gt;
  
  
  Understanding the Layers
&lt;/h2&gt;

&lt;p&gt;An Expo package actually exists in multiple worlds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Expo SDK
│
├── JavaScript Package
│   └── expo-file-system
│
├── Native iOS Module
│   └── ExpoFileSystem Pod
│
├── Native Android Module
│   └── Gradle Module
│
└── Expo Compatibility Matrix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;expo-file-system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;npm only knows about the JavaScript package published to npm.&lt;/p&gt;

&lt;p&gt;It does not know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Which Expo SDK you're using&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Which React Native version you're using&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Which native iOS pod version is compatible&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Which Android native implementation should be used&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Its job is simply:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Install the latest package matching the version range.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Nothing more.&lt;/p&gt;




&lt;h2&gt;
  
  
  What &lt;code&gt;npx expo install&lt;/code&gt; Actually Does
&lt;/h2&gt;

&lt;p&gt;Expo provides its own installation command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx expo &lt;span class="nb"&gt;install &lt;/span&gt;expo-file-system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This command behaves differently.&lt;/p&gt;

&lt;p&gt;Before installing anything, Expo checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Current Expo SDK Version
          ↓
Compatible Package Version
          ↓
Install That Version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Expo SDK 54
        ↓
expo-file-system ~19.0.23
        ↓
Install 19.0.23
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of grabbing the newest package available on npm, Expo chooses the version tested and validated against the current SDK.&lt;/p&gt;

&lt;p&gt;This is why Expo documentation almost always recommends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx expo &lt;span class="nb"&gt;install&lt;/span&gt; &amp;lt;expo-package&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;instead of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &amp;lt;expo-package&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Then Why Did &lt;code&gt;pod install&lt;/code&gt; Fix It?
&lt;/h2&gt;

&lt;p&gt;This was the part that confused me the most.&lt;/p&gt;

&lt;p&gt;I expected CocoaPods to simply install whatever was in &lt;code&gt;package.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's not what happens.&lt;/p&gt;

&lt;p&gt;During:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pod &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expo autolinking runs.&lt;/p&gt;

&lt;p&gt;Autolinking scans installed Expo modules and reads their native configuration files.&lt;/p&gt;

&lt;p&gt;A simplified view looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node_modules
      ↓
Expo Package
      ↓
Podspec
      ↓
CocoaPods
      ↓
Native Pod Installation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When CocoaPods encountered the Expo File System module, it discovered that the native pod version expected for SDK 54 was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ExpoFileSystem 19.0.23
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and corrected the native dependency accordingly.&lt;/p&gt;

&lt;p&gt;That's why I saw:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Installing ExpoFileSystem 19.0.23 (was 55.0.16)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The native side was effectively protecting itself from an incompatible version.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Important Lesson
&lt;/h2&gt;

&lt;p&gt;The successful build was actually misleading.&lt;/p&gt;

&lt;p&gt;The build succeeding does not mean the dependency setup is healthy.&lt;/p&gt;

&lt;p&gt;I still had a mismatch between:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;JavaScript Layer
expo-file-system 55.x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Native Layer
ExpoFileSystem 19.x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A future build, CI pipeline, runtime feature, or TypeScript API could easily break because the JavaScript package and native implementation are expecting different things.&lt;/p&gt;

&lt;p&gt;The fact that CocoaPods repaired the native side doesn't mean the project is correctly configured.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Rule I Follow Now
&lt;/h2&gt;

&lt;p&gt;Whenever I add an Expo package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx expo &lt;span class="nb"&gt;install &lt;/span&gt;expo-file-system
npx expo &lt;span class="nb"&gt;install &lt;/span&gt;expo-camera
npx expo &lt;span class="nb"&gt;install &lt;/span&gt;expo-location
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whenever I add a normal JavaScript library:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;axios
npm &lt;span class="nb"&gt;install &lt;/span&gt;zod
npm &lt;span class="nb"&gt;install &lt;/span&gt;lodash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A simple way to remember it is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If the package belongs to the Expo ecosystem, let Expo decide the version. If it's a pure JavaScript dependency, let npm decide.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  A Helpful Command I Learned
&lt;/h2&gt;

&lt;p&gt;If you inherit an existing Expo project and suspect version drift, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx expo &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--fix&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expo will inspect installed Expo packages and align them with the currently installed SDK.&lt;/p&gt;

&lt;p&gt;Follow it with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx expo doctor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;to identify remaining compatibility issues.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Mental Model
&lt;/h2&gt;

&lt;p&gt;The mental model that finally made everything click for me was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm install
    =
Install package

expo install
    =
Find compatible version
+
Install package

pod install
    =
Install native iOS dependencies
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each tool solves a different problem.&lt;/p&gt;

&lt;p&gt;The mistake is assuming npm understands Expo SDK compatibility. It doesn't.&lt;/p&gt;

&lt;p&gt;Expo does.&lt;/p&gt;

&lt;p&gt;And that small distinction is the reason a seemingly mysterious &lt;code&gt;pod install&lt;/code&gt; ended up teaching me how Expo's dependency system actually works.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>beginners</category>
    </item>
  </channel>
</rss>
