<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Alex Aslam</title>
    <description>The latest articles on DEV Community by Alex Aslam (@alex_aslam).</description>
    <link>https://dev.to/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>DEV Community: Alex Aslam</title>
      <link>https://dev.to/alex_aslam</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alex_aslam"/>
    <language>en</language>
    <item>
      <title>Database-Level Caching with Materialized Views and Summary Tables: The Art of Precomputed Truth</title>
      <dc:creator>Alex Aslam</dc:creator>
      <pubDate>Sun, 03 May 2026 21:38:26 +0000</pubDate>
      <link>https://dev.to/alex_aslam/database-level-caching-with-materialized-views-and-summary-tables-the-art-of-precomputed-truth-1i6l</link>
      <guid>https://dev.to/alex_aslam/database-level-caching-with-materialized-views-and-summary-tables-the-art-of-precomputed-truth-1i6l</guid>
      <description>&lt;p&gt;Let me rewind to a Tuesday afternoon I’d rather forget.&lt;/p&gt;

&lt;p&gt;We had a Rails monolith that had grown fat and happy over five years. The dashboard—a beautiful, chart-filled monster—was running a 12-second query every time the CEO clicked “refresh.” Twelve seconds of &lt;code&gt;GROUP BY&lt;/code&gt;, &lt;code&gt;COUNT(DISTINCT)&lt;/code&gt;, and &lt;code&gt;LEFT JOIN&lt;/code&gt; hell across a million-row &lt;code&gt;events&lt;/code&gt; table.&lt;/p&gt;

&lt;p&gt;The CEO didn’t yell. He just stared at the spinning cursor and said, “This used to be fast.” That silence was worse.&lt;/p&gt;

&lt;p&gt;I’d already tried everything. Redis caching? Stale data on first load. Counter caches? Fine for counts, useless for complex rollups. Pagination? The dashboard needed totals. I was staring at the ceiling at 2 AM when I whispered: “What if I just… precompute the answer?”&lt;/p&gt;

&lt;p&gt;Materialized views and summary tables aren’t new. They’re as old as data warehouses. But in Rails, with ActiveRecord’s object‑relational map guiding our every thought, we forget that the database itself can be a &lt;em&gt;cache&lt;/em&gt;. A smart, transactional, ACID‑compliant cache that never lies.&lt;/p&gt;

&lt;p&gt;This is the journey of learning to think in sets, not objects. Senior Rails devs who’ve optimized N+1 queries to death: your next frontier is the precomputed column.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lie We Tell Ourselves: “Indexes Are Enough”
&lt;/h2&gt;

&lt;p&gt;We throw indexes at everything. Composite, partial, expression‑based. And indexes are magic—until they aren’t. When your query aggregates millions of rows, the database still has to &lt;em&gt;read&lt;/em&gt; those rows. Even with a covering index, you’re doing work per row, per request.&lt;/p&gt;

&lt;p&gt;I remember running &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; on that CEO dashboard:&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;Aggregate&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cost&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;12483&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;67&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;12483&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;68&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;  &lt;span class="n"&gt;Seq&lt;/span&gt; &lt;span class="n"&gt;Scan&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cost&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;10483&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;33&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;400068&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="o"&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;Filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'2024-01-01'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Seq scan&lt;/em&gt;. Four hundred thousand rows. Every. Single. Request.&lt;/p&gt;

&lt;p&gt;Indexes reduced the scan to an index scan, but the aggregate still looped over hundreds of thousands of index entries. The database was doing the same work over and over. Like a chef who grates a block of cheese for every single omelet, instead of pre‑grating a bowl in the morning.&lt;/p&gt;

&lt;p&gt;That’s when I discovered materialized views: the pre‑grated cheese bowl.&lt;/p&gt;

&lt;h2&gt;
  
  
  The First Step: Materialized Views as Heavy‑Lifters
&lt;/h2&gt;

&lt;p&gt;A materialized view is a query whose results are stored physically on disk. You refresh it on a schedule, or after relevant data changes. Reads are &lt;em&gt;instantaneous&lt;/em&gt;—milliseconds instead of seconds.&lt;/p&gt;

