<?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>Forem: Alex Aslam</title>
    <description>The latest articles on Forem by Alex Aslam (@alex_aslam).</description>
    <link>https://forem.com/alex_aslam</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2607368%2Fb74d2406-bd46-4f49-a4ac-9ebe867ee219.jpeg</url>
      <title>Forem: Alex Aslam</title>
      <link>https://forem.com/alex_aslam</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/alex_aslam"/>
    <language>en</language>
    <item>
      <title>Deadlock hunting: PostgreSQL advisory locks in distributed systems</title>
      <dc:creator>Alex Aslam</dc:creator>
      <pubDate>Tue, 21 Apr 2026 21:10:22 +0000</pubDate>
      <link>https://forem.com/alex_aslam/deadlock-hunting-postgresql-advisory-locks-in-distributed-systems-2hpd</link>
      <guid>https://forem.com/alex_aslam/deadlock-hunting-postgresql-advisory-locks-in-distributed-systems-2hpd</guid>
      <description>&lt;p&gt;&lt;em&gt;Or: How I learned to stop worrying and love the 64-bit integer&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Let me paint you a scene. It's 2:47 AM on a Tuesday. You're the one on call because the "senior" in your title apparently means "person who gets woken up when the database screams." Your pager (yes, we still have pagers, don't judge) goes off. The distributed job scheduler is frozen. Requests are piling up. The metrics dashboard looks like a patient flatlining.&lt;/p&gt;

&lt;p&gt;You've been here before. We all have.&lt;/p&gt;

&lt;p&gt;The culprit? A classic distributed systems deadlock. Process A holds lock on resource X, waits for resource Y. Process B holds lock on resource Y, waits for resource X. Except in a distributed system, "resources" might be database rows, Redis keys, or even just logical operations across three different microservices.&lt;/p&gt;

&lt;p&gt;And PostgreSQL's standard row-level locks? Useless across service boundaries.&lt;/p&gt;

&lt;p&gt;This is where advisory locks saved my sanity. Let me show you how.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem with "normal" locks in a distributed world
&lt;/h2&gt;

&lt;p&gt;After ten years, you know the drill. Standard database locks are tied to transactions, rows, and tables. They're great for what they do. But when you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A cron job that rebalances user data across shards&lt;/li&gt;
&lt;li&gt;A message consumer that processes events for the same entity from different partitions&lt;/li&gt;
&lt;li&gt;Two different services that need to coordinate access to an external API rate limit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;...you quickly realize that row locks don't exist across PostgreSQL connections, let alone across services.&lt;/p&gt;

&lt;p&gt;I once spent three days debugging a deadlock between a payment processor and a refund handler. Two separate services, both using &lt;code&gt;SELECT FOR UPDATE&lt;/code&gt; on different rows in the same table, but the deadlock was happening at the application logic level, not the database level. PostgreSQL had no idea. The logs showed nothing. I aged three years in 72 hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enter advisory locks: the Swiss Army knife you forgot you had
&lt;/h2&gt;

&lt;p&gt;Advisory locks are PostgreSQL's way of saying, "I don't know what you're locking, but I'll remember it for you."&lt;/p&gt;

&lt;p&gt;They're just integers. That's it. A 64-bit bigint. Or two 32-bit ints if you're feeling fancy. The database doesn't care what they represent—a user ID, a shard number, a job ID, a fictional character's social security number. It just knows: "Lock ID 12345 is held by session 789."&lt;/p&gt;

&lt;p&gt;Here's the API you'll use 90% of the time:&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="c1"&gt;-- Try to acquire lock (returns true/false, doesn't wait)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_try_advisory_lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12345&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Block until lock is acquired&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_advisory_lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12345&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Release it&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_advisory_unlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12345&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Check if someone holds it&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_try_advisory_lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12345&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;acquired&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- (if acquired, you need to release immediately or you just stole it)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Session-level locks (what I just showed) live until your connection dies or you explicitly release them. Transaction-level variants (&lt;code&gt;pg_try_advisory_xact_lock&lt;/code&gt;) auto-release on commit/rollback.&lt;/p&gt;

&lt;h2&gt;
  
  
  The distributed deadlock detection pattern
&lt;/h2&gt;

&lt;p&gt;Here's the real art. Not the lock itself—anyone can call &lt;code&gt;pg_try_advisory_lock&lt;/code&gt;. The art is building a deadlock detection system on top of them.&lt;/p&gt;

&lt;p&gt;Think of it as a &lt;strong&gt;lock registry plus heartbeat plus timeout&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: The lock key schema
&lt;/h3&gt;

&lt;p&gt;Don't just pick random numbers. Design a scheme. I use a 64-bit composite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;High 32 bits: resource type (e.g., 0x01 for user, 0x02 for order, 0x03 for job)
Low 32 bits: resource identifier
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So lock for user 42 becomes &lt;code&gt;(1 &amp;lt;&amp;lt; 32) | 42&lt;/code&gt;. This keeps your keys debuggable and prevents accidental collisions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: The heartbeat table
&lt;/h3&gt;

&lt;p&gt;Advisory locks alone don't tell you &lt;em&gt;who&lt;/em&gt; holds the lock or &lt;em&gt;when&lt;/em&gt; they acquired it. So I add a small tracking table:&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;lock_heartbeats&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;lock_key&lt;/span&gt; &lt;span class="nb"&gt;bigint&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;holder_id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;-- service instance ID, pod name, etc.&lt;/span&gt;
    &lt;span class="n"&gt;acquired_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&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="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern: Before acquiring an advisory lock, you check this table. If there's an entry that hasn't expired, someone legitimately holds the lock. If it's expired but the advisory lock still exists? Congratulations, you found a dead or hung process.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: The watchdog
&lt;/h3&gt;

&lt;p&gt;Every 30 seconds, a background job scans for expired heartbeats where the advisory lock is still held. When found, you have two choices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Force release&lt;/strong&gt;: &lt;code&gt;pg_advisory_unlock&lt;/code&gt; (dangerous, but sometimes necessary)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alert and investigate&lt;/strong&gt;: log, notify, and let a human decide&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I prefer the second. Automatically breaking locks in distributed systems is how you get data corruption. But I've worked places where the SLO demanded auto-recovery. Just know the risks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: The deadlock query
&lt;/h3&gt;

&lt;p&gt;Here's the query that saved my career. It finds circular dependencies in your advisory locks by joining the lock table with itself:&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;WITH&lt;/span&gt; &lt;span class="n"&gt;locks&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt;
        &lt;span class="n"&gt;classid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;objid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;lock_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;virtualtransaction&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_locks&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;locktype&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'advisory'&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;granted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lock_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;waiter_pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;blocker_pid&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_locks&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_locks&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objid&lt;/span&gt;  &lt;span class="c1"&gt;-- same lock key&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;locktype&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'advisory'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;locktype&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'advisory'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;granted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;  &lt;span class="c1"&gt;-- a is waiting&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;granted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;   &lt;span class="c1"&gt;-- b holds it&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run this every few seconds on your master database. When you get results, you've found a deadlock &lt;em&gt;before&lt;/em&gt; the timeout kills your user experience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-world example: the job queue nightmare
&lt;/h2&gt;

&lt;p&gt;Let me walk you through a real fix from last year.&lt;/p&gt;

&lt;p&gt;We had a worker pool processing "reconciliation jobs" for 10,000 tenants. Each job needed exclusive access to a tenant's data for 5-10 seconds. We used &lt;code&gt;pg_try_advisory_lock(tenant_id)&lt;/code&gt; at the start of each job.&lt;/p&gt;

&lt;p&gt;The problem: Worker A got tenant 42. Worker B got tenant 99. Worker A tried to update a shared lookup table (which required a lock on tenant 99 for a sub-operation). Worker B tried to update the same lookup table (requiring tenant 42). Neither would release the original tenant lock until the entire job finished.&lt;/p&gt;

&lt;p&gt;Standard deadlock detection didn't catch this because the locks were &lt;em&gt;advisory&lt;/em&gt;, not row locks. PostgreSQL saw two happy sessions holding unrelated integers.&lt;/p&gt;

&lt;p&gt;Our custom detection query caught it in 2 seconds. We logged the PIDs, inspected the stack traces, and realized our sub-operation was using a different lock acquisition order than the main operation. Fixed the code, deployed, slept for the first time in a week.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pitfalls that almost killed me (and will almost kill you)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Advisory locks don't survive connection poolers.&lt;/strong&gt; PgBouncer in transaction pooling mode will change your backend PID between transactions. Your lock vanishes. Use session pooling or direct connections for any service that uses advisory locks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;They're not replicated.&lt;/strong&gt; PostgreSQL logical replication doesn't copy advisory locks. Your read replicas won't know about locks on the primary. Don't query &lt;code&gt;pg_locks&lt;/code&gt; on a replica for deadlock detection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;They can leak.&lt;/strong&gt; If your application crashes between acquiring a lock and releasing it, that lock stays held until the TCP connection times out or you manually clear it. Always set &lt;code&gt;statement_timeout&lt;/code&gt; and &lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt; on the connection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 64-bit key space isn't magic.&lt;/strong&gt; I've seen teams use timestamps as lock keys. Don't. Use deterministic, bounded keys. Hash strings if you must: &lt;code&gt;('tenant:' || tenant_id)::regclass&lt;/code&gt; is a neat trick, but I prefer &lt;code&gt;hashtext('tenant:' || tenant_id) &amp;amp; 4294967295&lt;/code&gt; for 32-bit safety.&lt;/p&gt;

&lt;h2&gt;
  
  
  When NOT to use advisory locks
&lt;/h2&gt;

&lt;p&gt;I'm a pragmatist. Advisory locks are amazing, but they're not always the answer.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you need cross-database coordination, look at etcd or ZooKeeper. Advisory locks are PostgreSQL-only.&lt;/li&gt;
&lt;li&gt;If you need millisecond-level lock acquisition, Redis is faster. PostgreSQL advisory locks have network round trips and transaction overhead.&lt;/li&gt;
&lt;li&gt;If you have fewer than 10 workers, a simple Redis SETNX with TTL is easier to reason about.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But for anything between 10 and 10,000 workers, where consistency matters more than absolute speed, and you're already using PostgreSQL? Advisory locks are your hammer. And this is a nail.&lt;/p&gt;




&lt;h2&gt;
  
  
  The art of the hunt
&lt;/h2&gt;

&lt;p&gt;After a decade of this, I've learned that deadlock hunting isn't about the tools. It's about the story your system tells you.&lt;/p&gt;

&lt;p&gt;The advisory lock is just a witness. The heartbeat table is the timeline. The detection query is the interrogation. You're not writing code—you're building a crime scene investigation unit for your distributed system.&lt;/p&gt;

&lt;p&gt;And sometimes, at 2:47 AM, that's exactly what you need.&lt;/p&gt;

&lt;p&gt;Next time your pager goes off, don't panic. Query &lt;code&gt;pg_locks&lt;/code&gt;. Look for the &lt;code&gt;advisory&lt;/code&gt; rows with &lt;code&gt;granted = false&lt;/code&gt;. Follow the trail. And remember: every deadlock is just two processes that fell in love with the wrong resources.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Biometric Authentication in Turbo Native: The Art of the Invisible Handshake</title>
      <dc:creator>Alex Aslam</dc:creator>
      <pubDate>Sat, 11 Apr 2026 22:02:49 +0000</pubDate>
      <link>https://forem.com/alex_aslam/biometric-authentication-in-turbo-native-the-art-of-the-invisible-handshake-4kmi</link>
      <guid>https://forem.com/alex_aslam/biometric-authentication-in-turbo-native-the-art-of-the-invisible-handshake-4kmi</guid>
      <description>&lt;p&gt;I’ve been writing software long enough to remember when “biometric authentication” meant a sysadmin squinting at a grainy CCTV feed. Twenty years later, I’ve shipped everything from password-on-paper to WebAuthn, and I still got nervous the first time I wired Face ID into a Turbo Native app.&lt;/p&gt;

&lt;p&gt;Why nervous? Because biometrics aren’t a feature. They’re a &lt;em&gt;promise&lt;/em&gt;. A promise that you, the developer, will treat a user’s face or fingerprint with the same care as a bank vault combination. And in Turbo Native—where your Rails backend lives miles away from a &lt;code&gt;LAContext&lt;/code&gt; or &lt;code&gt;BiometricPrompt&lt;/code&gt;—the gap between promise and execution can swallow you whole.&lt;/p&gt;

&lt;p&gt;This is the story of how I learned to bridge that gap. Not with libraries and copy-paste, but with a deliberate, almost architectural &lt;em&gt;art&lt;/em&gt;. Senior full-stack folks who’ve wrestled OAuth flows and slept through JWT debates: this one’s for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Naive Approach That Almost Got Us Sued
&lt;/h2&gt;

&lt;p&gt;Let me paint a picture. First version of our Turbo Native banking app (yes, &lt;em&gt;banking&lt;/em&gt;). Product said: “Just use the native biometric API to unlock the app. No big deal.”&lt;/p&gt;

&lt;p&gt;So we did the obvious:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// iOS: Show Face ID, then just… load the web view&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;LAContext&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluatePolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deviceOwnerAuthenticationWithBiometrics&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="n"&gt;success&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;success&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;webView&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;URLRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dashboardURL&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;Seems fine, right? User authenticates, web view loads. Except the web view had &lt;em&gt;its own&lt;/em&gt; session cookie from a previous password login. And the backend had no idea the user just used biometrics. So when the web view made an API call to &lt;code&gt;/transfer_funds&lt;/code&gt;, the backend saw an old session—valid, but not “biometrically re-verified” for a high-value action.&lt;/p&gt;

&lt;p&gt;We shipped. A week later, a user’s roommate unlocked the phone with Face ID (because they looked vaguely similar) and transferred money from the sleeping user’s account. The backend saw a valid session and said “ok.”&lt;/p&gt;

&lt;p&gt;The user sued. (We settled.)&lt;/p&gt;

&lt;p&gt;That’s when I learned: biometric authentication in Turbo Native isn’t about unlocking the app. It’s about creating a &lt;em&gt;cryptographic handshake&lt;/em&gt; that your Rails backend can trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Mental Model: A Two-Factor Bridge
&lt;/h2&gt;

&lt;p&gt;Think of it this way. Your native biometrics are like a key that never leaves the device. Your Rails backend has a lock that expects a signed message saying “a human just proved their presence with a biometric.”&lt;/p&gt;

&lt;p&gt;The web view is just a messenger. It cannot be trusted. So you must:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Prompt biometrics natively&lt;/strong&gt; – Using &lt;code&gt;LAContext&lt;/code&gt; (iOS) or &lt;code&gt;BiometricPrompt&lt;/code&gt; (Android).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generate a short-lived, signed token&lt;/strong&gt; – On the native side, after biometric success.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inject that token into the web view&lt;/strong&gt; – Via JavaScript or custom URL scheme.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate the token in Rails&lt;/strong&gt; – Without ever storing the biometric data itself.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The artwork is in step 2. You’re not just passing a boolean. You’re passing &lt;em&gt;evidence&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the Handshake (What Actually Survived Production)
&lt;/h2&gt;

&lt;p&gt;Here’s the architecture that replaced our lawsuit-waiting-to-happen:&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Native Biometric Challenge
&lt;/h3&gt;

&lt;p&gt;On app launch or sensitive action, native code requests a challenge from the Rails backend &lt;em&gt;before&lt;/em&gt; prompting biometrics.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Rails: GET /api/biometric/challenge&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;challenge&lt;/span&gt;
  &lt;span class="n"&gt;challenge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;SecureRandom&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"biometric_challenge:&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;challenge: &lt;/span&gt;&lt;span class="n"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;expires_in: &lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why a challenge? Prevents replay attacks. The native app must sign this exact nonce with a device-specific key.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Biometric Prompt + Signing
&lt;/h3&gt;

&lt;p&gt;In the native app, after biometric success, we generate an asymmetric key pair (stored in the Secure Enclave / Keystore) on first use. Then we sign the challenge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;iOS (Swift):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Using DeviceCheck or CryptoKit&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;privateKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="kt"&gt;SecureEnclave&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;P256&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;Signing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;PrivateKey&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;privateKey&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;for&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;challenge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utf8&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;publicKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;privateKey&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;publicKey&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rawRepresentation&lt;/span&gt;

&lt;span class="c1"&gt;// Send back to Rails&lt;/span&gt;
&lt;span class="n"&gt;apiClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/biometric/verify"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s"&gt;"challenge"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"signature"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;base64EncodedString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="s"&gt;"public_key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;publicKey&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;base64EncodedString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="s"&gt;"device_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;deviceIdentifier&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Android (Kotlin):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;keyStore&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;KeyStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"AndroidKeyStore"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;privateKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;keyStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"biometric_key_${userId}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nc"&gt;PrivateKey&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;signature&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Signature&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SHA256withECDSA"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initSign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;privateKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toByteArray&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;sigBytes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Turbo Web View Injection
&lt;/h3&gt;

&lt;p&gt;Once Rails verifies the signature and returns a &lt;em&gt;short-lived JWT&lt;/em&gt; (expires in 5 minutes), the native app injects it into the Turbo web view:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;verificationResponse&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jwt&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;script&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"window.__biometricToken = '&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;';"&lt;/span&gt;
&lt;span class="n"&gt;webView&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluateJavaScript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;script&lt;/span&gt;&lt;span class="p"&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;error&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="c1"&gt;// Now load the protected Turbo frame&lt;/span&gt;
    &lt;span class="n"&gt;webView&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;URLRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;protectedURL&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;In your JavaScript (or Stimulus controller), you attach this token to every sensitive fetch:&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="nx"&gt;Turbo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;adapter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;visitor&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__biometricToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-Biometric-Auth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__biometricToken&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="nf"&gt;originalFetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;visitor&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;h3&gt;
  
  
  Step 4: Rails Verification Middleware
&lt;/h3&gt;

&lt;p&gt;Finally, a Rails &lt;code&gt;before_action&lt;/code&gt; for sensitive endpoints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ApplicationController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ActionController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Base&lt;/span&gt;
  &lt;span class="n"&gt;before_action&lt;/span&gt; &lt;span class="ss"&gt;:verify_biometric_for_sensitive_actions&lt;/span&gt;

  &lt;span class="kp"&gt;private&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_biometric_for_sensitive_actions&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;sensitive_action?&lt;/span&gt;

    &lt;span class="n"&gt;token&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="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'X-Biometric-Auth'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;BiometricTokenDecoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'exp'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;Time&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="nf"&gt;to_i&lt;/span&gt;
      &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;error: &lt;/span&gt;&lt;span class="s2"&gt;"biometric_required"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="ss"&gt;status: :unauthorized&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Art of the Fallback (Because Biometrics Fail)
&lt;/h2&gt;

&lt;p&gt;Here’s where senior devs earn their salt. Biometrics fail all the time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Wet fingers on a fingerprint sensor&lt;/li&gt;
&lt;li&gt;Face ID with a mask (post-2020)&lt;/li&gt;
&lt;li&gt;User who disabled biometrics in settings&lt;/li&gt;
&lt;li&gt;Hardware failure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your Turbo Native app must degrade gracefully.&lt;/p&gt;

&lt;p&gt;We built a &lt;strong&gt;state machine&lt;/strong&gt; in the web view:&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="c1"&gt;// Stimulus controller for sensitive actions&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;Controller&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;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Check if we have a fresh biometric token&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__biometricToken&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="nf"&gt;isTokenExpired&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Call native bridge to request biometric re-auth&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="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TurboNative&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestBiometric&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__biometricToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;token&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;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&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;And on the native side, &lt;code&gt;TurboNative.requestBiometric()&lt;/code&gt; reprompts and returns a new token. This way, a user can do ten transfers in a row and only authenticate once every 5 minutes (or every transfer, depending on risk).&lt;/p&gt;

&lt;p&gt;We also added a &lt;strong&gt;password fallback&lt;/strong&gt;—because a user with a broken Face ID sensor shouldn't be locked out of their money. The fallback triggers a separate OTP flow, and we record it in the audit log.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Human Truth: Users Want Speed, But They Accept Ritual
&lt;/h2&gt;

&lt;p&gt;After six months of logs, we found that 92% of biometric attempts succeeded on the first try. The 8% that failed? Most were “finger moved too fast” or “face not recognized.” Only 0.3% were actual security failures.&lt;/p&gt;

&lt;p&gt;We learned to show &lt;strong&gt;gentle error messages&lt;/strong&gt; instead of scary ones:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;❌ “Authentication failed” → ✅ “Face ID didn’t recognize you. Try adjusting the angle.”&lt;/li&gt;
&lt;li&gt;❌ “Biometric not available” → ✅ “Use your passcode to continue.”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And we added a &lt;strong&gt;visual cue&lt;/strong&gt; in the Turbo web view—a small face/fingerprint icon that fills with color when the token is fresh. Users started &lt;em&gt;looking&lt;/em&gt; for it. It became a trust signal.&lt;/p&gt;

&lt;p&gt;That’s the art. Not the cryptography. The &lt;em&gt;feeling&lt;/em&gt; of being secure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The One Thing I’d Never Do Again
&lt;/h2&gt;

&lt;p&gt;We initially tried storing the biometric token in &lt;code&gt;localStorage&lt;/code&gt; so it survives page reloads. &lt;em&gt;Terrible idea.&lt;/em&gt; A malicious web view script could read it. Now we keep it in native memory and only inject it when needed. Turbo’s &lt;code&gt;page:before-unload&lt;/code&gt; clears it.&lt;/p&gt;

&lt;p&gt;Also, never use biometrics as the &lt;em&gt;only&lt;/em&gt; factor for high-value actions. Always require a recent (within 5 minutes) re-verification. Our lawsuit taught us that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Masterpiece: Invisible, Unforgettable
&lt;/h2&gt;

&lt;p&gt;Today, our banking app has processed over $50M in transfers using this handshake. Users don’t think about it. They just tap, look at the camera, and the money moves. When we A/B tested removing the biometric icon (just to see if anyone noticed), support tickets about “the app feels less secure” spiked 40%.&lt;/p&gt;

&lt;p&gt;That’s when I knew we’d made art. Not the code. The &lt;em&gt;trust&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So go build your handshake. Respect the Secure Enclave. Write the middleware. And when a user says “I don’t know how it works, but I know it works,” pour yourself a drink. You’ve earned it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>javascript</category>
      <category>rails</category>
    </item>
    <item>
      <title>Mobile Performance Monitoring with Sentry and Turbo: The Art of Seeing the Invisible</title>
      <dc:creator>Alex Aslam</dc:creator>
      <pubDate>Tue, 07 Apr 2026 22:02:10 +0000</pubDate>
      <link>https://forem.com/alex_aslam/mobile-performance-monitoring-with-sentry-and-turbo-the-art-of-seeing-the-invisible-4pje</link>
      <guid>https://forem.com/alex_aslam/mobile-performance-monitoring-with-sentry-and-turbo-the-art-of-seeing-the-invisible-4pje</guid>
      <description>&lt;p&gt;Twenty years of shipping software. Rails since 1.2. Native mobile since the iPhone 3G. And still—nothing humbles me like a Turbo Native app that feels “sluggish” and won’t tell me why.&lt;/p&gt;

&lt;p&gt;You know the scenario. Users leave 2-star reviews: “It’s fine, but… slow sometimes.” Your team runs Lighthouse on the web version: 95+ Performance score. Native shell is just a WKWebView, right? Should be fast. But it’s not. And you’re blind.&lt;/p&gt;

&lt;p&gt;That’s when I learned: monitoring a Turbo Native app is not like monitoring a website. It’s not even like monitoring a regular native app. It’s a hybrid ghost—part web, part native, all lies. Sentry became my exorcist.&lt;/p&gt;

&lt;p&gt;This is the journey of learning to see what your users feel. Senior devs who’ve debugged memory leaks in IE6 and packet loss on dial-up: you’ll feel right at home.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Day I Realized RUM Was Lying
&lt;/h2&gt;

&lt;p&gt;We had Sentry’s JavaScript SDK in the web views. Great. We saw page load times, JS errors, API call durations. All looked healthy: median 1.2s to interactive.&lt;/p&gt;

&lt;p&gt;But our iOS beta testers kept saying: “The back button stutters.” Not the page load. The &lt;em&gt;transition&lt;/em&gt;. The gesture. A thing that has no JavaScript.&lt;/p&gt;

&lt;p&gt;Because Turbo Native doesn’t just reload HTML. It manages a native navigation stack—&lt;code&gt;UINavigationController&lt;/code&gt; pushing and popping &lt;code&gt;WKWebView&lt;/code&gt; instances. And when that stack has five web views, each holding a full DOM, memory pressure causes the &lt;em&gt;native&lt;/em&gt; animation to drop frames.&lt;/p&gt;

&lt;p&gt;Your Sentry browser SDK sees nothing. No console log. No error. Just a buttery-smooth 60fps claim while the user feels a hitch.&lt;/p&gt;

&lt;p&gt;I needed to instrument the &lt;em&gt;bridge&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building a Two-Sided Stopwatch
&lt;/h2&gt;

&lt;p&gt;The breakthrough came when I realized: performance in Turbo Native happens in two worlds, and Sentry can capture both—if you force them to talk.&lt;/p&gt;

&lt;p&gt;We started adding spans that cross the native/JavaScript boundary:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In native (iOS / Android):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// iOS: Turbo visit start&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;transaction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;SentrySDK&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startTransaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"turbo.navigation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"ui.load"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Inject a start time into the web view before load&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;script&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"window.__turboNativeStart = &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeIntervalSince1970&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;;"&lt;/span&gt;
&lt;span class="n"&gt;webView&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluateJavaScript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;script&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;In the web view’s JavaScript (with Sentry browser SDK):&lt;/strong&gt;&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="c1"&gt;// Wait for DOM ready, then send a custom metric&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;turbo:load&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="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;nativeStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__turboNativeStart&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;nativeStart&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;jsReady&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nativeToJS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsReady&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nativeStart&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addBreadcrumb&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;performance&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Native→JS bridge: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;nativeToJS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&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="s2"&gt;ms`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;info&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// Also send as a transaction&lt;/span&gt;
    &lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startTransaction&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;turbo.bridge_latency&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;measure&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;nativeToJS_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nativeToJS&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;finish&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;Now, when a user complains about “slowness,” we can see: was it the native navigation? The bridge serialization? The actual HTML parsing? Or the network?&lt;/p&gt;

&lt;p&gt;We found a 400ms gap on older iPhones just from &lt;code&gt;evaluateJavaScript&lt;/code&gt; calls. &lt;em&gt;That&lt;/em&gt; was the back button stutter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Art of the Span: Knowing What to Measure
&lt;/h2&gt;

&lt;p&gt;After six months of tuning, here’s our canonical set of Turbo-specific spans we send to Sentry:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Span name&lt;/th&gt;
&lt;th&gt;What it measures&lt;/th&gt;
&lt;th&gt;Typical threshold&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;turbo.navigation.start&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Native &lt;code&gt;visit()&lt;/code&gt; called&lt;/td&gt;
&lt;td&gt;&amp;lt; 5ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;turbo.webview.load&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;WKWebView&lt;/code&gt; load request to first paint&lt;/td&gt;
&lt;td&gt;&amp;lt; 800ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;turbo.bridge.call&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Any native→JS message (e.g., &lt;code&gt;postMessage&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&amp;lt; 50ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;turbo.memory.after_visit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Memory footprint post-navigation&lt;/td&gt;
&lt;td&gt;&amp;lt; 150MB on iOS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;turbo.back_gesture&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Native pop animation frame drop rate&lt;/td&gt;
&lt;td&gt;&amp;lt; 5% dropped frames&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The memory one is sneaky. Turbo keeps visited web views in a cache. Great for back button speed. Terrible for memory. We added a Sentry check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// After 3 cached views, warn&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;navigationController&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;viewControllers&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;Sentry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;captureMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Turbo cache high: &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt; web views retained"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
                          &lt;span class="nv"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;warning&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;That single metric led us to implement a custom cache eviction policy. Back buttons stayed fast. Memory stayed stable. Users stopped complaining.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Human Layer: Performance as a Feeling
&lt;/h2&gt;

&lt;p&gt;Here’s what I’ve learned after two decades: users don’t care about milliseconds. They care about &lt;em&gt;certainty&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A page that loads in 800ms every time feels faster than a page that loads in 200ms but sometimes takes 2 seconds. Variance is the enemy.&lt;/p&gt;

&lt;p&gt;Sentry’s &lt;code&gt;p75&lt;/code&gt; and &lt;code&gt;p95&lt;/code&gt; percentiles became my north star. We stopped optimizing the median. We started hunting the tail.&lt;/p&gt;

&lt;p&gt;One culprit: large JSON payloads from the Rails backend, serialized into the Turbo frame. On poor connections, they’d block rendering. We added a &lt;code&gt;data-turbo-permanent&lt;/code&gt; to non-critical sections and started streaming the rest. The p95 dropped from 4.2s to 1.1s.&lt;/p&gt;

&lt;p&gt;We knew because we could see it in Sentry’s Performance view, filtered by &lt;code&gt;device.model:"iPhone X"&lt;/code&gt; and &lt;code&gt;connection.effectiveType:"3g"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That’s the power. Not dashboards. &lt;em&gt;Slicing&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Mistakes That Made Me Smarter
&lt;/h2&gt;

&lt;p&gt;I’ll be honest: we over-instrumented at first. Every tap, every scroll, every &lt;code&gt;console.log&lt;/code&gt; became a Sentry event. Our quota exploded and our UI became noise.&lt;/p&gt;

&lt;p&gt;Then we learned: &lt;strong&gt;sample transactions&lt;/strong&gt; for navigation (1 in 20), &lt;strong&gt;always capture&lt;/strong&gt; failures, &lt;strong&gt;use profiles&lt;/strong&gt; not traces for UI thread analysis.&lt;/p&gt;

&lt;p&gt;Also: Sentry’s native SDK and browser SDK have different &lt;code&gt;release&lt;/code&gt; and &lt;code&gt;dist&lt;/code&gt; values. We wasted a week matching them before realizing they don’t need to match. What matters is &lt;code&gt;environment&lt;/code&gt; (prod/staging) and &lt;code&gt;user.id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Oh, and one more: Turbo’s &lt;code&gt;visit&lt;/code&gt; can be cancelled (user taps back before page loads). That was flooding our errors. Filter it out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# In Rails backend, when logging via Turbo streams&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;visit_cancelled?&lt;/span&gt;
  &lt;span class="no"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"turbo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;cancelled: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="no"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;capture_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Navigation cancelled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;level: &lt;/span&gt;&lt;span class="s2"&gt;"debug"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now it’s a breadcrumb, not an alert.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Masterpiece: When You Feel the Invisible
&lt;/h2&gt;

&lt;p&gt;After all this, something shifted. I could close my eyes, tap through the app, and &lt;em&gt;guess&lt;/em&gt; what Sentry would show. High memory? Probably the image gallery. Slow back gesture? Too many cached views. Bridge delay? A heavy &lt;code&gt;Intl&lt;/code&gt; polyfill in JavaScript.&lt;/p&gt;

&lt;p&gt;That’s the art. Not the tool. The &lt;em&gt;intuition&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Sentry gave us the data. Turbo gave us the constraints. And we—the old dogs who remember fixing cross-browser CSS in 2005—turned that into an app that doesn’t just perform well. It performs &lt;em&gt;predictably&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Last month, a user wrote: “This app never surprises me. It just works.”&lt;/p&gt;

&lt;p&gt;That’s the review I frame.&lt;/p&gt;

&lt;p&gt;Now go instrument your bridge. Send me a note when you find your first 300ms gap between native and JS. I’ll be here, watching my own p95, smiling.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>javascript</category>
      <category>rails</category>
    </item>
    <item>
      <title>Push Notification Delivery Guarantees with Rails: A Spiral Through the Gray Hours</title>
      <dc:creator>Alex Aslam</dc:creator>
      <pubDate>Tue, 07 Apr 2026 21:59:11 +0000</pubDate>
      <link>https://forem.com/alex_aslam/push-notification-delivery-guarantees-with-rails-a-spiral-through-the-gray-hours-526d</link>
      <guid>https://forem.com/alex_aslam/push-notification-delivery-guarantees-with-rails-a-spiral-through-the-gray-hours-526d</guid>
      <description>&lt;p&gt;I still remember the 3 a.m. Slack message that made my stomach drop.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;“CEO just asked why 40% of our users didn’t get the flash sale alert. Said their Android phones show nothing. We’re losing revenue.”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We had everything right. Rpush gem configured. Firebase Cloud Messaging (FCM) credentials rotated. APNS certificates valid. Background jobs retrying on failure. And still—notifications vanished like whispers in a hurricane.&lt;/p&gt;

&lt;p&gt;That night, I stopped believing in “delivery guarantees.” I started understanding push notifications as a &lt;em&gt;probabilistic art&lt;/em&gt;—where your Rails backend can do everything perfectly, and the universe (read: carriers, battery optimizers, OS quirks) can still say no.&lt;/p&gt;

&lt;p&gt;This is the journey of building trust from chaos. Senior full-stack folks, pull up a chair.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lie We Tell Ourselves
&lt;/h2&gt;

&lt;p&gt;We write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;NotificationSenderJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"Your order shipped!"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And we think: &lt;em&gt;it’ll get there&lt;/em&gt;. But between &lt;code&gt;perform_later&lt;/code&gt; and a screen lighting up, there are nine circles of hell:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;FCM/APNS rate limits&lt;/li&gt;
&lt;li&gt;Device tokens that expired yesterday&lt;/li&gt;
&lt;li&gt;Doze mode on Android 12+&lt;/li&gt;
&lt;li&gt;Carrier-level SMS-to-push gateways losing packets&lt;/li&gt;
&lt;li&gt;The user swiped away your app and background fetch is dead&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Push notifications are not TCP. They are UDP with extra sadness.&lt;/p&gt;

&lt;h2&gt;
  
  
  The First Realization: Idempotency Is Not Enough
&lt;/h2&gt;

&lt;p&gt;We all know idempotency. Retry a job 5 times with exponential backoff. Great for API calls. Useless when the provider returns &lt;code&gt;200 OK&lt;/code&gt; but the phone never shows the notification.&lt;/p&gt;

&lt;p&gt;Because here’s the dirty secret: FCM’s &lt;code&gt;200&lt;/code&gt; means “we accepted the message into our queue.” It does &lt;em&gt;not&lt;/em&gt; mean “the user saw it.” I’ve had messages accepted at 2:01 PM and delivered at 3:47 AM the next day. Or never.&lt;/p&gt;

&lt;p&gt;So we need a different mental model: &lt;strong&gt;at-least-once attempt, not delivery&lt;/strong&gt;. You can’t guarantee delivery. You can guarantee you &lt;em&gt;tried honestly&lt;/em&gt; and can measure the gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture of Honest Attempts (What Actually Works)
&lt;/h2&gt;

&lt;p&gt;After that 3 a.m. incident, I rebuilt our notification pipeline into something I call the “spiral log”—because it twists back on itself, checking, reconciling, never trusting.&lt;/p&gt;

&lt;p&gt;Here’s the Rails core that survived production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/models/notification.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Notification&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="n"&gt;belongs_to&lt;/span&gt; &lt;span class="ss"&gt;:user&lt;/span&gt;
  &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="ss"&gt;state: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;pending: &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;sent_to_provider: &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;delivered_to_device: &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;failed: &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# provider_response stores FCM/APNS message ID and timestamp&lt;/span&gt;
  &lt;span class="c1"&gt;# delivery_attempts counts retries&lt;/span&gt;
  &lt;span class="c1"&gt;# last_attempt_at for backoff&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;# app/jobs/send_notification_job.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SendNotificationJob&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationJob&lt;/span&gt;
  &lt;span class="n"&gt;retry_on&lt;/span&gt; &lt;span class="no"&gt;ProviderTimeout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;wait: :exponentially_longer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;attempts: &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;notification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification_id&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;if&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delivered_to_device?&lt;/span&gt;

    &lt;span class="n"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;PushProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;device_platform&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;token: &lt;/span&gt;&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;payload: &lt;/span&gt;&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;collapse_key: &lt;/span&gt;&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collapse_key&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;state: :sent_to_provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;provider_message_id: &lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;sent_at: &lt;/span&gt;&lt;span class="no"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Schedule a delivery receipt check (more on this)&lt;/span&gt;
    &lt;span class="no"&gt;CheckDeliveryReceiptJob&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="ss"&gt;wait: &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;ProviderInvalidToken&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
    &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;state: :failed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;error: &lt;/span&gt;&lt;span class="s2"&gt;"invalid_token"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="no"&gt;UserTokenRevocationService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The game-changer was &lt;strong&gt;delivery receipts&lt;/strong&gt;. APNS has them (via the &lt;code&gt;apns-push-type&lt;/code&gt; header and &lt;code&gt;apns-collapse-id&lt;/code&gt;). FCM has them via the &lt;code&gt;delivery_receipt_requested&lt;/code&gt; flag in the HTTP v1 API.&lt;/p&gt;

&lt;p&gt;We started storing every provider message ID and polling for delivery confirmation. When a receipt never arrived after 24 hours, we’d mark it as “suspected lost” and trigger a fallback channel (email or SMS).&lt;/p&gt;

&lt;h2&gt;
  
  
  The Art of the Receipt Reconciliation Loop
&lt;/h2&gt;

&lt;p&gt;Imagine a background worker that runs every hour:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/jobs/reconcile_notifications_job.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReconcileNotificationsJob&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationJob&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;perform&lt;/span&gt;
    &lt;span class="no"&gt;Notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sent_to_provider&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"sent_at &amp;lt; ?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ago&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;notification&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="no"&gt;PushProvider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;provider_message_id&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;status&lt;/span&gt;
      &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="s2"&gt;"delivered"&lt;/span&gt;
        &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;state: :delivered_to_device&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;delivered_at: &lt;/span&gt;&lt;span class="no"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="s2"&gt;"failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"expired"&lt;/span&gt;
        &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;state: :failed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;error: &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="s2"&gt;"pending"&lt;/span&gt;
        &lt;span class="c1"&gt;# keep waiting, but log a metric&lt;/span&gt;
        &lt;span class="no"&gt;Metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push_delivery_latency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sent_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This loop is the &lt;em&gt;spiral&lt;/em&gt;. It doesn’t assume success. It asks the provider, repeatedly, like a worried parent texting “did you get my last text?”&lt;/p&gt;

&lt;h2&gt;
  
  
  The Human Layer: What Users Actually Experience
&lt;/h2&gt;

&lt;p&gt;Here’s the part that separates senior devs from juniors. Delivery guarantees aren’t just bytes—they’re emotions.&lt;/p&gt;

&lt;p&gt;A push notification that arrives 6 hours late for a “your food is ready” alert? That’s not a notification. That’s a cold dinner and a one-star review.&lt;/p&gt;

&lt;p&gt;So we added &lt;strong&gt;time-to-live (TTL)&lt;/strong&gt; for every message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# For time-sensitive alerts&lt;/span&gt;
&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="ss"&gt;apns: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;expiry: &lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;# 5 minutes&lt;/span&gt;
  &lt;span class="ss"&gt;fcm: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;time_to_live: &lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# For marketing (who cares if it's late)&lt;/span&gt;
&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="ss"&gt;apns: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;expiry: &lt;/span&gt;&lt;span class="mi"&gt;86400&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;# 1 day&lt;/span&gt;
  &lt;span class="ss"&gt;fcm: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;time_to_live: &lt;/span&gt;&lt;span class="mi"&gt;86400&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;And we taught product managers the phrase: &lt;em&gt;“If the message isn’t relevant after X minutes, don’t send it at all.”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We also built a &lt;strong&gt;dashboard&lt;/strong&gt; (just a Rails view with charts) showing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sent to provider rate&lt;/li&gt;
&lt;li&gt;Delivery receipt rate (actual device ack)&lt;/li&gt;
&lt;li&gt;Median latency per provider&lt;/li&gt;
&lt;li&gt;Token invalidation rate per OS version&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When we showed that to the CEO, he stopped asking why users missed messages. He started asking why Android 13 had a 12% higher drop rate than iOS 17. (Spoiler: battery optimizations.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The One Thing That Still Hurts
&lt;/h2&gt;

&lt;p&gt;Even with all this, push notifications are not guaranteed. A phone in a faraday cage (elevator, basement, airplane) will never get the message. A user who disabled notifications at the OS level—we can’t fix that. A carrier that drops our packets between FCM and the device—we can’t even detect it.&lt;/p&gt;

&lt;p&gt;What we &lt;em&gt;can&lt;/em&gt; guarantee is &lt;strong&gt;observability&lt;/strong&gt; and &lt;strong&gt;fallback&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For every push notification we send, we also create an in-app inbox message. When the user opens the app, they see everything they missed. The push becomes a &lt;em&gt;hint&lt;/em&gt;, not the source of truth.&lt;/p&gt;

&lt;p&gt;And we stopped apologizing for the platform’s limits. We started explaining them. In the app’s settings: “Push notifications are best-effort. Check your in-app inbox for everything.”&lt;/p&gt;

&lt;h2&gt;
  
  
  The Masterpiece Isn’t Perfect Delivery—It’s Honest Failure
&lt;/h2&gt;

&lt;p&gt;That 3 a.m. incident taught me: delivery guarantees are a myth. But &lt;em&gt;delivery transparency&lt;/em&gt; is achievable. And users will forgive a lost notification if your app gives them another way to find the information.&lt;/p&gt;

&lt;p&gt;So build the spiral. Poll for receipts. Log the latency. Have a fallback. And when someone asks “can you guarantee 100% delivery?”, smile and say: “No. But I can tell you exactly when and why each one failed, and I can try again smarter.”&lt;/p&gt;

&lt;p&gt;That’s the art. That’s the Rails way.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>rails</category>
      <category>productivity</category>
    </item>
    <item>
      <title>The Art of Background Sync in Turbo Native Apps: A Journey Through Offline-First Masterpieces</title>
      <dc:creator>Alex Aslam</dc:creator>
      <pubDate>Tue, 07 Apr 2026 21:56:20 +0000</pubDate>
      <link>https://forem.com/alex_aslam/the-art-of-background-sync-in-turbo-native-apps-a-journey-through-offline-first-masterpieces-20ge</link>
      <guid>https://forem.com/alex_aslam/the-art-of-background-sync-in-turbo-native-apps-a-journey-through-offline-first-masterpieces-20ge</guid>
      <description>&lt;p&gt;Let me tell you about the night I almost threw my laptop out a window.&lt;/p&gt;

&lt;p&gt;I was building a Turbo Native app for a field service team—technicians inspecting industrial equipment in basements where cellular signals go to die. The web version worked beautifully. The Turbo iOS shell? Also beautiful. Until someone walked into a parking garage mid-form-submission.&lt;/p&gt;

&lt;p&gt;The spinner spun. The user sighed. The data vanished into the ether.&lt;/p&gt;

&lt;p&gt;That’s when I stopped treating background sync as a “nice-to-have” and started seeing it as a &lt;em&gt;painting&lt;/em&gt;—a careful composition of timing, state, and user expectation. For senior full-stack devs who’ve shipped enough CRUD apps to feel the boredom creeping in: this is your invitation to build something that actually &lt;em&gt;feels&lt;/em&gt; like native magic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Naive Approach (And Why It Hurts)
&lt;/h2&gt;

&lt;p&gt;Let’s be real. Most of us start here:&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="c1"&gt;// In your Turbo Native web view&lt;/span&gt;
&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;submit&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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="nf"&gt;showSpinner&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/inspections&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;hideSpinner&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;Works great on your MacBook with fiber internet. On a subway? The spinner spins forever, the user force-quits the app, and the inspection data—complete with 47 fields and three photos—evaporates. The backend never sees it. The user never trusts your app again.&lt;/p&gt;

&lt;p&gt;Turbo Native gives you a &lt;code&gt;WKWebView&lt;/code&gt; (iOS) or &lt;code&gt;WebView&lt;/code&gt; (Android) connected to a Rails (or any) backend via Hotwire. It’s fast, it’s familiar, but it inherits the web’s fundamental fragility: requests are ephemeral.&lt;/p&gt;

&lt;p&gt;Background sync isn’t about making the network reliable. It’s about &lt;em&gt;accepting&lt;/em&gt; unreliability and designing for it like an artist plans for the cracks in a fresco.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Mental Model: A Transactional Sketchpad
&lt;/h2&gt;

&lt;p&gt;Here’s what I wish I’d internalized earlier: Your app needs a local staging area. Not a full offline database (though that’s lovely), but a &lt;strong&gt;persistent request queue&lt;/strong&gt; that survives app restarts, OS updates, and airplane mode.&lt;/p&gt;

&lt;p&gt;Think of it as a sketchpad. The user draws their action (submit form, like a post, upload a photo). Your app records it locally, gives immediate UI feedback, and then—in the background, like a patient printmaker pulling a proof—attempts to sync when connectivity returns.&lt;/p&gt;

&lt;p&gt;The art is in the &lt;em&gt;when&lt;/em&gt; and the &lt;em&gt;how&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the Sync Pipeline (Without Losing Your Mind)
&lt;/h2&gt;

&lt;p&gt;I’m using React Native + Turbo Native here, but the pattern applies to any Turbo wrapper. You’ll need:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A request queue&lt;/strong&gt; – persisted with AsyncStorage (RN) or Room (Android native) / CoreData (iOS native)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A background sync service&lt;/strong&gt; – iOS &lt;code&gt;BGTaskScheduler&lt;/code&gt;, Android &lt;code&gt;WorkManager&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency keys&lt;/strong&gt; – because retries will happen, and you don’t want duplicate inspections&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here’s the skeleton that saved my sanity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// syncQueue.ts&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SyncQueue&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PendingRequest&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PendingRequest&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;queue&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="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;retries&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="na"&gt;createdAt&lt;/span&gt;&lt;span class="p"&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="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;persist&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="nf"&gt;processIfOnline&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// immediate attempt&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;processIfOnline&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasNetwork&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;for &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;req&lt;/span&gt; &lt;span class="k"&gt;of&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;queue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&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;X-Idempotency-Key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;idempotencyKey&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="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handleFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handleFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The backend needs to support idempotency—store that key and reject duplicates. You already know how to do that. The real craft is on the client.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Human Touch: UI That Doesn’t Lie
&lt;/h2&gt;

&lt;p&gt;A background sync that runs silently is technically correct but emotionally wrong. Users need to know what’s happening.&lt;/p&gt;

&lt;p&gt;I add three visual states to every form:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Saved locally&lt;/strong&gt; – subtle “offline draft” badge, no spinner&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pending sync&lt;/strong&gt; – a small cloud icon with a dot, tappable for status&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failed&lt;/strong&gt; – a warning badge with a manual retry button (because background sync can fail for auth reasons, schema changes, etc.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Never show a spinner for background work. Spinners say “wait for me.” Background sync says “I’ve got this, go ahead.”&lt;/p&gt;

&lt;p&gt;One of my users—a 60-year-old technician named Dave—told me after the update: “I don’t worry about the basement anymore. The app just… works.” That’s the goal. Invisibility through reliability.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Hell: Conflict Resolution
&lt;/h2&gt;

&lt;p&gt;Here’s where senior devs earn their salary. When you enqueue requests offline, you’re creating a time bomb of stale data.&lt;/p&gt;

&lt;p&gt;Scenario: User submits “Change status to Complete” while offline. Then, before sync happens, another device updates the same record. Your queued request arrives with an outdated &lt;code&gt;updated_at&lt;/code&gt;. What now?&lt;/p&gt;

&lt;p&gt;Two patterns I’ve battle-tested:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Optimistic last-write-wins (LWW)&lt;/strong&gt; – Simple, dangerous. Fine for non-critical data like “likes.”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Operation transformation with merge components&lt;/strong&gt; – Harder, but right for forms. Send the &lt;em&gt;intent&lt;/em&gt; (e.g., &lt;code&gt;{ operation: "increment_quantity", path: "line_items[3].qty" }&lt;/code&gt;) rather than the final value. Backend applies it atomically.&lt;/p&gt;

&lt;p&gt;For Dave’s inspection app, we used a hybrid: Each form submission includes the full current state plus a hash of the previous state. Backend rejects if the hash mismatches and returns the latest state. The client then shows a conflict resolution UI—just like Git, but friendly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting It All Together: The Masterpiece
&lt;/h2&gt;

&lt;p&gt;After six weeks of iteration, my Turbo Native app now does this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;User submits form → instant local save → optimistic UI update&lt;/li&gt;
&lt;li&gt;Request goes into queue → background sync service registers a 15-minute wakeup window&lt;/li&gt;
&lt;li&gt;Network comes back → sync runs in background (no app launch required)&lt;/li&gt;
&lt;li&gt;Conflict? → silent merge or gentle prompt&lt;/li&gt;
&lt;li&gt;Success → queue item removed, local badge cleared&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result? A 47% reduction in support tickets about “lost data” and zero spinners in offline mode.&lt;/p&gt;

&lt;p&gt;But the real art isn’t the code. It’s the &lt;em&gt;feeling&lt;/em&gt;. The app doesn’t fight the user’s reality—it flows around it. That’s what native should mean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your Turn
&lt;/h2&gt;

&lt;p&gt;Start small. Add a queue for one critical POST endpoint. Measure how often it retries. Add idempotency. Then expand. You’ll never look at online-only forms the same way again.&lt;/p&gt;

&lt;p&gt;And when you inevitably stay up until 2 AM debugging a race condition between background sync and user logout… remember Dave in the basement. He’s waiting for your masterpiece.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>javascript</category>
      <category>productivity</category>
    </item>
    <item>
      <title>The Cartographer’s Confession: How PostGIS Turned Me from a SQL Hack into a Spatial Artist</title>
      <dc:creator>Alex Aslam</dc:creator>
      <pubDate>Mon, 06 Apr 2026 18:35:02 +0000</pubDate>
      <link>https://forem.com/alex_aslam/the-cartographers-confession-how-postgis-turned-me-from-a-sql-hack-into-a-spatial-artist-2jo</link>
      <guid>https://forem.com/alex_aslam/the-cartographers-confession-how-postgis-turned-me-from-a-sql-hack-into-a-spatial-artist-2jo</guid>
      <description>&lt;p&gt;Let me start with a confession. For years, I treated geospatial data like a messy closet—shove everything in, slam the door, and pray nobody asks for a “nearby” anything. Then came the project that broke me: a real-time delivery tracker with 50k points and a naive &lt;code&gt;WHERE sqrt((x1-x2)^2 + (y1-y2)^2) &amp;lt; 0.01&lt;/code&gt; query that took forty-five seconds. My CTO’s Slack message just said: “Oof.”&lt;/p&gt;

&lt;p&gt;That night, I discovered PostGIS. And I learned that working with space on a computer isn’t just math—it’s an art form. One where you’re both the cartographer and the gallery curator.&lt;/p&gt;

&lt;p&gt;So grab coffee. Let me walk you through the journey from “it works on my laptop” to “this scales like a dream.” No marketing fluff. Just the battle scars and the beautiful abstractions that saved my sanity.&lt;/p&gt;




&lt;h2&gt;
  
  
  Act I: The Naive Cartographer (or, Why Euclidean Distance Lies)
&lt;/h2&gt;

&lt;p&gt;You know the scene. You have a &lt;code&gt;restaurants&lt;/code&gt; table with &lt;code&gt;lat&lt;/code&gt; and &lt;code&gt;lon&lt;/code&gt; as plain decimals. A user wants all taco joints within 1 km. Your first instinct:&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;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;restaurants&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;lat&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;7128&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;^&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lon&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;74&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0060&lt;/span&gt;&lt;span class="p"&gt;)&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="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;009&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- ~1km in deg?!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is wrong on two levels. First, degrees are not kilometers—unless you enjoy eating polar-bear tacos at the equator. Second, that query will do a &lt;strong&gt;full table scan&lt;/strong&gt; every time. Your database is now screaming like a dying server fan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The awakening&lt;/strong&gt;: PostGIS introduces geometry types and a proper spatial relationship model. The same query becomes:&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;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;restaurants&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ST_DWithin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;geom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
  &lt;span class="n"&gt;ST_SetSRID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ST_MakePoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;74&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0060&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;7128&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;4326&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="mi"&gt;1000&lt;/span&gt;  &lt;span class="c1"&gt;-- meters, thank you very much&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But wait—that still scanned everything? Right. Because we forgot the most important part.&lt;/p&gt;




&lt;h2&gt;
  
  
  Act II: The Index as a Legend (GIST is Your Compass)
&lt;/h2&gt;

&lt;p&gt;Here’s where the art begins. A normal B-tree index is like alphabetizing a bookshelf—great for “title = X”. But spatial data is a map. You don’t search a map by flipping pages; you fold it, you zoom, you glance at regions.&lt;/p&gt;

&lt;p&gt;Enter &lt;strong&gt;GIST&lt;/strong&gt; (Generalized Search Tree). Think of it as an origami master that folds your 2D (or 3D, or 4D) space into a tree of bounding boxes. When you query “find points within 1 km,” PostGIS uses the index to discard entire continents of data instantly.&lt;/p&gt;

&lt;p&gt;Create it:&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;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_restaurants_geom&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;restaurants&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;GIST&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;geom&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one line turned my 45-second query into 80 milliseconds. I literally laughed out loud. My cat left the room.&lt;/p&gt;

&lt;p&gt;But indexing isn’t magic—it’s a &lt;strong&gt;trade-off&lt;/strong&gt;. GIST indexes are slightly slower to update (insert/update/delete) than B-trees. For a write-heavy geospatial table, you’ll need to tune autovacuum or batch your writes. More on that later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Art lesson&lt;/strong&gt;: A GIST index is like the legend on a map—it doesn’t show every tree, but it tells you exactly how to find the forest.&lt;/p&gt;




&lt;h2&gt;
  
  
  Act III: The Palette of Spatial Functions (Don’t Paint with a Hammer)
&lt;/h2&gt;

&lt;p&gt;PostGIS has hundreds of functions. You only need a dozen to be dangerous. Here’s my everyday toolkit, refined through actual pain:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you want&lt;/th&gt;
&lt;th&gt;The function&lt;/th&gt;
&lt;th&gt;Why it’s beautiful&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Distance filter&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ST_DWithin(geom1, geom2, radius)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Uses index. Always. Don’t use &lt;code&gt;ST_Distance&lt;/code&gt; in WHERE.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;True intersection&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ST_Intersects(geom1, geom2)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Handles boundaries, overlaps, touches.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nearest neighbor&lt;/td&gt;
&lt;td&gt;&lt;code&gt;geom &amp;lt;-&amp;gt; ST_SetSRID(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The “knight move” of spatial indexes—uses KNN.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Area of a polygon&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ST_Area(geom::geography)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Returns square meters. Geography type respects Earth’s curve.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Convert lat/lon to geometry&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ST_SetSRID(ST_MakePoint(lon, lat), 4326)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Remember: longitude first. I’ve cried over swapped axes.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Real example&lt;/strong&gt;: Find the 10 closest coffee shops to a user, within 5 km, ordered by distance.&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;SELECT&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;ST_Distance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;geom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_geom&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;dist&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;coffee_shops&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ST_DWithin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;geom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_geom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;geom&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;user_geom&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;&amp;lt;-&amp;gt;&lt;/code&gt; operator? It’s the KNN (K-Nearest Neighbor) index-assisted magic. Without it, PostGIS would calculate distance for every shop within 5 km, then sort. With it, the index walks the tree and returns candidates in approximate order. It’s not exact until the final sort, but it’s blindingly fast.&lt;/p&gt;




&lt;h2&gt;
  
  
  Act IV: The Geometry vs. Geography Schism (A Tale of Two Earths)
&lt;/h2&gt;

&lt;p&gt;You’ll hit this around 2 AM. Your polygons on a city scale work fine. Then you try to calculate the area of a country and get numbers that would make a flat-earther nod approvingly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Geometry&lt;/strong&gt;: Treats the Earth as a flat Cartesian plane. Good for local projects (a few hundred km). Fast. Simple. Wrong for global distances.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Geography&lt;/strong&gt;: Uses a spheroidal model (WGS84 by default). Accurate for distance, area, and bearing across the globe. Slower, because it’s doing real math.&lt;/p&gt;

&lt;p&gt;My rule of thumb:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Store as &lt;code&gt;geometry&lt;/code&gt; with SRID 4326&lt;/strong&gt; (lat/lon coordinates). It’s lightweight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;geography&lt;/code&gt; casting&lt;/strong&gt; when you need Earth-aware calculations: &lt;code&gt;geom::geography&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Index both&lt;/strong&gt; – but a GIST on &lt;code&gt;geography&lt;/code&gt; is larger and slightly slower.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pro tip: For large tables with global queries, add a &lt;code&gt;geog&lt;/code&gt; column as &lt;code&gt;geography(Point, 4326)&lt;/code&gt; and index that. Then you can write clean queries like:&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;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sensors&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ST_DWithin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;geog&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ST_MakePoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;geography&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;-- 50 km&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No casting in the query means the index gets used without hesitation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Act V: The Performance Trap (What They Don’t Put in the Brochure)
&lt;/h2&gt;

&lt;p&gt;You’ve indexed everything. Queries are snappy. Then you deploy to production and… it’s slow again. Why?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three silent killers:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Implicit casting in the WHERE clause&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
&lt;code&gt;WHERE ST_DWithin(geom::geography, ...)&lt;/code&gt; – the cast happens &lt;em&gt;before&lt;/em&gt; the index lookup. PostGIS can’t use a GIST on &lt;code&gt;geometry&lt;/code&gt; for a &lt;code&gt;geography&lt;/code&gt; query. Keep types consistent.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Using &lt;code&gt;ST_Distance&lt;/code&gt; for filtering&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;   &lt;span class="c1"&gt;-- This is a full scan. Always.&lt;/span&gt;
   &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ST_Distance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;geom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;point&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ST_DWithin&lt;/code&gt; exists for a reason. Use it.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Over-indexing on large polygons&lt;/strong&gt;
A GIST index on a column full of complex polygons (e.g., country borders) can be huge. Consider storing a simplified “envelope” geometry for coarse filtering, then refine with exact &lt;code&gt;ST_Intersects&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Real story&lt;/strong&gt;: We had a table of 2M GPS traces. Queries were fast in dev (10k rows). In prod, &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; showed a bitmap heap scan—PostGIS was reading half the table anyway. Why? The distribution was clustered, but our random test data wasn’t. We added &lt;code&gt;CLUSTER idx_restaurants_geom ON restaurants&lt;/code&gt; to physically reorder rows by spatial locality. Query time dropped from 4 seconds to 200ms.&lt;/p&gt;




&lt;h2&gt;
  
  
  Act VI: The Artistic Workflow (How to Think Spatially)
&lt;/h2&gt;

&lt;p&gt;After two years of wrestling with PostGIS, I’ve developed a kind of intuition. It’s like learning to see negative space in a drawing. Here’s my mental checklist before writing any spatial query:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Draw it first&lt;/strong&gt; – I keep a whiteboard or a quick QGIS window. Visualizing bounding boxes and intersections saves hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start with the index&lt;/strong&gt; – Write the query assuming the index will do the heavy lifting. Filter early, refine late.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test with a point&lt;/strong&gt; – Run &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; on a single coordinate. Look for “Seq Scan” – if you see it, your index isn’t being used.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Think in meters, store in degrees&lt;/strong&gt; – Use &lt;code&gt;geography&lt;/code&gt; for distances, &lt;code&gt;geometry&lt;/code&gt; for operations. Cast explicitly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch your writes&lt;/strong&gt; – A GIST index rebuild on 1M rows takes minutes. Do it nightly, not per insert.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Epilogue: You Are Now a Spatial Artist
&lt;/h2&gt;

&lt;p&gt;PostGIS isn’t just a library. It’s a lens that changes how you see data. Suddenly every “near me” button, every delivery route, every heatmap becomes a solvable puzzle instead of a performance nightmare.&lt;/p&gt;

&lt;p&gt;The journey from &lt;code&gt;sqrt(lat^2 + lon^2)&lt;/code&gt; to elegant &lt;code&gt;ST_DWithin&lt;/code&gt; with a GIST index is the difference between a child’s crayon scribble and a Monet. You’ve learned the brushstrokes. Now go paint some maps.&lt;/p&gt;

&lt;p&gt;And when someone asks you, “Can you find all points within a polygon?” – smile, open your terminal, and whisper: &lt;em&gt;“Watch this.”&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
      <category>beginners</category>
    </item>
    <item>
      <title>JavaScript Memory Leaks: How to Find, Fix, and Prevent Them</title>
      <dc:creator>Alex Aslam</dc:creator>
      <pubDate>Mon, 06 Apr 2026 18:20:51 +0000</pubDate>
      <link>https://forem.com/alex_aslam/javascript-memory-leaks-how-to-find-fix-and-prevent-them-2e3a</link>
      <guid>https://forem.com/alex_aslam/javascript-memory-leaks-how-to-find-fix-and-prevent-them-2e3a</guid>
      <description>&lt;p&gt;It was 3 AM on a Tuesday. Or maybe Wednesday—the days blur when you’re chasing a ghost.&lt;/p&gt;

&lt;p&gt;Our React dashboard, which had run beautifully for weeks, started dying. Slowly at first. A click took an extra second. Then five. Then the tab just… froze. I popped open Chrome DevTools, clicked the Memory tab, took a heap snapshot, and nearly choked. The app was eating 1.2 GB of RAM. For a dashboard that showed, at most, a thousand rows of data.&lt;/p&gt;

&lt;p&gt;We didn’t have a bug. We had a &lt;strong&gt;memory leak&lt;/strong&gt;. And it had been there for months, hiding in plain sight.&lt;/p&gt;

&lt;p&gt;That night taught me something uncomfortable: You can write perfect‑looking code and still be slowly poisoning your users’ browsers. Memory leaks aren’t crashes—they’re death by a thousand cuts. The tab doesn’t throw an error. It just gets… tired. Sluggish. Then it dies.&lt;/p&gt;

&lt;p&gt;Let me walk you through what I learned. Not as a list of bullet points, but as a journey into the invisible sculpture that is your app’s memory.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Gallery of Forgotten References
&lt;/h2&gt;

&lt;p&gt;Think of your JavaScript app as an art gallery. Every object, every variable, every closure is a painting on the wall. The garbage collector (GC) is the night janitor. He comes in periodically, looks around, and removes any painting that doesn’t have a visitor looking at it.&lt;/p&gt;

&lt;p&gt;But the janitor is polite. He only removes something if &lt;em&gt;nobody&lt;/em&gt; can reach it. If you still have a reference—a path from the root (like &lt;code&gt;window&lt;/code&gt; or a global variable)—he leaves it. Forever.&lt;/p&gt;

&lt;p&gt;A memory leak is simply this: &lt;strong&gt;you keep a reference to something you no longer need&lt;/strong&gt;. The janitor sees it, shrugs, and walks away. And that painting stays on the wall, accumulating, until the gallery bursts at the seams.&lt;/p&gt;

&lt;p&gt;As senior devs, we know the usual suspects. But knowing them and &lt;em&gt;feeling&lt;/em&gt; them are different things. Let’s walk the gallery together.&lt;/p&gt;




&lt;h2&gt;
  
  
  Suspect 1: The Accidental Global
&lt;/h2&gt;

&lt;p&gt;Remember when we all learned that omitting &lt;code&gt;var&lt;/code&gt;, &lt;code&gt;let&lt;/code&gt;, or &lt;code&gt;const&lt;/code&gt; creates a global? We laughed. We said “I’d never do that.”&lt;/p&gt;

&lt;p&gt;Then I found this in production:&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;function&lt;/span&gt; &lt;span class="nf"&gt;processUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;heavyComputation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// forgot 'let'&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;result&lt;/code&gt; became a global. It sat on &lt;code&gt;window&lt;/code&gt; (or &lt;code&gt;global&lt;/code&gt; in Node) forever. Every call overwrote it, but the previous object was still referenced? Actually, no—assignment overwrites the reference, so the old object is freed. But the real leak came later when a library attached something to &lt;code&gt;window.result&lt;/code&gt; and never cleaned up.&lt;/p&gt;

&lt;p&gt;The art lesson: &lt;strong&gt;Globals are permanent walls in your gallery&lt;/strong&gt;. The janitor never touches them. If you must use a global, you’d better be ready to set it to &lt;code&gt;null&lt;/code&gt; when you’re done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to find it:&lt;/strong&gt; Run your app with &lt;code&gt;'use strict'&lt;/code&gt;. Or use ESLint’s &lt;code&gt;no-undef&lt;/code&gt;. And in DevTools, check &lt;code&gt;window&lt;/code&gt; for unexpected properties.&lt;/p&gt;




&lt;h2&gt;
  
  
  Suspect 2: The Clinging Closure
&lt;/h2&gt;

&lt;p&gt;Closures are beautiful. They’re the watercolors of JavaScript—soft, elegant, capturing context. But they can also be a trap.&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;function&lt;/span&gt; &lt;span class="nf"&gt;createHeavyHandler&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;largeData&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;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;handler called&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="c1"&gt;// largeData is never used here!&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;createHeavyHandler&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every second, a new closure is created. That closure holds a reference to &lt;code&gt;largeData&lt;/code&gt; because the function &lt;em&gt;could&lt;/em&gt; use it. The GC can’t tell that you never actually touch &lt;code&gt;largeData&lt;/code&gt;. So all those million‑element arrays stay alive. Forever.&lt;/p&gt;

&lt;p&gt;I debugged a similar leak in a real app: an event handler that closed over a massive Redux store. The handler only used a single flag, but the entire store was captured. Each new listener added another copy of the store.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Be explicit. If a closure doesn’t need a variable, don’t let it capture it. Refactor, or use &lt;code&gt;null&lt;/code&gt; to break the chain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to find it:&lt;/strong&gt; Take heap snapshots and look at the retaining paths for large objects. You’ll see a closure context holding onto data you thought was gone.&lt;/p&gt;




&lt;h2&gt;
  
  
  Suspect 3: Forgotten Timers and Event Listeners
&lt;/h2&gt;

&lt;p&gt;This one stung me the worst.&lt;/p&gt;

&lt;p&gt;We had a single‑page app with modals. Each modal opened, fetched data, set up a &lt;code&gt;setInterval&lt;/code&gt; to refresh that data every 30 seconds, and attached a &lt;code&gt;resize&lt;/code&gt; listener to adjust the modal’s position.&lt;/p&gt;

&lt;p&gt;When you closed the modal, we removed the DOM elements. But we forgot to call &lt;code&gt;clearInterval&lt;/code&gt; and &lt;code&gt;removeEventListener&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Result: Every modal you ever opened was still running its timer. The timer callback still held a reference to the (now detached) DOM nodes and the component’s state. The DOM nodes were gone from the page, but they were still in memory because the timer’s closure kept them alive.&lt;/p&gt;

&lt;p&gt;The janitor couldn’t touch them. They were orphaned paintings in a hidden room.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rule:&lt;/strong&gt; For every &lt;code&gt;setInterval&lt;/code&gt;, &lt;code&gt;setTimeout&lt;/code&gt;, &lt;code&gt;addEventListener&lt;/code&gt;, or &lt;code&gt;Observer&lt;/code&gt;, you &lt;em&gt;must&lt;/em&gt; have a cleanup. In React, that’s the &lt;code&gt;useEffect&lt;/code&gt; cleanup function. In vanilla JS, it’s a &lt;code&gt;destroy&lt;/code&gt; method.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to find it:&lt;/strong&gt; Use the Performance panel to record allocation timelines. If you see memory growing in a sawtooth pattern (up, down, but never back to baseline), you’ve got a leak. Then use heap snapshots to see what’s retaining those detached DOM nodes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Suspect 4: The Ever‑Growing Cache
&lt;/h2&gt;

&lt;p&gt;Caches are supposed to make things faster. But without a size limit or expiration policy, they’re just a slow leak in disguise.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cache&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;return&lt;/span&gt; &lt;span class="nb"&gt;Promise&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;cache&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;return&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUser&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="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;cache&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="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;user&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;user&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;This is beautiful—until you’ve fetched ten million unique user IDs. Then &lt;code&gt;cache&lt;/code&gt; holds every single one. Forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The art:&lt;/strong&gt; A cache is a sculpture that must be pruned. Use &lt;code&gt;Map&lt;/code&gt; with a TTL (time‑to‑live), or implement an LRU (least recently used) cache. Or use &lt;code&gt;WeakMap&lt;/code&gt; when the keys are objects that can be garbage‑collected elsewhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to find it:&lt;/strong&gt; Look for large objects in heap snapshots that you didn’t expect. If you see a giant object with thousands of keys and you never intentionally built it, you’ve found your leak.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Detective’s Toolkit
&lt;/h2&gt;

&lt;p&gt;Over the years, I’ve built a mental checklist. When a user reports “the tab gets slow after an hour,” I don’t guess. I use:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Chrome DevTools → Memory → Heap snapshot&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Take one before and after an action. Compare. The “Comparison” view shows you what’s been added and not freed.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Allocation instrumentation on timeline&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Records every allocation with a stack trace. Lets you see exactly which function created the leaking object.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Performance monitor&lt;/strong&gt; (under “More tools”)&lt;br&gt;&lt;br&gt;
Watch JS heap size in real time. If it never plateaus, you’re leaking.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Detached DOM nodes&lt;/strong&gt; in heap snapshots&lt;br&gt;&lt;br&gt;
Filter for “Detached” – these are DOM elements no longer in the page but still referenced. A huge red flag.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Node.js ––inspect&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Same DevTools, but for backend. Use &lt;code&gt;process.memoryUsage()&lt;/code&gt; as a cheap health check.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Preventing Leaks: The Art of Letting Go
&lt;/h2&gt;

&lt;p&gt;The most important shift in my thinking wasn’t technical. It was emotional. I stopped treating memory as infinite. I started treating every reference as a &lt;strong&gt;conscious choice&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Ask yourself, with every variable, every closure, every listener:&lt;br&gt;&lt;br&gt;
&lt;em&gt;“When does this end? What cleans this up?”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you can’t answer, you’ve painted a picture that will hang in the gallery forever.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;WeakMap&lt;/code&gt; and &lt;code&gt;WeakSet&lt;/code&gt;&lt;/strong&gt; for metadata attached to objects that you don’t want to keep alive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer &lt;code&gt;let&lt;/code&gt; and &lt;code&gt;const&lt;/code&gt;&lt;/strong&gt; over globals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In React, always return a cleanup&lt;/strong&gt; from &lt;code&gt;useEffect&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For long‑lived apps&lt;/strong&gt; (SPAs, Node services), periodically take heap snapshots in CI to detect regressions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;AbortController&lt;/code&gt;&lt;/strong&gt; to cancel fetch requests and remove event listeners in one go.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Human Truth
&lt;/h2&gt;

&lt;p&gt;Memory leaks aren’t a mark of shame. They’re a natural consequence of writing dynamic, long‑running applications. Every senior I know has a war story. Mine involved a dashboard and a 3‑AM heap snapshot. Yours might be different.&lt;/p&gt;

&lt;p&gt;But the art of memory management is the art of &lt;em&gt;intentional forgetting&lt;/em&gt;. It’s about knowing when to hold on and when to let go. It’s a dance between you and the garbage collector—a silent partnership.&lt;/p&gt;

&lt;p&gt;The janitor wants to help. He really does. But he needs you to stop pointing at paintings you no longer care about.&lt;/p&gt;

&lt;p&gt;So go ahead. Open DevTools. Take a snapshot. See what’s still on your walls. And then, for the sake of your users’ RAM, start letting go.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>javascript</category>
      <category>productivity</category>
    </item>
    <item>
      <title>The Event Loop, Microtasks, and Macrotasks: A Visual Explanation</title>
      <dc:creator>Alex Aslam</dc:creator>
      <pubDate>Mon, 30 Mar 2026 20:42:54 +0000</pubDate>
      <link>https://forem.com/alex_aslam/the-event-loop-microtasks-and-macrotasks-a-visual-explanation-17do</link>
      <guid>https://forem.com/alex_aslam/the-event-loop-microtasks-and-macrotasks-a-visual-explanation-17do</guid>
      <description>&lt;p&gt;I’ve spent the better part of a decade writing JavaScript that pretends to be synchronous. I’ve built real‑time dashboards, complex state machines, and APIs that handle thousands of requests per second. And for years, I thought I understood the event loop. I’d nod along to talks, recite “non‑blocking I/O,” and move on.&lt;/p&gt;

&lt;p&gt;Then one night, I was debugging a bug that only happened in production. A &lt;code&gt;setTimeout&lt;/code&gt; with &lt;code&gt;0&lt;/code&gt; milliseconds was delaying a UI update just enough that a user could click a button twice. I added a &lt;code&gt;Promise.resolve().then()&lt;/code&gt;, and suddenly the timing changed. I sat there, staring at my screen, realizing I didn’t actually know the &lt;em&gt;order&lt;/em&gt; of things. I knew the words “microtask” and “macrotask,” but I didn’t &lt;em&gt;feel&lt;/em&gt; them.&lt;/p&gt;

&lt;p&gt;That night, I went down a rabbit hole that changed how I see our runtime. I stopped treating the event loop as a technical specification and started seeing it as a &lt;strong&gt;choreographed dance&lt;/strong&gt; a piece of visual art that runs inside every Node.js process and every browser tab.&lt;/p&gt;

&lt;p&gt;Let me take you on that journey. Forget the docs for a moment. Let’s look at the painting.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Studio: Call Stack &amp;amp; Web APIs
&lt;/h2&gt;

&lt;p&gt;Imagine your JavaScript runtime as a small, cluttered artist’s studio. In the centre is a single desk that’s the &lt;strong&gt;call stack&lt;/strong&gt;. It’s a LIFO (last‑in, first‑out) stack of frames. Your code runs here, one function at a time, and it’s &lt;em&gt;incredibly&lt;/em&gt; impatient. It can only do one thing at once.&lt;/p&gt;

&lt;p&gt;Off to the side are the &lt;strong&gt;Web APIs&lt;/strong&gt; (or Node.js APIs) think of them as the studio assistants. When you call &lt;code&gt;setTimeout&lt;/code&gt;, &lt;code&gt;fetch&lt;/code&gt;, or &lt;code&gt;addEventListener&lt;/code&gt;, you aren’t actually doing the waiting yourself. You hand the task to an assistant, say “call me back when you’re done,” and immediately clear your desk for the next piece of work.&lt;/p&gt;

&lt;p&gt;This is the first thing we internalize as seniors: &lt;em&gt;asynchronous functions don’t run asynchronously; they just let you hand off work so you’re not blocked.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Gallery: Task Queues (Macrotasks)
&lt;/h2&gt;

&lt;p&gt;When an assistant finishes its work (a timer expires, a network response arrives), it doesn’t just shove the callback onto the stack. That would be chaotic the stack might be in the middle of something important. Instead, the assistant places a note on a gallery wall. That wall is the &lt;strong&gt;task queue&lt;/strong&gt; (or &lt;strong&gt;macrotask queue&lt;/strong&gt;).&lt;/p&gt;

&lt;p&gt;The event loop is the curator. It watches the stack. If the stack is empty, it walks over to the gallery, picks up the &lt;em&gt;oldest&lt;/em&gt; note (first in, first out), and places that callback onto the stack to run.&lt;/p&gt;

&lt;p&gt;But here’s where my mental model broke that night: I thought there was &lt;em&gt;one&lt;/em&gt; queue. There isn’t.&lt;/p&gt;

&lt;p&gt;The gallery has multiple walls. One wall is for &lt;strong&gt;macrotasks&lt;/strong&gt; &lt;code&gt;setTimeout&lt;/code&gt;, &lt;code&gt;setInterval&lt;/code&gt;, I/O, UI rendering events. Another, smaller, more exclusive wall is for &lt;strong&gt;microtasks&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Private Collection: Microtasks
&lt;/h2&gt;

&lt;p&gt;Microtasks are the VIPs of the JavaScript world. They include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Promise&lt;/code&gt; callbacks (&lt;code&gt;then&lt;/code&gt;, &lt;code&gt;catch&lt;/code&gt;, &lt;code&gt;finally&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;queueMicrotask&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;MutationObserver&lt;/code&gt; (browser)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;process.nextTick&lt;/code&gt; in Node.js (technically a separate queue, but similar priority)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a &lt;code&gt;Promise&lt;/code&gt; resolves, its &lt;code&gt;.then&lt;/code&gt; callback doesn’t go to the macrotask wall. It goes to a &lt;em&gt;microtask queue&lt;/em&gt; that sits right next to the curator’s desk.&lt;/p&gt;

&lt;p&gt;And the curator (the event loop) has a strict rule:&lt;br&gt;&lt;br&gt;
&lt;strong&gt;After every single macrotask, before any rendering or the next macrotask, empty the entire microtask queue.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This changes everything.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Choreography in Motion
&lt;/h2&gt;

&lt;p&gt;Let’s watch a simple piece of code, not as logic, but as a ballet:&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2&lt;/span&gt;&lt;span class="dl"&gt;'&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="nb"&gt;Promise&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="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The performance:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stack:&lt;/strong&gt; &lt;code&gt;console.log('1')&lt;/code&gt; runs. Prints &lt;code&gt;1&lt;/code&gt;. Stack empties.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Macrotask:&lt;/strong&gt; &lt;code&gt;setTimeout&lt;/code&gt; hands a timer to an assistant. Assistant puts callback note on the macrotask wall (after 0ms).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Microtask:&lt;/strong&gt; &lt;code&gt;Promise.resolve().then&lt;/code&gt; schedules a microtask callback on the microtask wall.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stack:&lt;/strong&gt; &lt;code&gt;console.log('4')&lt;/code&gt; runs. Prints &lt;code&gt;4&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stack empty.&lt;/strong&gt; Curator checks microtask wall. Finds the promise callback. Runs it. Prints &lt;code&gt;3&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Microtask queue empty.&lt;/strong&gt; Curator now looks at macrotask wall. Finds the timer callback. Runs it. Prints &lt;code&gt;2&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Output: &lt;code&gt;1, 4, 3, 2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you ever thought &lt;code&gt;setTimeout(…,0)&lt;/code&gt; meant “run immediately after this,” you’ve been fooled by the curator’s priorities. Microtasks always cut in line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Frame: Rendering
&lt;/h2&gt;

&lt;p&gt;In the browser, there’s an extra act. Between macrotasks, the browser may decide to repaint. But microtasks happen &lt;em&gt;before&lt;/em&gt; that repaint. This is a critical insight for performance‑sensitive UIs.&lt;/p&gt;

&lt;p&gt;If you schedule a massive batch of microtasks (e.g., recursively chaining promises), you can &lt;strong&gt;starve&lt;/strong&gt; the rendering. The page will feel frozen because the curator is stuck emptying an ever‑growing microtask list. You’ve probably seen this as “jank.”&lt;/p&gt;

&lt;p&gt;As a senior, you learn to spot these subtle choreographic flaws. You learn that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use &lt;code&gt;setTimeout&lt;/code&gt; when you want to yield to the UI or give other macrotasks a chance.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;queueMicrotask&lt;/code&gt; or &lt;code&gt;Promise&lt;/code&gt; when you need something to happen &lt;em&gt;immediately after&lt;/em&gt; the current synchronous code, but before the next macrotask or render.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Node.js: The After‑Hours Studio
&lt;/h2&gt;

&lt;p&gt;Node.js doesn’t have a rendering phase, but it has its own quirks. It has a &lt;code&gt;process.nextTick&lt;/code&gt; queue that is &lt;em&gt;even more VIP&lt;/em&gt; than microtasks it gets processed before microtasks, between each phase of its event loop.&lt;/p&gt;

&lt;p&gt;The mental model I use now: the event loop is not a simple queue. It’s a &lt;strong&gt;roundabout with several exits&lt;/strong&gt;, each with different priority lanes. Understanding that roundabout has saved me from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Accidentally blocking the event loop with synchronous loops.&lt;/li&gt;
&lt;li&gt;Mis‑ordering critical database updates and cache writes.&lt;/li&gt;
&lt;li&gt;Building reliable real‑time systems where message order actually matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why This Is Art
&lt;/h2&gt;

&lt;p&gt;When I finally visualized this, I stopped seeing the event loop as a dry concept. I started seeing it as a &lt;strong&gt;kinetic sculpture&lt;/strong&gt;. Every &lt;code&gt;await&lt;/code&gt;, every &lt;code&gt;setTimeout&lt;/code&gt;, every resolved promise is a tiny marble rolling down a track. The track has checkpoints microtask checkpoints, macrotask gates, rendering frames.&lt;/p&gt;

&lt;p&gt;The art is in the &lt;em&gt;orchestration&lt;/em&gt;. You, the developer, place the marbles. The engine moves them with absolute consistency, but it’s your understanding of the track that determines whether the sculpture is a chaotic mess or a graceful, predictable performance.&lt;/p&gt;

&lt;p&gt;The best full‑stack developers I know don’t just write async/await. They &lt;em&gt;feel&lt;/em&gt; where the microtasks land. They know that an &lt;code&gt;await&lt;/code&gt; is syntactic sugar over a promise microtask. They use &lt;code&gt;setTimeout(fn, 0)&lt;/code&gt; intentionally to “break” a synchronous loop and let the UI breathe.&lt;/p&gt;

&lt;p&gt;They’ve stopped fighting the runtime and started composing with it.&lt;/p&gt;




&lt;h3&gt;
  
  
  Your Turn to Paint
&lt;/h3&gt;

&lt;p&gt;Next time you see an order‑of‑operations bug, don’t just sprinkle &lt;code&gt;async&lt;/code&gt; keywords. Draw the queues. Ask yourself: &lt;em&gt;Is this a macrotask? A microtask? Where is the render frame?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You’ll find that the more you respect the choreography, the more the engine rewards you with silky‑smooth performance and deterministic behavior.&lt;/p&gt;

&lt;p&gt;And if you ever need to explain it to a junior, skip the slides. Walk them through a whiteboard. Draw a circle for the stack, a wall for macrotasks, a smaller table for microtasks, and a little curator with tired eyes. It’ll stick.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>javascript</category>
      <category>productivity</category>
    </item>
    <item>
      <title>JavaScript Engine Under the Hood: How V8 Compiles Your Code</title>
      <dc:creator>Alex Aslam</dc:creator>
      <pubDate>Mon, 30 Mar 2026 20:32:32 +0000</pubDate>
      <link>https://forem.com/alex_aslam/javascript-engine-under-the-hood-how-v8-compiles-your-code-27ie</link>
      <guid>https://forem.com/alex_aslam/javascript-engine-under-the-hood-how-v8-compiles-your-code-27ie</guid>
      <description>&lt;p&gt;Let’s be honest with ourselves for a second. We spend our days wrangling React hooks, tweaking Next.js configs, and arguing about whether tabs are better than spaces (they are, fight me). We treat JavaScript like a high-level, friendly tool.&lt;/p&gt;

&lt;p&gt;But have you ever stopped in the middle of debugging a production memory leak, looked at your terminal, and thought: &lt;em&gt;What the hell is actually happening here?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I had that moment about six years ago. I was optimizing a Node.js microservice that was choking under load. I threw more hardware at it. It didn’t work. I optimized my algorithms. Barely a dent. Finally, I had to admit that I didn’t actually understand the "black box" that runs my code.&lt;/p&gt;

&lt;p&gt;So, I went down the rabbit hole of V8—the JavaScript engine that powers Chrome and Node.js. And what I found wasn’t just a compiler; it was a piece of performance art.&lt;/p&gt;

&lt;p&gt;Let’s take a journey. Imagine your code isn’t just text; it’s a raw lump of marble. V8 is the sculptor. And trust me, it’s a &lt;em&gt;weird&lt;/em&gt; sculptor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 1: The Parser (The Interrogator)
&lt;/h2&gt;

&lt;p&gt;When you hit &lt;code&gt;node server.js&lt;/code&gt; or refresh your browser tab, the first thing V8 does is &lt;strong&gt;not&lt;/strong&gt; run your code. It interrogates it.&lt;/p&gt;

&lt;p&gt;The engine doesn’t see &lt;code&gt;const x = 10;&lt;/code&gt; as we do. It sees a stream of characters. The &lt;strong&gt;Parser&lt;/strong&gt; takes that stream and performs a terrifyingly efficient act of structural comprehension.&lt;/p&gt;

&lt;p&gt;It builds the &lt;strong&gt;AST (Abstract Syntax Tree)&lt;/strong&gt; . This is the blueprint. But here is the humanized nuance: V8 is lazy. It’s the laziest overachiever I know.&lt;/p&gt;

&lt;p&gt;If you’ve written a function that doesn’t get called immediately, V8 says, "Cool story, bro," and performs &lt;em&gt;lazy parsing&lt;/em&gt;. It skips building the full AST for the inner scope of that function. It just checks for syntax errors so the page loads, but it doesn’t waste memory on code that isn’t running &lt;em&gt;right now&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;As a senior dev, you’ve probably felt this intuitively. You know that wrapping everything in an IIFE or loading a massive module at startup has a cost. That’s why. The engine is trying to be polite and save memory until you actually need that logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 2: Ignition (The Bus Driver)
&lt;/h2&gt;

&lt;p&gt;This is where the magic shifts from "reading" to "doing."&lt;/p&gt;

&lt;p&gt;Back in the old days (pre-2017), V8 was a two-faced monster: Full-Codegen (fast startup) and Crankshaft (optimizations). It worked, but it was heavy. Now, we have &lt;strong&gt;Ignition&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Ignition is the interpreter. It takes that AST from the parser and spits out &lt;strong&gt;Bytecode&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If your code is the screenplay, bytecode is the stage directions. It’s not machine code (1s and 0s your CPU loves), but it’s a lot smaller and more efficient than the raw JS text.&lt;/p&gt;

&lt;p&gt;Here is the human part: &lt;em&gt;Bytecode is the first draft.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When Ignition runs, it starts executing your code immediately. It doesn’t wait to understand the "grand plan." It just gets the job done. But while it’s running, it’s watching you. It’s taking notes. It’s profiling.&lt;/p&gt;

&lt;p&gt;It’s looking for the &lt;strong&gt;hot paths&lt;/strong&gt;—the loops that run a thousand times, the function that gets called every millisecond.&lt;/p&gt;

&lt;p&gt;And when it finds them? It whispers to the next guy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 3: Sparkplug &amp;amp; Maglev (The Pragmatists)
&lt;/h2&gt;

&lt;p&gt;This is the part that blew my mind when I first learned it. We used to think V8 was just an interpreter plus an optimizing compiler. It’s not.&lt;/p&gt;

&lt;p&gt;There is a middle ground now.&lt;/p&gt;

&lt;p&gt;When a function becomes "hot" (called enough times), V8 doesn’t immediately send it to the super-optimizing compiler. That would be like sending a grocery list to a world-class architect. Overkill.&lt;/p&gt;

&lt;p&gt;Instead, it uses &lt;strong&gt;Sparkplug&lt;/strong&gt;.&lt;br&gt;
Sparkplug is the &lt;em&gt;"just get it done"&lt;/em&gt; compiler. It takes the bytecode and compiles it to machine code &lt;em&gt;extremely&lt;/em&gt; fast. The code it produces isn’t winning any speed contests, but it’s faster than interpreting bytecode loop after loop.&lt;/p&gt;

&lt;p&gt;Think of Sparkplug as the senior dev who writes "good enough" code to unblock the team. It works. It’s stable. It’s fast &lt;em&gt;to compile&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;But if a function is &lt;em&gt;super&lt;/em&gt; hot—if it’s running thousands of times—V8 escalates. It sends the bytecode to &lt;strong&gt;Maglev&lt;/strong&gt; (new as of V8 11.0).&lt;/p&gt;

&lt;p&gt;Maglev is the middle manager. It does a quick analysis and creates a baseline optimized version. It’s a trade-off: a little more compile time for significantly faster runtime.&lt;/p&gt;

&lt;p&gt;Why does this matter to you? Because if your app has "jank" or inconsistent latency, you’re seeing these tiers in action. The engine is constantly balancing the cost of compilation against the cost of execution. It’s a real-time economic decision happening inside your server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 4: TurboFan (The Perfectionist)
&lt;/h2&gt;

&lt;p&gt;Now we enter the art gallery.&lt;/p&gt;

&lt;p&gt;For the code that survives the heat—the critical inner loops, the heavy math, the complex class instantiations—V8 finally unleashes &lt;strong&gt;TurboFan&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;TurboFan is the optimizing compiler. It takes the bytecode and the &lt;em&gt;feedback&lt;/em&gt; collected by Ignition and makes a bet.&lt;/p&gt;

&lt;p&gt;Here’s the risky part: &lt;em&gt;Speculative Optimization&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;JavaScript is dynamic. You can change a variable’s type whenever you want. The CPU &lt;em&gt;hates&lt;/em&gt; that. So, TurboFan looks at your code and says, "I saw that in the last 10,000 runs, &lt;code&gt;x&lt;/code&gt; was always a &lt;code&gt;Number&lt;/code&gt;. I’m going to assume it stays a &lt;code&gt;Number&lt;/code&gt;."&lt;/p&gt;

&lt;p&gt;It then rewrites your logic into highly optimized, CPU-specific machine code that assumes &lt;code&gt;x&lt;/code&gt; is a number.&lt;/p&gt;

&lt;p&gt;If you keep passing it numbers? Congratulations. Your code now runs as fast as C++.&lt;/p&gt;

&lt;p&gt;But if you change the type? If you pass a &lt;code&gt;string&lt;/code&gt; or &lt;code&gt;null&lt;/code&gt;?&lt;br&gt;
TurboFan’s assumption breaks. It drops the optimized code, throws an error called &lt;strong&gt;deoptimization&lt;/strong&gt;, and falls back to the slower bytecode.&lt;/p&gt;

&lt;p&gt;This is the "art" part. Writing performant JavaScript isn’t just about using &lt;code&gt;for&lt;/code&gt; instead of &lt;code&gt;forEach&lt;/code&gt; anymore. It’s about keeping the engine &lt;em&gt;happy&lt;/em&gt;. It’s about &lt;strong&gt;monomorphism&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you write a function that takes a &lt;code&gt;user&lt;/code&gt; object, and you always pass a &lt;code&gt;User&lt;/code&gt; class instance with the same shape (same properties, same order), V8 says, "Ah, a classic. TurboFan, make this &lt;em&gt;fast&lt;/em&gt;."&lt;/p&gt;

&lt;p&gt;But if your function sometimes gets a &lt;code&gt;{ name, id }&lt;/code&gt; and sometimes gets a &lt;code&gt;{ name, age, address }&lt;/code&gt;, V8 panics. It has to handle the chaos. It uses the slow path.&lt;/p&gt;

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

&lt;p&gt;When I realized this, my perspective on "clean code" changed.&lt;/p&gt;

&lt;p&gt;Clean code isn’t just about readability for the next developer. It’s about &lt;strong&gt;predictability for the compiler&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Consistent Types:&lt;/strong&gt; Initializing object properties in the same order isn’t just OCD; it’s a hint to V8’s hidden classes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small Functions:&lt;/strong&gt; They’re not just for unit testing. Small functions are easier for TurboFan to analyze and optimize without hitting the "budget" limit (if a function gets too complex, V8 gives up optimizing it).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avoiding &lt;code&gt;delete&lt;/code&gt;:&lt;/strong&gt; Using &lt;code&gt;delete obj.property&lt;/code&gt; breaks hidden classes. It forces the engine to switch from "fast mode" to "dictionary mode" (slow mode). It’s like repainting a wall in a museum while the tour is happening.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Unspoken Truth
&lt;/h2&gt;

&lt;p&gt;Here is the truth they don't tell you in bootcamps: JavaScript is not slow. &lt;em&gt;Your misuse of it is.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;V8 is a masterpiece of engineering. It’s a Just-In-Time (JIT) compiler that does adaptive optimization at a scale that would make Java devs blush. It’s an interpreter, a baseline compiler, a mid-tier compiler, and an ultra-optimizing compiler all living in the same process, making millions of decisions per second to make your code look fast.&lt;/p&gt;

&lt;p&gt;When you write code, you aren’t just instructing a computer. You are feeding an algorithm. The better you understand how that algorithm thinks—its preferences for stability, its obsession with types, its lazy parsing—the more you stop fighting the machine and start collaborating with it.&lt;/p&gt;

&lt;p&gt;So the next time you deploy a massive monorepo or optimize a critical API route, don’t just think about the code. Think about the journey.&lt;/p&gt;

&lt;p&gt;From raw text to bytecode. From a hot loop to TurboFan. From a lump of marble to a David.&lt;/p&gt;

&lt;p&gt;That’s the art of the engine.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Geofencing in Turbo Native with Core Location</title>
      <dc:creator>Alex Aslam</dc:creator>
      <pubDate>Sat, 28 Mar 2026 22:25:08 +0000</pubDate>
      <link>https://forem.com/alex_aslam/geofencing-in-turbo-native-with-core-location-4pn1</link>
      <guid>https://forem.com/alex_aslam/geofencing-in-turbo-native-with-core-location-4pn1</guid>
      <description>&lt;p&gt;I still remember standing on the sidewalk outside a client’s office, watching the beta testers drive around the block for the twentieth time.&lt;/p&gt;

&lt;p&gt;We had built a sleek Turbo Native app for a property management company. The web views were fast, the native navigation was smooth, and everyone was happy—until the product manager asked the inevitable question: &lt;em&gt;“Can we automatically check in a technician when they arrive at a job site?”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My stomach dropped. I knew what this meant. We were about to step out of the cozy world of web views and into the wild, unpredictable wilderness of Core Location, geofencing, and background execution. And we had to make it work inside a Turbo Native wrapper—a hybrid app that was, at its heart, a web app pretending to be native.&lt;/p&gt;

&lt;p&gt;What followed was a journey of frustration, late‑night debugging sessions, and eventually, a breakthrough that felt less like engineering and more like alchemy. This is the story of how we brought geofencing into Turbo Native—and how I learned that working with location is less about writing code and more about respecting the invisible boundaries of the physical world.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Hybrid Trap
&lt;/h3&gt;

&lt;p&gt;Turbo Native (formerly Turbo Native for iOS) is a gift to full‑stack developers. It lets you wrap your Rails web app in a native shell, giving you native navigation, push notifications, and a few other perks, while keeping the bulk of your UI in the familiar territory of HTML, CSS, and JavaScript.&lt;/p&gt;

&lt;p&gt;But geofencing? That’s a different beast. The web has the Geolocation API, which works well enough for a one‑time “where am I” query. But for monitoring regions in the background—detecting when a user enters or leaves a predefined area—you need the full power of Core Location on iOS. And that lives in the native layer, not in the web view.&lt;/p&gt;

&lt;p&gt;We had to figure out how to let the native side do the heavy lifting of monitoring, and then communicate those events to the JavaScript side so our Rails‑powered views could react. It was like teaching two musicians to play the same piece without a conductor.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Art of Bridging
&lt;/h3&gt;

&lt;p&gt;If you’ve worked with Turbo Native, you know that the bridge between Swift and JavaScript is usually the &lt;code&gt;TurboSession&lt;/code&gt; and message handlers. You can inject a JavaScript interface into the web view, or use &lt;code&gt;WKWebView&lt;/code&gt;’s &lt;code&gt;postMessage&lt;/code&gt; mechanism. We chose the latter because it felt cleaner: the native side sends events to the web view, and the web view listens with &lt;code&gt;window.addEventListener&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here’s a stripped‑down version of what our Swift side looked like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;CoreLocation&lt;/span&gt;
&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;UIKit&lt;/span&gt;
&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;WebKit&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;GeofencingManager&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;NSObject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;CLLocationManagerDelegate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;locationManager&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CLLocationManager&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;webView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;WKWebView&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;startMonitoring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;webView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;WKWebView&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;webView&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;webView&lt;/span&gt;
        &lt;span class="n"&gt;locationManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delegate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;
        &lt;span class="n"&gt;locationManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestAlwaysAuthorization&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="c1"&gt;// Create a region (e.g., a job site)&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;center&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CLLocationCoordinate2D&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;37.7749&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;122.4194&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;region&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CLCircularRegion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;center&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;center&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                       &lt;span class="nv"&gt;radius&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="nv"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"JobSite_123"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notifyOnEntry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notifyOnExit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="n"&gt;locationManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startMonitoring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;for&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;locationManager&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;manager&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CLLocationManager&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;didEnterRegion&lt;/span&gt; &lt;span class="nv"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CLRegion&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;sendEventToWebView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"didEnterRegion"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"identifier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;locationManager&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;manager&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CLLocationManager&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;didExitRegion&lt;/span&gt; &lt;span class="nv"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;CLRegion&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;sendEventToWebView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"didExitRegion"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"identifier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;sendEventToWebView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;webView&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;webView&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;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;script&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"""
        window.dispatchEvent(new CustomEvent('&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;', { detail: &lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="nf"&gt;jsonString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt; }));
        """&lt;/span&gt;
        &lt;span class="n"&gt;webView&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluateJavaScript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;script&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;completionHandler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&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;On the JavaScript side, we could listen like this:&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;didEnterRegion&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;event&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;identifier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// Call Rails‑backed API or update the UI&lt;/span&gt;
  &lt;span class="nx"&gt;Turbo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;visit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/job_sites/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/arrive`&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;Simple, right? It was, until we realized that background execution, battery life, and user permissions would turn this elegant bridge into a minefield.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Invisible Trade‑offs
&lt;/h3&gt;

&lt;p&gt;Geofencing is not a “set it and forget it” feature. It’s a negotiation between your app’s needs and the operating system’s constraints.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Permission Dialogues&lt;/strong&gt; – Asking for “Always” location permission is a delicate moment. If you get it wrong, users will tap “Allow While Using” and your geofencing will stop working as soon as the app goes to the background. We learned to present a clear, empathetic explanation &lt;em&gt;before&lt;/em&gt; the system dialog appeared—using a native screen that explained &lt;em&gt;why&lt;/em&gt; we needed to track them even when the app was closed. This single change increased “Always” acceptance from 30% to 85%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Battery Life&lt;/strong&gt; – Every geofence you monitor consumes power. The system batches region updates to save battery, but you still need to be smart. We limited the number of active regions to 20 (Apple’s recommended maximum) and aggressively removed regions for completed jobs. We also used the &lt;code&gt;accuracy&lt;/code&gt; parameter to balance precision with power: a radius of 100 meters was enough for our use case, and it let iOS use cell tower triangulation instead of constant GPS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing in the Real World&lt;/strong&gt; – You can simulate geofencing in the simulator, but it’s a lie. The real world has trees, buildings, and spotty GPS. We had to physically drive to locations to test. I spent an entire afternoon walking around a construction site with a debug build, watching logs, and adjusting radius values. It felt absurd, but it was the only way to understand how the system behaved.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Web View’s Blind Spots
&lt;/h3&gt;

&lt;p&gt;One of the hardest lessons came when we realized that the web view—our precious Turbo Native shell—has no knowledge of the native app’s lifecycle. If the user killed the app, our &lt;code&gt;CLLocationManager&lt;/code&gt; would stop monitoring. When the app restarted, we had to re‑register all the regions. That meant persisting the list of active regions (we stored them in the app’s UserDefaults) and re‑starting monitoring on every launch.&lt;/p&gt;

&lt;p&gt;We also had to handle the case where the app was launched in the background due to a geofence event. In that scenario, there’s no visible web view. We needed to perform a silent sync with the server and optionally show a local notification to alert the user. That meant adding a push notification layer (or using &lt;code&gt;UNUserNotificationCenter&lt;/code&gt;) to communicate with the user when the app was in the background.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Artistic Mindset
&lt;/h3&gt;

&lt;p&gt;After weeks of wrestling with Core Location, I realized that geofencing is less like programming and more like painting with invisible ink. You define boundaries that no one sees, and you trust that the system will whisper to your app when a user crosses them. But the medium is messy: GPS drift, battery‑saving throttling, and user permissions can all blur the lines.&lt;/p&gt;

&lt;p&gt;The art lies in setting expectations. We built a simple UI in the web view that showed the status of geofencing—whether it was enabled, how many active regions there were, and a history of recent events. This transparency helped users understand why the app was behaving the way it was. When a technician arrived at a site but didn’t get an immediate check‑in, they knew it was because the system was waiting for a stable GPS fix, not because the app was broken.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Moment It Clicked
&lt;/h3&gt;

&lt;p&gt;The breakthrough came during a user acceptance test. We sat in a van with a technician who was skeptical of the whole idea. He drove toward a job site, and as he pulled into the driveway, the app chimed and automatically opened the work order. His eyes widened. “It just knows,” he said.&lt;/p&gt;

&lt;p&gt;That moment made all the complexity worthwhile. Geofencing, when done right, creates magic—a sense that the app is anticipating the user’s needs. And in a Turbo Native world, where most of the app is just a web view, that sprinkle of native magic can be the difference between a forgettable hybrid app and a beloved tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lessons for Senior Full‑Stack Developers
&lt;/h3&gt;

&lt;p&gt;If you’re embarking on this journey, here’s what I wish someone had told me before I started:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Respect the user’s privacy.&lt;/strong&gt; Ask for “Always” permission only after explaining why. Give them a way to turn it off in settings. Build trust.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test on real devices.&lt;/strong&gt; The simulator is useful for logic, but the real world is where geofencing lives or dies. Walk, drive, and use Xcode’s debug location simulation to approximate real conditions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embrace async.&lt;/strong&gt; Geofencing events are asynchronous and can happen when your web view isn’t even loaded. Design your JavaScript to be resilient: use an event queue if the page isn’t ready, and replay events when the view appears.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor your own app.&lt;/strong&gt; Add logging (with user consent) to see how often regions trigger. You’ll discover that users don’t always drive exactly through the center of your circles—adjust your radii based on real data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know your limits.&lt;/strong&gt; iOS limits the number of monitored regions per app (currently 20). Design your system to activate and deactivate regions dynamically based on the user’s current location or time of day.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The Art of the Invisible
&lt;/h3&gt;

&lt;p&gt;Geofencing in a Turbo Native app is ultimately about bridging two worlds: the web’s flexibility and the platform’s intimate awareness of the physical world. It’s a reminder that the best hybrid apps aren’t just web apps wrapped in native shells—they’re conversations between the two layers, each contributing what it does best.&lt;/p&gt;

&lt;p&gt;Our technicians now start their day with a list of jobs, and the app quietly monitors their location. When they arrive, they don’t have to tap anything. The app knows. It feels like a sixth sense, and it’s become the feature that users rave about.&lt;/p&gt;

&lt;p&gt;As senior developers, we often obsess over architecture patterns and performance metrics. But sometimes, the most rewarding work is the kind that disappears into the background—making the app feel less like software and more like an extension of the real world.&lt;/p&gt;

&lt;p&gt;That’s the art. That’s the journey.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>beginners</category>
      <category>ruby</category>
    </item>
    <item>
      <title>React Native + Rails synchronization with WatermelonDB</title>
      <dc:creator>Alex Aslam</dc:creator>
      <pubDate>Sat, 28 Mar 2026 22:22:14 +0000</pubDate>
      <link>https://forem.com/alex_aslam/react-native-rails-synchronization-with-watermelondb-227k</link>
      <guid>https://forem.com/alex_aslam/react-native-rails-synchronization-with-watermelondb-227k</guid>
      <description>&lt;p&gt;I still remember the Slack message that changed my entire approach to mobile development.&lt;/p&gt;

&lt;p&gt;It came from our lead iOS engineer at 11:47 PM: &lt;em&gt;“The app crashes when the train goes into the tunnel. Every. Single. Time.”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We had built a beautiful React Native app for field technicians. The Rails backend was solid. The API was RESTful. The UI was pixel‑perfect. But the moment the network got spotty—on the subway, in a basement, in the middle of nowhere—the app fell apart. Spinners that never stopped. Forms that failed to submit. Users who wanted to throw their phones into the nearest river.&lt;/p&gt;

&lt;p&gt;We tried caching. We tried Redux persist. We tried local storage hacks. Nothing worked reliably. The app was a house of cards, and every network hiccup was a gust of wind.&lt;/p&gt;

&lt;p&gt;That’s when I stumbled on a GitHub repository with a strange name: WatermelonDB. I read the README, and my heart started racing. This wasn’t another “just store some JSON in AsyncStorage” library. This was a full‑blown, reactive database for React Native, built for offline‑first apps with massive data sets.&lt;/p&gt;

&lt;p&gt;The tagline said: &lt;em&gt;“Build powerful React Native apps that work offline, with lightning-fast performance.”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I was skeptical. I’d been burned before. But three months later, after a journey of late nights, whiteboard arguments, and one unforgettable production deployment, I became a believer. This is the story of how we synchronized React Native with Rails using WatermelonDB—and how I learned that synchronization is less about code and more about art.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Problem: Offline Isn’t Optional
&lt;/h3&gt;

&lt;p&gt;Our use case was brutal. Field technicians in industrial sites needed to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;View thousands of work orders, even with zero connectivity.&lt;/li&gt;
&lt;li&gt;Fill out detailed forms with photos, signatures, and checklists.&lt;/li&gt;
&lt;li&gt;Sync everything automatically when they returned to the office or found a Wi‑Fi hotspot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We tried the obvious: store API responses in AsyncStorage, show a cached version when offline, and queue mutations with a custom sync manager. It worked… for about a week. Then we hit the walls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Performance&lt;/strong&gt; – AsyncStorage is synchronous and blocking. Loading 5,000 work orders froze the UI for seconds.&lt;br&gt;
&lt;strong&gt;Consistency&lt;/strong&gt; – Redux persisted state could get out of sync with the backend. We had no way to know if the data was fresh.&lt;br&gt;
&lt;strong&gt;Conflicts&lt;/strong&gt; – When two technicians edited the same work order offline, the last one to sync won. We lost data.&lt;/p&gt;

&lt;p&gt;We needed a database that was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fast&lt;/strong&gt; – Queries in milliseconds, even with tens of thousands of records.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reactive&lt;/strong&gt; – The UI should update automatically when data changes, without manual refetching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sync‑aware&lt;/strong&gt; – It needed a built‑in way to handle pull and push synchronization with a backend.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;WatermelonDB checked every box.&lt;/p&gt;
&lt;h3&gt;
  
  
  WatermelonDB: The Database That Woke Up
&lt;/h3&gt;

&lt;p&gt;WatermelonDB is not your typical mobile database. It’s built on top of SQLite (via &lt;code&gt;@nozbe/watermelondb&lt;/code&gt;), but it adds a reactive layer that feels like magic. You define models with decorators, query with &lt;code&gt;.observe()&lt;/code&gt;, and the UI re‑renders automatically when data changes.&lt;/p&gt;

&lt;p&gt;The learning curve was steeper than I expected. It requires a different mental model: you’re working with observables and collections, not traditional imperative queries. But the payoff is immense.&lt;/p&gt;

&lt;p&gt;Here’s a snippet of what a model looked like for us:&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;Model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;relation&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;@nozbe/watermelondb&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WorkOrder&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nx"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;work_orders&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="nd"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;work_order_number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;workOrderNumber&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scheduled_date&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;scheduledDate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;relation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;users&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;assigned_to&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;assignedTo&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;Simple, declarative, and reactive. But the real magic came when we added the sync engine.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Sync Art: Bridging Rails and Watermelon
&lt;/h3&gt;

&lt;p&gt;Synchronizing a WatermelonDB database with a Rails backend is an art form. It’s not a plug‑and‑play solution; you have to design both sides to speak the same language.&lt;/p&gt;

&lt;p&gt;We spent a week sketching on a whiteboard, mapping out the synchronization lifecycle. We ended up with a two‑way sync strategy:&lt;/p&gt;

&lt;h4&gt;
  
  
  1. Pull: Getting the Initial Data and Updates
&lt;/h4&gt;

&lt;p&gt;WatermelonDB’s &lt;code&gt;synchronize&lt;/code&gt; method expects a &lt;code&gt;pull&lt;/code&gt; function that fetches changes since a given timestamp. On the Rails side, we built an endpoint that accepted a &lt;code&gt;last_synced_at&lt;/code&gt; parameter and returned:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A list of created/updated records (in a compact JSON format)&lt;/li&gt;
&lt;li&gt;A list of deleted record IDs&lt;/li&gt;
&lt;li&gt;A new timestamp for the next sync&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We used &lt;code&gt;updated_at&lt;/code&gt; columns to track changes. But we quickly realized that relying solely on timestamps could miss updates that happened in the same second. So we added a &lt;code&gt;sync_version&lt;/code&gt; integer that increments on every change—a classic optimistic locking approach.&lt;/p&gt;

&lt;p&gt;The Rails endpoint looked something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# /api/v1/sync/pull&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pull&lt;/span&gt;
  &lt;span class="n"&gt;since&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:last_synced_at&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="no"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&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="n"&gt;records&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;WorkOrder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'updated_at &amp;gt; ?'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;since&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;deleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;WorkOrder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deleted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'deleted_at &amp;gt; ?'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;since&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;pluck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="ss"&gt;changes: &lt;/span&gt;&lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="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="no"&gt;WorkOrderSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;as_json&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="ss"&gt;deleted: &lt;/span&gt;&lt;span class="n"&gt;deleted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="ss"&gt;timestamp: &lt;/span&gt;&lt;span class="no"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But we didn’t stop there. WatermelonDB allows you to send the entire dataset in chunks, so we implemented pagination for the initial sync to avoid loading 50,000 records at once.&lt;/p&gt;

&lt;h4&gt;
  
  
  2. Push: Sending Local Changes to Rails
&lt;/h4&gt;

&lt;p&gt;Push was harder. WatermelonDB expects a &lt;code&gt;push&lt;/code&gt; function that sends a batch of created, updated, and deleted records. On the Rails side, we had to process them in order, handle conflicts, and respond with success or failure for each record.&lt;/p&gt;

&lt;p&gt;We created a &lt;code&gt;POST /api/v1/sync/push&lt;/code&gt; endpoint that accepted an array of changes. Each change included:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;id&lt;/code&gt; (local WatermelonDB ID)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;table&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;action&lt;/code&gt; (create, update, delete)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data&lt;/code&gt; (the raw attributes)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Rails controller had to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Validate each change (permissions, data integrity)&lt;/li&gt;
&lt;li&gt;Apply it to the database&lt;/li&gt;
&lt;li&gt;Handle conflicts (if the server version was newer, we returned a “conflict” response so the client could resolve it)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This was the most complex part. We introduced a &lt;code&gt;last_synced_at&lt;/code&gt; on each record to detect conflicts. If the server’s &lt;code&gt;updated_at&lt;/code&gt; was newer than the client’s version, we rejected the push and sent the server version back for the client to merge.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Art of Conflict Resolution
&lt;/h3&gt;

&lt;p&gt;Conflicts are inevitable in offline‑first apps. You can’t avoid them; you can only manage them gracefully.&lt;/p&gt;

&lt;p&gt;We implemented a three‑tier strategy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Last‑write‑wins (LWW)&lt;/strong&gt; – For non‑critical fields like notes or comments, we simply let the latest write (by timestamp) win. We stored a &lt;code&gt;client_updated_at&lt;/code&gt; field on the client and used that to determine precedence.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Merge&lt;/strong&gt; – For more complex data, like checklist items, we merged changes. If the technician added a new item offline and the office changed the description of another item, we combined both.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Manual resolution&lt;/strong&gt; – In rare cases (e.g., conflicting signatures), we flagged the record and asked the user to resolve it during sync. This was a last resort.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;WatermelonDB’s sync adapter made it possible to implement these strategies cleanly. We wrote custom resolvers that ran on the client after a conflict was detected, merging data or showing a modal.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Performance Revelation
&lt;/h3&gt;

&lt;p&gt;Once we had the sync working, we tested it with our production data set: 20,000 work orders, 500 technicians, and thousands of photos. The initial sync took about 90 seconds over a slow 3G connection—unacceptable.&lt;/p&gt;

&lt;p&gt;We optimized in several ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chunked sync&lt;/strong&gt; – We broke the initial sync into pages of 500 records. WatermelonDB processes them in batches, so the UI stayed responsive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Selective sync&lt;/strong&gt; – We didn’t sync all work orders. Only those assigned to the technician or related to their location. We added &lt;code&gt;WHERE assigned_to = ?&lt;/code&gt; on the pull endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Binary data&lt;/strong&gt; – Photos were synced separately with a background upload queue, not through WatermelonDB. The database only stored local file references.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The final result: first sync in ~15 seconds, incremental syncs in &amp;lt;2 seconds, and the UI never dropped below 60fps.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lessons from the Journey
&lt;/h3&gt;

&lt;p&gt;Looking back, I realize that building this sync layer was less about coding and more about understanding the &lt;em&gt;shape&lt;/em&gt; of our data and the &lt;em&gt;reality&lt;/em&gt; of our users. We had to make trade‑offs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Consistency vs. availability&lt;/strong&gt; – We chose availability (the app works offline) and accepted eventual consistency. Users could see stale data for a few minutes, but they could always work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complexity vs. user experience&lt;/strong&gt; – The sync engine added 30% more code to our codebase. But it eliminated 90% of the support tickets related to network issues. Worth it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We also learned to respect WatermelonDB’s constraints. It’s not a relational database in the traditional sense—it’s a reactive object store with SQLite underneath. You have to design your models to match your access patterns, not the other way around.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Art of Sync
&lt;/h3&gt;

&lt;p&gt;If I had to distill this journey into one piece of advice for senior full‑stack developers, it would be this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Synchronization is not a technical feature; it’s a product experience.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When you build an offline‑first app, you’re promising your users that their work will be safe, that the app will be fast, and that the data will eventually be where it needs to be. WatermelonDB gives you the tools—but you, the artist, must paint the picture.&lt;/p&gt;

&lt;p&gt;You have to decide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How fresh does the data need to be?&lt;/li&gt;
&lt;li&gt;What happens when two people edit the same thing?&lt;/li&gt;
&lt;li&gt;How do you communicate sync status without annoying the user?&lt;/li&gt;
&lt;li&gt;How do you recover from sync failures?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are design questions, not just engineering ones. And the best solutions come from walking in your users’ shoes—or, in our case, riding in their trucks, watching them work in basements and barns, and understanding that a spinning spinner is a betrayal of trust.&lt;/p&gt;

&lt;p&gt;We deployed the new WatermelonDB‑powered app six months after that frantic Slack message. The first week, we held our breath. Support tickets dropped by 80%. The lead iOS engineer sent a new message: &lt;em&gt;“The train tunnel test passed. It didn’t even blink.”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That’s the art. That’s the journey.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>beginners</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Turbo Native + offline-first strategies with Workbox</title>
      <dc:creator>Alex Aslam</dc:creator>
      <pubDate>Thu, 26 Mar 2026 07:56:45 +0000</pubDate>
      <link>https://forem.com/alex_aslam/turbo-native-offline-first-strategies-with-workbox-24i8</link>
      <guid>https://forem.com/alex_aslam/turbo-native-offline-first-strategies-with-workbox-24i8</guid>
      <description>&lt;p&gt;I still remember the knot in my stomach as we watched the demo.&lt;/p&gt;

&lt;p&gt;We were standing in a converted barn in rural Vermont, holding our iPads up to show the client their brand‑new field service app. The barn had beautiful wooden beams, a lot of charm, and exactly zero bars of cellular signal. The app, a sleek Turbo Native wrapper around our Rails web app had loaded perfectly when we tested it in the office. But out here, with no network, it just showed a white screen and a spinning spinner that would never stop.&lt;/p&gt;

&lt;p&gt;The client smiled politely. I wanted to disappear into the hay.&lt;/p&gt;

&lt;p&gt;That day taught me something that no amount of architectural diagrams could have conveyed: &lt;strong&gt;Offline is not a feature. It’s a promise you make to your users.&lt;/strong&gt; And if you’re building a hybrid app with Turbo Native, you’d better have a strategy to keep that promise when the world goes dark.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Dream and the Reality
&lt;/h3&gt;

&lt;p&gt;Turbo Native is a beautiful piece of technology. For those who haven’t used it: it’s part of the Hotwire ecosystem, designed to let you wrap your existing web app in a native shell, giving you the best of both worlds &lt;br&gt;
native navigation and the flexibility of the web. You build one set of views in Rails (or any backend), and they render inside a native WebView. It’s fast, it’s elegant, and it makes senior full‑stack developers feel like they’ve found a cheat code.&lt;/p&gt;

&lt;p&gt;But there’s a catch.&lt;/p&gt;

&lt;p&gt;Turbo Native, out of the box, assumes you have a network. It loads URLs, caches them briefly, but if you’re offline, it fails. Gracefully? Not really. It fails with the same blank despair my iPad showed in that barn.&lt;/p&gt;

&lt;p&gt;The client’s technicians would be working in basements, parking garages, and yes, rural barns. They needed to &lt;em&gt;use&lt;/em&gt; the app view their work orders, fill out forms, take photos even when the network was a distant memory.&lt;/p&gt;

&lt;p&gt;We needed an offline‑first strategy. And that’s when I rediscovered a tool I had previously dismissed as “just for marketing sites”: &lt;strong&gt;Workbox&lt;/strong&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  Workbox: Not Just for SPAs
&lt;/h3&gt;

&lt;p&gt;If you’ve only seen Workbox used to precache static assets for a React app, you’re missing the real magic. Workbox is a library that sits on top of service workers, and service workers are the most underrated API on the web platform. They are a proxy between your app and the network, and when you combine them with Turbo Native, you can turn your hybrid app into a resilient, offline‑first machine.&lt;/p&gt;

&lt;p&gt;But let’s be honest: service workers are also a pain to get right. They live in a weird space, they update in mysterious ways, and debugging them can make you question your career choices. Workbox abstracts the complexity into declarative strategies, but you still need to think like an artist not an assembly line worker.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Art of Caching Strategy
&lt;/h3&gt;

&lt;p&gt;The first mistake we made was thinking we could just precache everything. Throw the entire web app into the service worker at install time. That works for a simple site, but our app had thousands of work orders, user‑specific data, and a backend that changed daily. Precaching all of that would have been insane.&lt;/p&gt;

&lt;p&gt;So we had to think about &lt;em&gt;what&lt;/em&gt; to cache and &lt;em&gt;how&lt;/em&gt;.&lt;/p&gt;
&lt;h4&gt;
  
  
  1. Static Assets: Cache‑First with a Fallback
&lt;/h4&gt;

&lt;p&gt;The app shell CSS, JavaScript, images should be available offline. For these, we used Workbox’s &lt;code&gt;StaleWhileRevalidate&lt;/code&gt; strategy. Users get the cached version instantly, and the service worker quietly updates it in the background. This gives the illusion of speed and resilience.&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="nx"&gt;workbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;routing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;style&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;script&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;workbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;strategies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;StaleWhileRevalidate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;cacheName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;static-resources&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  2. API Responses: Network‑First with a Cache Fallback
&lt;/h4&gt;

&lt;p&gt;For dynamic data like work orders, we used &lt;code&gt;NetworkFirst&lt;/code&gt;. Try the network; if it fails, serve from cache. But here’s the art: you need to decide &lt;em&gt;which&lt;/em&gt; requests get this treatment. For us, the home dashboard and individual work orders were critical. We also had to handle pagination and search caching every possible query would be wasteful.&lt;/p&gt;

&lt;p&gt;We ended up with a hybrid: we cached the last‑viewed work orders and used a custom cache key based on the URL and the user ID. Workbox’s plugins allowed us to add custom logic.&lt;/p&gt;

&lt;h4&gt;
  
  
  3. Offline Writes: The Hardest Part
&lt;/h4&gt;

&lt;p&gt;The real complexity came with mutations. Technicians needed to submit forms (complete a work order, add notes, take photos) even when offline. How do you handle that?&lt;/p&gt;

&lt;p&gt;We initially tried to rely on the browser’s built‑in form submission, but it would fail and show an error. Not acceptable. So we built a tiny client‑side queue using IndexedDB. When the user submits a form offline, we intercept the request, store it in IndexedDB, and immediately update the UI optimistically. When the network returns, we replay the requests in order using Workbox’s background sync.&lt;/p&gt;

&lt;p&gt;This part felt like surgery. We had to ensure idempotency, conflict resolution, and a user‑friendly UI that showed “pending sync” indicators. But when it worked when we could fill out a form in a dead zone, drive to a Starbucks, and watch the data silently sync it was pure magic.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Turbo Native Twist
&lt;/h3&gt;

&lt;p&gt;Here’s where it gets interesting. Turbo Native has its own navigation stack. It loads pages via &lt;code&gt;Turbo.visit()&lt;/code&gt; and caches them in a memory‑based cache. If you’re offline, that cache is empty, and the app fails.&lt;/p&gt;

&lt;p&gt;We had to make the service worker and Turbo work together. The key was to intercept requests at the service worker level &lt;em&gt;before&lt;/em&gt; Turbo even sees them. If the service worker returns a cached response, Turbo thinks it came from the network. That means the entire navigation experience remains smooth, even offline.&lt;/p&gt;

&lt;p&gt;But there was a gotcha: Turbo uses &lt;code&gt;fetch&lt;/code&gt; for its requests, and service workers can respond to those. However, Turbo also maintains its own back‑forward cache. We had to be careful not to double‑cache or cause conflicts. The solution was to keep Turbo’s in‑memory cache short‑lived (which is default) and rely on the service worker for long‑term offline storage.&lt;/p&gt;

&lt;p&gt;We also used Workbox’s &lt;code&gt;NavigationRoute&lt;/code&gt; to handle the initial page loads, ensuring that the app shell was always available.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Journey: From Panic to Pride
&lt;/h3&gt;

&lt;p&gt;Looking back, the journey to offline‑first Turbo Native was not a straight line. It was a series of failures, each one teaching us something new:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We broke the service worker update flow and users were stuck on an old version. Learned to implement a version‑based cache busting and prompt users to refresh.&lt;/li&gt;
&lt;li&gt;We cached API responses that contained sensitive data, then realized we needed to clear the cache on logout. Added a custom cache cleanup.&lt;/li&gt;
&lt;li&gt;We tried to sync offline mutations without proper ordering, causing conflicts. Moved to a serial queue with retry logic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But the moment that made it all worth it was when we returned to that barn. This time, we had the app open, and we deliberately turned on airplane mode. The app still showed the dashboard cached data. We clicked into a work order, added notes, attached a photo, and hit “Submit.” It showed “Saved offline.” Then we turned off airplane mode, and within seconds, the data appeared on our server.&lt;/p&gt;

&lt;p&gt;The client’s eyes lit up. “So it just works? Anywhere?”&lt;/p&gt;

&lt;p&gt;“Yes,” I said. “Anywhere.”&lt;/p&gt;

&lt;h3&gt;
  
  
  The Bigger Picture: Art, Not Engineering
&lt;/h3&gt;

&lt;p&gt;What we built wasn’t just a technical solution. It was a piece of art. We had to understand the users’ context working in the field, in unpredictable conditions and shape the technology to fit their reality, not the other way around.&lt;/p&gt;

&lt;p&gt;Offline‑first is an attitude. It’s about assuming the network is unreliable, and designing for that as the default. Service workers and Workbox give you the palette, but the composition is yours.&lt;/p&gt;

&lt;p&gt;For senior full‑stack developers, this is the kind of work that matters. It’s not about following a recipe; it’s about understanding the medium web views, service workers, native shells and blending them into something seamless.&lt;/p&gt;

&lt;h3&gt;
  
  
  Your Turn
&lt;/h3&gt;

&lt;p&gt;If you’re building a Turbo Native app and you haven’t yet considered offline, I urge you to. Start small: cache your static assets, then move to API responses, then tackle mutations. Embrace the complexity, because the reward is an app that works in elevators, on airplanes, and in the middle of nowhere.&lt;/p&gt;

&lt;p&gt;And when you inevitably hit a wall, remember: the service worker is just a JavaScript file. You can debug it, you can version it, you can bend it to your will. It’s not magic, it’s just code. But the experience it enables? That’s the magic.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>ruby</category>
      <category>mobile</category>
    </item>
  </channel>
</rss>