&lt;p&gt;Here’s the one that saved my CEO’s dashboard:&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="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;daily_sales_summary&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; 
  &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&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;as&lt;/span&gt; &lt;span class="n"&gt;units_sold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount_cents&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;revenue_cents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;user_id&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;unique_buyers&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'completed'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in Rails:&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;DailySalesSummary&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="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;primary_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sx"&gt;%i[day product_id]&lt;/span&gt;
  &lt;span class="n"&gt;belongs_to&lt;/span&gt; &lt;span class="ss"&gt;:product&lt;/span&gt;

  &lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="ss"&gt;:recent&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="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;day: &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;days&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ago&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="no"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;today&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;# Dashboard query becomes:&lt;/span&gt;
&lt;span class="n"&gt;revenue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;DailySalesSummary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:revenue_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From 12 seconds to 42 milliseconds. The CEO’s cursor stopped spinning. I felt like a wizard.&lt;/p&gt;

&lt;p&gt;But materialized views come with a curse: &lt;strong&gt;staleness&lt;/strong&gt;. The data is only as fresh as your last &lt;code&gt;REFRESH&lt;/code&gt;. We started with a cron job running every hour. Fine for a dashboard. Not fine for a real‑time leaderboard.&lt;/p&gt;

&lt;p&gt;That’s when I learned about &lt;em&gt;incremental refresh&lt;/em&gt; (PostgreSQL 14+ with &lt;code&gt;REFRESH MATERIALIZED VIEW CONCURRENTLY&lt;/code&gt;) and the art of the summary table.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Art of the Summary Table: Listen, Then Update
&lt;/h2&gt;

&lt;p&gt;A summary table (a.k.a. aggregation table) is a regular PostgreSQL table you maintain with triggers or ActiveRecord callbacks. It’s a materialized view you update &lt;em&gt;incrementally&lt;/em&gt;—only the rows that change.&lt;/p&gt;

&lt;p&gt;We built one for a gamification feature: user points from dozens of actions (comments, likes, shares). The raw &lt;code&gt;user_actions&lt;/code&gt; table grew by 50k rows/day. Real‑time leaderboard queries were killing us.&lt;/p&gt;

&lt;p&gt;Here’s the pattern that lived through Black Friday:&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;# db/migrate/create_user_points_summaries.rb&lt;/span&gt;
&lt;span class="n"&gt;create_table&lt;/span&gt; &lt;span class="ss"&gt;:user_points_summaries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;id: &lt;/span&gt;&lt;span class="kp"&gt;false&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;t&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;integer&lt;/span&gt; &lt;span class="ss"&gt;:user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;integer&lt;/span&gt; &lt;span class="ss"&gt;:total_points&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;default: &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;integer&lt;/span&gt; &lt;span class="ss"&gt;:daily_points&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;default: &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;integer&lt;/span&gt; &lt;span class="ss"&gt;:weekly_points&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;default: &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt; &lt;span class="ss"&gt;:last_calculated_at&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timestamps&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="n"&gt;add_index&lt;/span&gt; &lt;span class="ss"&gt;:user_points_summaries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;unique: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, every action that creates a &lt;code&gt;UserAction&lt;/code&gt; record triggers an &lt;em&gt;incremental&lt;/em&gt; update:&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;UserAction&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;after_create_commit&lt;/span&gt; &lt;span class="ss"&gt;:increment_summary&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;increment_summary&lt;/span&gt;
    &lt;span class="no"&gt;UserPointsSummary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;UserPointsSummary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_or_initialize_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;user_id: &lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;total_points&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;point_value&lt;/span&gt;
      &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;daily_points&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;point_value&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;today?&lt;/span&gt;
      &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;weekly_points&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;point_value&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;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;week&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ago&lt;/span&gt;
      &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;last_calculated_at&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;current&lt;/span&gt;
      &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save!&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;Notice the &lt;code&gt;lock&lt;/code&gt;? Yes. Two concurrent actions on the same user will deadlock if you’re not careful. We used &lt;code&gt;SELECT FOR UPDATE&lt;/code&gt; to serialize updates per user. It’s fine because a single user’s actions are rare—but for global aggregates, you’d need a different pattern (like a queued job).&lt;/p&gt;

&lt;p&gt;The beauty of summary tables? They’re &lt;em&gt;always fresh&lt;/em&gt;. Every write triggers an incremental update. Reads are O(1). The database becomes a materialized stream.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Challenge: Keeping It Atomic
&lt;/h2&gt;

&lt;p&gt;Here’s where art meets science. When you maintain summary tables, you open the door to &lt;strong&gt;inconsistency&lt;/strong&gt;. What if the &lt;code&gt;after_create_commit&lt;/code&gt; fails? What if the summary update succeeds but the original action rolls back?&lt;/p&gt;

&lt;p&gt;You need idempotency and atomicity. Our pattern:&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;UserAction&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;after_create_commit&lt;/span&gt; &lt;span class="ss"&gt;:schedule_summary_refresh&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;schedule_summary_refresh&lt;/span&gt;
    &lt;span class="c1"&gt;# Non‑critical: use a background job with idempotency key&lt;/span&gt;
    &lt;span class="no"&gt;RefreshUserPointsJob&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="nb"&gt;self&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;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RefreshUserPointsJob&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="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="n"&gt;action_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Recalculate from scratch for this user using the raw table&lt;/span&gt;
    &lt;span class="c1"&gt;# Idempotent and safe, even if called multiple times&lt;/span&gt;
    &lt;span class="n"&gt;totals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;UserAction&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="ss"&gt;user_id: &lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                       &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"date(created_at)"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                       &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:point_value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="no"&gt;UserPointsSummary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;user_id: &lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;total_points: &lt;/span&gt;&lt;span class="n"&gt;totals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&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="ss"&gt;unique_by: :user_id&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;This trades real‑time for eventual consistency. For a leaderboard, that’s fine. For the CEO dashboard, we stuck with hourly materialized views.&lt;/p&gt;

&lt;p&gt;The art is knowing which battles to fight.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Magic: Combining Both Worlds
&lt;/h2&gt;

&lt;p&gt;After two years of refinement, I now have a three‑tier caching strategy inside the database:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Technique&lt;/th&gt;
&lt;th&gt;Freshness&lt;/th&gt;
&lt;th&gt;Use case&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;L1&lt;/td&gt;
&lt;td&gt;In‑memory (Rails cache)&lt;/td&gt;
&lt;td&gt;Seconds&lt;/td&gt;
&lt;td&gt;User‑specific, hot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;L2&lt;/td&gt;
&lt;td&gt;Summary table (trigger‑updated)&lt;/td&gt;
&lt;td&gt;Millisecond‑level&lt;/td&gt;
&lt;td&gt;Real‑time counters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;L3&lt;/td&gt;
&lt;td&gt;Materialized view (scheduled refresh)&lt;/td&gt;
&lt;td&gt;Hour/Day&lt;/td&gt;
&lt;td&gt;Analytics dashboards&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The dashboard that started this journey? It now uses a materialized view for daily aggregates, a summary table for “today so far,” and a small bit of JavaScript to poll the real‑time summary every 30 seconds.&lt;/p&gt;

&lt;p&gt;Twelve seconds became 80ms. The CEO doesn’t even look at the spinner anymore. He just trusts the numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Human Lesson: Caching Is a Taxonomy of Time
&lt;/h2&gt;

&lt;p&gt;You can’t cache everything. What you can do is classify your data by how &lt;em&gt;fresh&lt;/em&gt; it needs to be. Real‑time counters? Summary table. Yesterday’s numbers? Materialized view. Last year’s reports? Just a regular table with good indexes.&lt;/p&gt;

&lt;p&gt;I’ve stopped reaching for Redis as the default. Sometimes the best cache is the one already inside your database—the one that understands transactions, consistency, and the shape of your data.&lt;/p&gt;

&lt;p&gt;Materialized views and summary tables feel archaic. They aren’t shiny. But they’re &lt;em&gt;reliable&lt;/em&gt;. And for a senior Rails engineer who’s seen too many caching layers collapse under their own complexity, that reliability is the ultimate art.&lt;/p&gt;

&lt;p&gt;Now go precompute something beautiful. And when a junior asks, “why not just add an index?”, tell them about the CEO and the spinning cursor. Some lessons need to be lived.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
      <category>rails</category>
    </item>
    <item>
      <title>Designing for Soft Deletes: Patterns, Performance, and Queries</title>
      <dc:creator>Alex Aslam</dc:creator>
      <pubDate>Sun, 03 May 2026 21:33:55 +0000</pubDate>
      <link>https://dev.to/alex_aslam/designing-for-soft-deletes-patterns-performance-and-queries-20li</link>
      <guid>https://dev.to/alex_aslam/designing-for-soft-deletes-patterns-performance-and-queries-20li</guid>
      <description>&lt;p&gt;I still remember the exact moment I realized soft deletes weren't just a feature—they were a confession.&lt;/p&gt;

&lt;p&gt;We were seven months into a CRM project. The product manager came to my desk with a panicked look. &lt;em&gt;"A user accidentally deleted 3,000 contact records. Can we get them back?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My stomach turned. I had designed the database with hard deletes—clean, efficient, and unforgiving. &lt;code&gt;DELETE FROM contacts WHERE id = ?&lt;/code&gt; and it was gone. Forever. The backup restoration would take hours, and we'd lose a day's worth of new data.&lt;/p&gt;

&lt;p&gt;That night, as I wrote a painful data recovery script, I promised myself: &lt;em&gt;never again.&lt;/em&gt; I joined the church of soft deletes. I added an &lt;code&gt;is_deleted&lt;/code&gt; column to every table, updated all the queries, and felt a wave of relief.&lt;/p&gt;

&lt;p&gt;Then the performance problems started.&lt;/p&gt;

&lt;p&gt;Six months later, every query had &lt;code&gt;WHERE deleted_at IS NULL&lt;/code&gt;, indexes were bloated, and a junior dev accidentally included soft‑deleted records in a report, causing chaos. I realized that soft deletes are not a silver bullet—they are a design pattern that requires artistry, discipline, and a deep understanding of trade‑offs.&lt;/p&gt;

&lt;p&gt;This is the story of how I learned to wield soft deletes like a craftsman, not a cowboy.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Two Paths: Hard vs. Soft
&lt;/h3&gt;

&lt;p&gt;Let's be honest—hard deletes are seductive. They keep your tables lean, your indexes fast, and your foreign keys clean. You never have to filter out "dead" rows. Joins are simple. Performance is predictable.&lt;/p&gt;

&lt;p&gt;But hard deletes make a dangerous assumption: &lt;em&gt;that data is only valuable when it's current.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every senior developer knows this is false. Data has a half‑life. A customer who deleted their account last year might come back. A moderator who deleted a comment might need an audit trail. An accidental bulk delete can ruin your business.&lt;/p&gt;

&lt;p&gt;Soft deletes acknowledge that deletion is often just a change of state. You're not destroying data—you're hiding it from the default view. But hiding is easy. Making that hiding performant, queryable, and maintainable? That's the art.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Naïve Pattern and Its Pitfalls
&lt;/h3&gt;

&lt;p&gt;The simplest soft delete pattern is a nullable timestamp column:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;contacts&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;deleted_at&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_contacts_deleted_at&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deleted_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every 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;contacts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;deleted_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;...;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works for small tables. But as your data grows, you'll hit three walls:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Index Bloat&lt;/strong&gt; – An index on &lt;code&gt;deleted_at&lt;/code&gt; is mostly empty if few records are deleted. But the index still has to scan all those &lt;code&gt;NULL&lt;/code&gt; entries. On PostgreSQL, you can use a partial index: &lt;code&gt;CREATE INDEX ... WHERE deleted_at IS NULL&lt;/code&gt;. That's a game changer. It indexes only the active rows, keeping the index small and fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Forgotten Filters&lt;/strong&gt; – Someone will forget the &lt;code&gt;deleted_at IS NULL&lt;/code&gt; clause. A junior dev, a reporting tool, a quick debugging query. Suddenly, soft‑deleted data leaks into production. I've seen this cause regulatory nightmares (showing deleted customer data in exports) and embarrassing UI bugs (showing "deleted" comments).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Foreign Key Confusion&lt;/strong&gt; – If you have a &lt;code&gt;orders&lt;/code&gt; table with a foreign key to &lt;code&gt;customers&lt;/code&gt;, and you soft‑delete a customer, what should happen to their orders? If the foreign key uses &lt;code&gt;ON DELETE CASCADE&lt;/code&gt;, hard deletes remove the orders. But with soft deletes, you need to decide: hide the orders too, or show them as orphaned? There's no database constraint to help you. You have to enforce this in application logic.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Art of the Partial Index
&lt;/h3&gt;

&lt;p&gt;Let me show you the single most important optimization for soft deletes: partial indexes.&lt;/p&gt;

&lt;p&gt;Most databases (PostgreSQL, SQLite, newer MySQL) support indexes with a &lt;code&gt;WHERE&lt;/code&gt; clause. For soft deletes, you want:&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_active_contacts&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;contacts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;first_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;deleted_at&lt;/span&gt; &lt;span class="k"&gt;IS&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;Now, queries for active contacts use a tiny index that excludes every deleted row. Inserts and updates to active rows are still fast. Even if you have millions of deleted records, the index never grows.&lt;/p&gt;

&lt;p&gt;I learned this lesson the hard way. We had a &lt;code&gt;transactions&lt;/code&gt; table with 50 million rows, 40 million of which were soft‑deleted (archived). A simple &lt;code&gt;SELECT SUM(amount) FROM transactions WHERE deleted_at IS NULL&lt;/code&gt; was taking 30 seconds because the index was scanning through all 50 million entries. After adding a partial index, the same query took 200 milliseconds.&lt;/p&gt;

&lt;p&gt;Partial indexes are art because they require you to think about &lt;em&gt;what "active" means&lt;/em&gt; in your domain. Sometimes &lt;code&gt;deleted_at IS NULL&lt;/code&gt; is enough. Sometimes you also need &lt;code&gt;status != 'archived'&lt;/code&gt;. The index becomes a living statement of your data's lifecycle.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Query Pattern: Views and Row‑Level Security
&lt;/h3&gt;

&lt;p&gt;To solve the "forgotten filter" problem, we created a set of views that automatically exclude soft‑deleted records.&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;VIEW&lt;/span&gt; &lt;span class="n"&gt;active_contacts&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&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;contacts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;deleted_at&lt;/span&gt; &lt;span class="k"&gt;IS&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;Then we trained the team to query the view, not the table. The view acts as a contract: &lt;em&gt;this is what you see when you want live data.&lt;/em&gt; For admin reports that need to include deleted records, we forced explicit &lt;code&gt;SELECT * FROM contacts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In PostgreSQL, we went a step further and used row‑level security (RLS) to enforce the filter at the database level. Every query to the &lt;code&gt;contacts&lt;/code&gt; table automatically had &lt;code&gt;WHERE deleted_at IS NULL&lt;/code&gt; injected, unless the user had a specific role. This made it impossible to accidentally leak soft‑deleted data.&lt;/p&gt;

&lt;p&gt;But RLS has a performance cost. Test it thoroughly.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Two‑Column Strategy: Deleted At vs. Deleted By
&lt;/h3&gt;

&lt;p&gt;A simple &lt;code&gt;deleted_at&lt;/code&gt; timestamp is enough for most cases. But I've found that adding a &lt;code&gt;deleted_by&lt;/code&gt; column (user ID) transforms soft deletes from a technical pattern into an audit trail.&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;contacts&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;deleted_at&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;contacts&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;deleted_by_id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&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 you restore a record, you know who deleted it and when. This has saved my team countless times during customer support inquiries: &lt;em&gt;"Who marked this lead as deleted last week?"&lt;/em&gt; Having the answer in the same row (instead of a separate audit log) makes queries trivial.&lt;/p&gt;

&lt;p&gt;We also added a &lt;code&gt;deleted_reason&lt;/code&gt; text column for cases where users had to explain why. This turned out to be invaluable for spotting patterns—like a particular salesperson marking leads as deleted instead of following up.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Performance of Restoration
&lt;/h3&gt;

&lt;p&gt;Restoring a soft‑deleted record is just an update:&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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;contacts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;deleted_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But what if you need to restore &lt;em&gt;all&lt;/em&gt; records that were deleted in a date range? That's a bulk update. And if your table has a partial index on &lt;code&gt;deleted_at IS NULL&lt;/code&gt;, that update will be fast because the database only needs to touch the active index entries for the restored rows.&lt;/p&gt;

&lt;p&gt;However, there's a hidden cost: if your soft‑deleted records have foreign key relationships to other soft‑deleted records (e.g., a customer and their orders), restoring the customer doesn't automatically restore the orders. You have to decide whether to cascade the restoration or leave the orders deleted. We built a stored procedure that restores a customer and all their associated records in a transaction. This is the kind of complexity that senior developers must anticipate.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Art of Hard Deletion (Yes, Sometimes)
&lt;/h3&gt;

&lt;p&gt;Despite everything I've said, there are times when you need true, hard deletion.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GDPR right to erasure&lt;/strong&gt; – If a user requests that all their personal data be permanently removed, a soft delete doesn't satisfy the law. You need to hard delete.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage costs&lt;/strong&gt; – If you're storing large binary data (images, videos) and the deleted records are accumulating petabytes, soft deletes become a financial liability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regulatory retention periods&lt;/strong&gt; – You might be required to keep data for 7 years, then purge it. Soft deletes can mark it as "eligible for hard deletion," then a scheduled job physically removes it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We built a two‑phase system:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Soft delete&lt;/strong&gt; – &lt;code&gt;deleted_at&lt;/code&gt; set, data hidden from normal queries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard delete&lt;/strong&gt; – After 90 days (or retention period), a background job permanently removes soft‑deleted records and logs the action to an audit table.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This gives users a grace period to restore mistakes while still complying with data retention laws. The background job uses batched deletes (&lt;code&gt;DELETE FROM contacts WHERE deleted_at &amp;lt; NOW() - INTERVAL '90 days' LIMIT 1000&lt;/code&gt;) to avoid locking the table.&lt;/p&gt;

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

&lt;p&gt;That CRM I mentioned at the beginning? After the hard‑delete disaster, we added soft deletes. After the performance problems, we added partial indexes. After the forgotten filters, we added views and RLS. After the GDPR request, we added the two‑phase hard delete.&lt;/p&gt;

&lt;p&gt;Today, that system handles 50 million soft‑deleted records without breaking a sweat. Queries are fast. Audits are transparent. Support can restore accidental deletions with a single click. And I sleep better at night.&lt;/p&gt;

&lt;p&gt;Soft deletes are not a lazy shortcut. They are a deliberate design choice that says: &lt;em&gt;data has memory, and our system respects that.&lt;/em&gt; But like any powerful tool, they require craftsmanship.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Senior Developer's Checklist
&lt;/h3&gt;

&lt;p&gt;Before you sprinkle &lt;code&gt;deleted_at&lt;/code&gt; across your schema, ask yourself:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Do I actually need to recover this data?&lt;/strong&gt; For ephemeral logs or caches, hard deletes are fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How will I enforce the filter across all queries?&lt;/strong&gt; Consider views, RLS, or a dedicated query builder layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What indexes will keep active queries fast?&lt;/strong&gt; Partial indexes are your best friend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How will foreign key relationships behave?&lt;/strong&gt; Decide on cascade behavior for both deletion and restoration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What is the hard‑deletion policy?&lt;/strong&gt; Soft deletes are not forever. Plan for eventual purging.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And most importantly: &lt;strong&gt;document your soft‑delete strategy.&lt;/strong&gt; Leave a comment in your migration file. Explain why you chose a timestamp over a boolean (timestamps give you ordering). Clarify which tables use soft deletes and which don't. Your future self—and your team—will thank you.&lt;/p&gt;

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

&lt;p&gt;The best soft‑delete implementation is the one users never notice. They don't see &lt;code&gt;WHERE deleted_at IS NULL&lt;/code&gt; in the logs. They don't wonder why a "deleted" record still appears in an export. They just know that when they accidentally click the wrong button, someone can fix it.&lt;/p&gt;

&lt;p&gt;That's the art: creating resilience behind the scenes, turning a potential catastrophe into an oops‑I‑can‑fix‑that moment. It's not glamorous. But it's the kind of quiet craftsmanship that separates senior engineers from the rest.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
      <category>database</category>
    </item>
    <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://dev.to/alex_aslam/deadlock-hunting-postgresql-advisory-locks-in-distributed-systems-2hpd</link>
      <guid>https://dev.to/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://dev.to/alex_aslam/biometric-authentication-in-turbo-native-the-art-of-the-invisible-handshake-4kmi</link>
      <guid>https://dev.to/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://dev.to/alex_aslam/mobile-performance-monitoring-with-sentry-and-turbo-the-art-of-seeing-the-invisible-4pje</link>
      <guid>https://dev.to/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://dev.to/alex_aslam/push-notification-delivery-guarantees-with-rails-a-spiral-through-the-gray-hours-526d</link>
      <guid>https://dev.to/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://dev.to/alex_aslam/the-art-of-background-sync-in-turbo-native-apps-a-journey-through-offline-first-masterpieces-20ge</link>
      <guid>https://dev.to/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://dev.to/alex_aslam/the-cartographers-confession-how-postgis-turned-me-from-a-sql-hack-into-a-spatial-artist-2jo</link>
      <guid>https://dev.to/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://dev.to/alex_aslam/javascript-memory-leaks-how-to-find-fix-and-prevent-them-2e3a</link>
      <guid>https://dev.to/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://dev.to/alex_aslam/the-event-loop-microtasks-and-macrotasks-a-visual-explanation-17do</link>
      <guid>https://dev.to/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://dev.to/alex_aslam/javascript-engine-under-the-hood-how-v8-compiles-your-code-27ie</link>
      <guid>https://dev.to/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://dev.to/alex_aslam/geofencing-in-turbo-native-with-core-location-4pn1</link>
      <guid>https://dev.to/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>
  </channel>
</rss>
