<?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: Anand Rathnas</title>
    <description>The latest articles on DEV Community by Anand Rathnas (@anand_rathnas_d5b608cc3de).</description>
    <link>https://dev.to/anand_rathnas_d5b608cc3de</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3671625%2F8642714b-af2d-4fc1-9097-c08fc07fdab5.png</url>
      <title>DEV Community: Anand Rathnas</title>
      <link>https://dev.to/anand_rathnas_d5b608cc3de</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/anand_rathnas_d5b608cc3de"/>
    <language>en</language>
    <item>
      <title>Building an Affiliate Marketplace from Scratch: 14 Tables, 27 Services, Zero Money Movement</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Fri, 03 Jul 2026 04:47:29 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/building-an-affiliate-marketplace-from-scratch-14-tables-27-services-zero-money-movement-4amn</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/building-an-affiliate-marketplace-from-scratch-14-tables-27-services-zero-money-movement-4amn</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/affiliate-marketplace-architecture/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Everyone told me the same thing: "If you're building an affiliate marketplace, you need a payments team."&lt;/p&gt;

&lt;p&gt;No, you don't. You need a very clear opinion about what your platform should and shouldn't do. For jo4, that opinion is: &lt;strong&gt;we never hold, move, or touch money.&lt;/strong&gt; 14 database tables, 27 services, 146 source files, 81 test files — and not a single money-movement API call.&lt;/p&gt;

&lt;p&gt;Here's how.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;jo4's affiliate marketplace connects brands (advertisers) with publishers (affiliates). Brands create campaigns, publishers apply with bids, and when a conversion happens, we record it and calculate commission. That's it. We never process payments. Settlement is a ledger entry that says "brand owes publisher X" — the actual payment happens externally.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Money Flow (That Doesn't Flow Through Us)
&lt;/h2&gt;

&lt;p&gt;This is the part that confuses people, so let me draw it out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Brand connects their Stripe account via Stripe Connect OAuth&lt;/li&gt;
&lt;li&gt;Customer buys something on the brand's site → Stripe checkout fires a webhook&lt;/li&gt;
&lt;li&gt;jo4 receives the webhook → verifies it came from the brand's connected account&lt;/li&gt;
&lt;li&gt;jo4 records the conversion and calculates commission (CPA or RevShare)&lt;/li&gt;
&lt;li&gt;Monthly settlement scheduler sums what each brand owes each publisher&lt;/li&gt;
&lt;li&gt;Brand pays publisher externally (wire, PayPal, whatever they agreed on)&lt;/li&gt;
&lt;li&gt;Brand marks the settlement as paid in jo4&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 1-4 are automated. Steps 5-7 are the "boring" part that keeps us out of money transmission regulations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stripe Connect OAuth is used ONLY for webhook verification.&lt;/strong&gt; We're confirming that checkout events actually came from the brand's connected Stripe account. We never initiate charges, transfers, or payouts through it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Commission Engine
&lt;/h2&gt;

&lt;p&gt;Two models, both dead simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="nc"&gt;CommissionType&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="no"&gt;CPA&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// Fixed amount per conversion ($10 per sale)&lt;/span&gt;
    &lt;span class="no"&gt;REV_SHARE&lt;/span&gt;   &lt;span class="c1"&gt;// Percentage of sale (15% of order total)&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The calculation happens in &lt;code&gt;CommissionCalculatorService&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="nf"&gt;calculateCommission&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CampaignEntity&lt;/span&gt; &lt;span class="n"&gt;campaign&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;saleAmount&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;switch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;campaign&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCommissionType&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;CPA&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;campaign&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCommissionAmount&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="no"&gt;REV_SHARE&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;saleAmount&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;multiply&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;campaign&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCommissionRate&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;divide&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;valueOf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&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="nc"&gt;RoundingMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HALF_UP&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;};&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No tiered rates, no graduated scales, no time-based multipliers. Those are features you add when you have paying customers asking for them, not when you're building V1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fraud Scoring
&lt;/h2&gt;

&lt;p&gt;Every conversion gets a fraud score before it's counted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Velocity check&lt;/strong&gt;: How many conversions from this publisher in the last hour? Spike beyond 3x the rolling average = flag&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Click-to-conversion time&lt;/strong&gt;: If someone clicks an affiliate link and converts in under 2 seconds, that's suspicious. Humans don't buy that fast&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IP clustering&lt;/strong&gt;: Multiple conversions from the same IP range within a window&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Flagged conversions aren't rejected — they're held for review. The brand decides. This is important: &lt;strong&gt;we don't make fraud decisions, we surface signals.&lt;/strong&gt; The brand owns the risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Settlement: Monthly with Advisory Locks
&lt;/h2&gt;

&lt;p&gt;The settlement scheduler runs on the 1st of every month at 2 AM UTC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Scheduled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cron&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0 0 2 1 * *"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;runMonthlySettlement&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;tryAcquireAdvisoryLock&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;SETTLEMENT_LOCK_ID&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Another instance is running settlement, skipping"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;processSettlements&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;releaseAdvisoryLock&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;SETTLEMENT_LOCK_ID&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgreSQL advisory locks handle distributed coordination. If you're running multiple instances (we are), only one will process settlements. The others gracefully skip.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;processSettlements()&lt;/code&gt; method groups all approved conversions by brand-publisher pair, sums commissions, and creates a settlement record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Data&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SettlementEntity&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;brandProfileId&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;publisherProfileId&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;totalAmount&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;SettlementStatus&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// PENDING, PAID, DISPUTED&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;YearMonth&lt;/span&gt; &lt;span class="n"&gt;settlementPeriod&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Status starts as &lt;code&gt;PENDING&lt;/code&gt;. The brand reviews it, pays the publisher however they agreed to, then marks it &lt;code&gt;PAID&lt;/code&gt; in jo4. If there's a disagreement, it goes to &lt;code&gt;DISPUTED&lt;/code&gt; and they work it out offline.&lt;/p&gt;

&lt;h2&gt;
  
  
  No Clawback Logic
&lt;/h2&gt;

&lt;p&gt;This was a deliberate architectural decision. Many affiliate platforms implement clawback windows — if a customer refunds within 30 days, the commission is reversed.&lt;/p&gt;

&lt;p&gt;We don't do this.&lt;/p&gt;

&lt;p&gt;The brand sets the campaign terms. If they want a clawback window, they handle it in their settlement review before marking it paid. The risk is entirely on the campaign owner, which matches the industry standard for self-serve affiliate platforms.&lt;/p&gt;

&lt;p&gt;Adding clawback logic would mean: tracking refund webhooks, implementing reversal entries, handling partial refunds, dealing with already-paid settlements, and building a dispute resolution flow. That's 4-6 weeks of work for a feature that solves a problem the brand can solve by waiting 30 days before paying.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Database: 14 Tables
&lt;/h2&gt;

&lt;p&gt;Here's the schema at a glance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;users&lt;/strong&gt; / &lt;strong&gt;profiles&lt;/strong&gt; — auth and account data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;campaigns&lt;/strong&gt; — brand's offer (commission type, rate, terms, FTC disclosure)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;bids&lt;/strong&gt; — publisher's application to a campaign&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;bid_templates&lt;/strong&gt; — reusable pitch + commission presets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;clicks&lt;/strong&gt; — every affiliate link click, timestamped and IP-logged&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;conversions&lt;/strong&gt; — confirmed sales with fraud scores&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;settlements&lt;/strong&gt; — monthly brand-to-publisher ledger&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;stripe_connections&lt;/strong&gt; — OAuth tokens for webhook verification&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;notifications&lt;/strong&gt; — in-app alerts for bid status changes, new conversions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;webhooks&lt;/strong&gt; / &lt;strong&gt;webhook_events&lt;/strong&gt; — outbound event delivery to brand's systems&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;14 tables. Each one maps to exactly one domain concept. No multi-purpose "events" table. No EAV pattern. No JSON columns for "flexibility."&lt;/p&gt;

&lt;h2&gt;
  
  
  27 Services, 146 Source Files
&lt;/h2&gt;

&lt;p&gt;The service layer follows a strict pattern: one service per domain operation, not one service per entity.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;CampaignActivationService&lt;/code&gt; — handles FTC gates, status transitions&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;BidStateMachineService&lt;/code&gt; — manages bid lifecycle (covered in the next post)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ConversionRecordingService&lt;/code&gt; — webhook → fraud check → commission calculation&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SettlementSchedulerService&lt;/code&gt; — monthly aggregation + advisory locks&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;StripeConnectService&lt;/code&gt; — OAuth flow + webhook signature verification&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each service gets its own test file. 81 test files total. That's a test-to-source ratio of 0.55, which is about right for a platform where bugs mean incorrect money calculations.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Start with fewer tables.&lt;/strong&gt; I could have shipped the MVP with 8 tables. Bid templates, fraud scoring, and the full notification system could have waited.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skip Stripe Connect for V1.&lt;/strong&gt; We could have started with manual webhook URL configuration instead of OAuth. The OAuth flow is slick but it delayed the launch by a week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use event sourcing for conversions.&lt;/strong&gt; Right now, conversion status is a mutable column. If I were starting over, I'd make every status change an immutable event. Debugging "why was this conversion rejected?" would be trivial.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;14 database tables&lt;/li&gt;
&lt;li&gt;27 services&lt;/li&gt;
&lt;li&gt;146 source files&lt;/li&gt;
&lt;li&gt;81 test files&lt;/li&gt;
&lt;li&gt;0 money-movement API calls&lt;/li&gt;
&lt;li&gt;0 money transmission license requirements&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last line is the whole point. By refusing to move money, we removed an entire category of regulatory, security, and operational complexity. The tradeoff is that settlement is manual — but that's a feature, not a bug, when you're a two-person team.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Building an affiliate platform or something similar?&lt;/strong&gt; What's your approach to the "should we move money" question? I'd love to hear how others have drawn that line.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — affiliate marketplace infrastructure where the platform never touches a dollar.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
    <item>
      <title>From 30-Second Polling to Real Push Notifications</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Wed, 01 Jul 2026 01:52:57 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/from-30-second-polling-to-real-push-notifications-2jdf</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/from-30-second-polling-to-real-push-notifications-2jdf</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/polling-to-push-notifications-mobile/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Our notification bell was lying to users.&lt;/p&gt;

&lt;p&gt;Not maliciously. It just... lagged. A publisher would submit a bid on a brand's campaign, and the brand wouldn't know for up to 30 seconds. In mobile app terms, 30 seconds is an eternity. Users were refreshing manually. Some thought notifications were broken entirely.&lt;/p&gt;

&lt;p&gt;They weren't broken. They were polling. And polling on mobile is a sin we needed to repent for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup: How We Got Here
&lt;/h2&gt;

&lt;p&gt;Our React Native app (Expo, managed workflow) had a &lt;code&gt;NotificationBell&lt;/code&gt; component. Simple enough. It used RTK Query's &lt;code&gt;pollingInterval&lt;/code&gt; to hit &lt;code&gt;GET /api/v1/protected/notifications/unread-count&lt;/code&gt; every 30 seconds.&lt;/p&gt;

&lt;p&gt;It worked on web. It worked on mobile. It "worked."&lt;/p&gt;

&lt;p&gt;But here's what "worked" actually meant on a phone sitting in someone's pocket:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2,880 HTTP requests per day&lt;/strong&gt; per active user (one every 30 seconds)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Battery drain&lt;/strong&gt; from keeping the radio alive for each poll cycle&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero delivery when the app was closed&lt;/strong&gt; — if you killed the app, you got nothing until you opened it again&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wasted bandwidth&lt;/strong&gt; — 99.9% of those responses came back with the same count&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We were running a distributed denial-of-service attack against our own API. From our own app. On behalf of our own users.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Obvious" Solution: Expo Push Service
&lt;/h2&gt;

&lt;p&gt;We're an Expo shop. EAS project ID configured, &lt;code&gt;expo prebuild&lt;/code&gt; for native builds. The natural path was Expo Push Service — a single HTTP POST to &lt;code&gt;exp.host/--/api/v2/push/send&lt;/code&gt; that fans out to both APNs and FCM. No Firebase SDK, no APNs HTTP/2 client, free up to 600 notifications per second.&lt;/p&gt;

&lt;p&gt;We planned it. We spec'd it. We wrote the migration SQL.&lt;/p&gt;

&lt;p&gt;Then we paused and asked ourselves: &lt;em&gt;Do we really want a third-party proxy between us and Apple/Google for something this critical?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pivot: Direct FCM + APNs
&lt;/h2&gt;

&lt;p&gt;We chose the harder path. Direct integration with both push services. No middleware, no proxy, no Expo dependency at runtime.&lt;/p&gt;

&lt;p&gt;Here's why:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Zero runtime dependency.&lt;/strong&gt; Expo Push Service is free and reliable, but it's still someone else's server. If &lt;code&gt;exp.host&lt;/code&gt; goes down at 2 AM, our users don't get notified about a time-sensitive bid. With direct integration, the only failure points are Apple, Google, and us.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Full payload control.&lt;/strong&gt; FCM v1 API and APNs have different payload structures, priority levels, and collapse keys. Going direct means we can tune each platform independently — badge counts on iOS, notification channels on Android, silent pushes for cache invalidation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. No token translation.&lt;/strong&gt; Expo Push Tokens (&lt;code&gt;ExponentPushToken[xxx]&lt;/code&gt;) are Expo's abstraction. Native device tokens are what FCM and APNs actually consume. By using &lt;code&gt;getDevicePushTokenAsync()&lt;/code&gt; on the client instead of &lt;code&gt;getExpoPushTokenAsync()&lt;/code&gt;, we skip the translation layer entirely.&lt;/p&gt;

&lt;p&gt;The tradeoff? We had to implement JWT authentication for &lt;em&gt;two&lt;/em&gt; different providers, each with their own signing algorithm, token format, and error semantics.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Backend: Two JWT Dialects
&lt;/h2&gt;

&lt;h3&gt;
  
  
  FCM (Android): RSA-256 OAuth Dance
&lt;/h3&gt;

&lt;p&gt;FCM v1 doesn't use a simple API key anymore. It requires a proper OAuth2 service account flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Load the Firebase service account's RSA private key (from an environment variable, never from the JSON file on disk)&lt;/li&gt;
&lt;li&gt;Build a JWT with &lt;code&gt;RS256&lt;/code&gt;, scoped to &lt;code&gt;firebase.messaging&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;POST that JWT to Google's token endpoint&lt;/li&gt;
&lt;li&gt;Get back an access token (valid ~1 hour)&lt;/li&gt;
&lt;li&gt;Use that access token as a Bearer header on every FCM send&lt;/li&gt;
&lt;li&gt;Cache it, refresh 5 minutes early&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The key separation was deliberate. The &lt;code&gt;jo4-prod-firebase-adminsdk-*.json&lt;/code&gt; file lives on the classpath for the &lt;code&gt;client_email&lt;/code&gt; field. The actual private key comes from &lt;code&gt;PUSH_FCM_SERVICE_ACCOUNT_KEY&lt;/code&gt; as a base64-encoded PEM at runtime. This means the JSON file in source control has no secrets.&lt;/p&gt;

&lt;h3&gt;
  
  
  APNs (iOS): EC-256 Provider Token
&lt;/h3&gt;

&lt;p&gt;Apple's approach is simpler in some ways, weirder in others:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Load the &lt;code&gt;.p8&lt;/code&gt; key (EC private key, also from an env var)&lt;/li&gt;
&lt;li&gt;Build a JWT with &lt;code&gt;ES256&lt;/code&gt;, issuer = team ID, key ID in the header&lt;/li&gt;
&lt;li&gt;That JWT &lt;em&gt;is&lt;/em&gt; the auth — no token exchange, just attach it as a bearer header&lt;/li&gt;
&lt;li&gt;Valid for 60 minutes, we refresh at 50&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The HTTP/2 requirement is the curveball. APNs &lt;em&gt;requires&lt;/em&gt; HTTP/2 — it will reject HTTP/1.1 connections. Java's &lt;code&gt;HttpClient&lt;/code&gt; handles this natively (we set &lt;code&gt;HttpClient.Version.HTTP_2&lt;/code&gt; at construction), but it's the kind of thing that silently fails if you're using an older HTTP library.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Token Lifecycle Problem
&lt;/h2&gt;

&lt;p&gt;Push tokens have a lifecycle that most tutorials gloss over. A device token can become invalid for half a dozen reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;User uninstalled the app&lt;/li&gt;
&lt;li&gt;User disabled notifications in system settings&lt;/li&gt;
&lt;li&gt;Token was refreshed by the OS (happens periodically on both platforms)&lt;/li&gt;
&lt;li&gt;User logged out and the token should no longer receive their notifications&lt;/li&gt;
&lt;li&gt;User logged into a &lt;em&gt;different&lt;/em&gt; account on the same device&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We handle each case:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Registration (upsert):&lt;/strong&gt; When the app boots and the user is authenticated, it calls &lt;code&gt;POST /push-token&lt;/code&gt;. If that token already exists for a &lt;em&gt;different&lt;/em&gt; user, we reassign it (device changed hands). If it's new, we create it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unregistration (logout):&lt;/strong&gt; Before clearing the session, the app calls &lt;code&gt;DELETE /push-token&lt;/code&gt;. This soft-deletes the token so the logged-out device stops receiving pushes. Critically, this happens &lt;em&gt;before&lt;/em&gt; the auth token is cleared — otherwise the API call would fail with 401.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auto-cleanup (delivery failure):&lt;/strong&gt; When FCM returns &lt;code&gt;UNREGISTERED&lt;/code&gt; (404) or APNs returns &lt;code&gt;410 Gone&lt;/code&gt; or &lt;code&gt;BadDeviceToken&lt;/code&gt;, we soft-delete the token automatically. No stale tokens accumulate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The soft-delete + hard-delete dance:&lt;/strong&gt; Here's a subtlety. We use soft-deletes everywhere (BaseEntity pattern). But we also have a partial unique index: &lt;code&gt;UNIQUE (push_token) WHERE deleted = false&lt;/code&gt;. If a user unregisters and re-registers the same token, the soft-deleted row would violate the uniqueness constraint. So before soft-deleting, we hard-delete any &lt;em&gt;previously&lt;/em&gt; soft-deleted rows with the same token. It's a native SQL query that bypasses our ORM's &lt;code&gt;@SQLRestriction("deleted = false")&lt;/code&gt; filter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;a class="mentioned-user" href="https://dev.to/async"&gt;@async&lt;/a&gt; + @Transactional Trap
&lt;/h2&gt;

&lt;p&gt;This one nearly cost us a day.&lt;/p&gt;

&lt;p&gt;Our push delivery runs inside &lt;code&gt;@Async&lt;/code&gt; methods. When a token needs to be soft-deleted (delivery failure), we need a database transaction. The natural instinct is to extract a &lt;code&gt;@Transactional&lt;/code&gt; private method.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This does not work.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Spring's &lt;code&gt;@Transactional&lt;/code&gt; relies on AOP proxies. When you call a &lt;code&gt;@Transactional&lt;/code&gt; method from &lt;em&gt;within the same bean&lt;/em&gt;, the call goes through &lt;code&gt;this&lt;/code&gt;, not through the proxy. The annotation is silently ignored. Your "transaction" is actually running without one.&lt;/p&gt;

&lt;p&gt;Inside an &lt;code&gt;@Async&lt;/code&gt; method, you're already past the proxy boundary. Internal calls to &lt;code&gt;@Transactional&lt;/code&gt; methods are no-ops.&lt;/p&gt;

&lt;p&gt;The fix: &lt;code&gt;TransactionTemplate&lt;/code&gt;. Programmatic transaction management that works regardless of proxy context.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;softDeleteToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UserPushTokenEntity&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;transactionTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;executeWithoutResult&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;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;userPushTokenRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hardDeleteSoftDeletedByPushToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPushToken&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setDeleted&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setDeleteReason&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;userPushTokenRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;});&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not glamorous. But it actually works. Every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Mobile Side: Less Drama, More Plumbing
&lt;/h2&gt;

&lt;p&gt;The React Native side was comparatively calm. A single &lt;code&gt;usePushNotifications&lt;/code&gt; hook handles everything:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Permission request&lt;/strong&gt; — &lt;code&gt;Notifications.getPermissionsAsync()&lt;/code&gt; then &lt;code&gt;requestPermissionsAsync()&lt;/code&gt; if needed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token retrieval&lt;/strong&gt; — &lt;code&gt;Notifications.getDevicePushTokenAsync()&lt;/code&gt; (native token, not Expo token)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend registration&lt;/strong&gt; — RTK Query mutation, best-effort (silently fails if backend is unreachable)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Foreground handling&lt;/strong&gt; — &lt;code&gt;setNotificationHandler&lt;/code&gt; to show banners even when the app is open&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache invalidation&lt;/strong&gt; — When a push arrives in the foreground, we invalidate RTK Query's &lt;code&gt;UnreadCount&lt;/code&gt; cache tag. The &lt;code&gt;NotificationBell&lt;/code&gt; re-renders with the fresh count. No polling needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tap routing&lt;/strong&gt; — When the user taps a notification, we extract &lt;code&gt;actionUrl&lt;/code&gt; from the payload data and &lt;code&gt;router.push()&lt;/code&gt; to the right screen&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The hook stores the push token in a module-level variable (not React state, not Redux). Why? Because during logout, React state may be mid-teardown and Redux may be mid-reset. A simple module variable survives both.&lt;/p&gt;

&lt;h2&gt;
  
  
  The NotificationBell: From Polling to Push
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&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="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useGetUnreadCountQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;skip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isAuthenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;pollingInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// The sin&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&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="nx"&gt;refetch&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useGetUnreadCountQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;skip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isAuthenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// No polling. Push notifications invalidate the cache.&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Only refetch when user returns to the app (tab switch, unlock)&lt;/span&gt;
&lt;span class="nf"&gt;useEffect&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;sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;AppState&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;change&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;next&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="nx"&gt;appState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;isAuthenticated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;refetch&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;appState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sub&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="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isAuthenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;refetch&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference: from 2,880 requests/day to maybe 20-30 (one per app foreground event). Server load dropped. Battery usage dropped. And notifications arrive &lt;em&gt;instantly&lt;/em&gt; instead of up to 30 seconds late.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Shipped
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Delivery mechanism&lt;/td&gt;
&lt;td&gt;HTTP polling (30s)&lt;/td&gt;
&lt;td&gt;FCM (Android) + APNs (iOS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background delivery&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Full system-level push&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latency&lt;/td&gt;
&lt;td&gt;0-30 seconds&lt;/td&gt;
&lt;td&gt;Sub-second&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Requests per user/day&lt;/td&gt;
&lt;td&gt;~2,880&lt;/td&gt;
&lt;td&gt;~20-30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Third-party dependency&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None (direct to Apple/Google)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token management&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;Auto-cleanup on delivery failure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Foreground behavior&lt;/td&gt;
&lt;td&gt;Badge update on next poll&lt;/td&gt;
&lt;td&gt;Instant banner + badge + sound&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Expo Push Service is good. Direct is better.&lt;/strong&gt; If you're serious about push reliability and payload control, go direct. The implementation cost is a few hundred lines of JWT plumbing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@Async&lt;/code&gt; and &lt;code&gt;@Transactional&lt;/code&gt; don't compose.&lt;/strong&gt; Use &lt;code&gt;TransactionTemplate&lt;/code&gt; for programmatic transactions inside async methods. This isn't a Spring bug — it's how AOP proxies work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Soft-delete + unique constraints need careful choreography.&lt;/strong&gt; Partial unique indexes (&lt;code&gt;WHERE deleted = false&lt;/code&gt;) are powerful but require hard-deleting stale soft-deleted rows before creating new ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store push tokens outside React state for logout.&lt;/strong&gt; Module-level variables are ugly but survive the teardown chaos of a logout flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;getDevicePushTokenAsync&lt;/code&gt; &amp;gt; &lt;code&gt;getExpoPushTokenAsync&lt;/code&gt;&lt;/strong&gt; if you're doing direct FCM/APNs. Skip the Expo token abstraction layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP/2 is mandatory for APNs.&lt;/strong&gt; Ensure your HTTP client is configured for it explicitly. Silent failures here are painful to debug.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Have you made the polling-to-push jump?&lt;/strong&gt; What surprised you the most? Drop a comment below.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a modern URL shortener with analytics, bio pages, and an affiliate marketplace for creators.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>java</category>
      <category>reactnative</category>
      <category>architecture</category>
    </item>
    <item>
      <title>I Made Claude Code Ding When It's Done (And It Changed My Workflow)</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Sun, 28 Jun 2026 01:49:39 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/i-made-claude-code-ding-when-its-done-and-it-changed-my-workflow-5e35</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/i-made-claude-code-ding-when-its-done-and-it-changed-my-workflow-5e35</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/claude-code-stop-hook-sound/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You know that feeling when you ask Claude Code to refactor a module, switch to Twitter for "just a sec," and come back 12 minutes later to find it's been sitting there waiting for your input for the last 11?&lt;/p&gt;

&lt;p&gt;Yeah. That was my Friday evening.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Are You Still There?" Problem
&lt;/h2&gt;

&lt;p&gt;I've been using Claude Code as my daily driver while building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt;. It's brilliant at crunching through multi-file refactors, running tests, and fixing bugs. But here's the thing - when Claude finishes a task or has a question, it just... sits there. Silently. Like a polite intern who finished their work but doesn't want to interrupt your YouTube rabbit hole.&lt;/p&gt;

&lt;p&gt;I needed a way for Claude to tap me on the shoulder. Something that says "Hey, I'm done" or "Hey, I need you" without me obsessively watching the terminal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: 5 Lines of JSON
&lt;/h2&gt;

&lt;p&gt;Claude Code has a hook system. I already wrote about &lt;a href="https://blog.jo4.io/claude-code-hooks/" rel="noopener noreferrer"&gt;PreToolUse hooks&lt;/a&gt; for blocking dangerous git commands. Turns out there's a &lt;code&gt;Stop&lt;/code&gt; hook that fires every time Claude finishes responding and hands control back to you.&lt;/p&gt;

&lt;p&gt;Here's the entire config I added to &lt;code&gt;~/.claude/settings.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Stop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"afplay /System/Library/Sounds/Funk.aiff"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. That's the whole thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Happens Now
&lt;/h2&gt;

&lt;p&gt;Every time Claude Code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Finishes a task and waits for my next instruction&lt;/li&gt;
&lt;li&gt;Runs into something it needs my input on&lt;/li&gt;
&lt;li&gt;Completes a long test suite run&lt;/li&gt;
&lt;li&gt;Asks me a clarifying question&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;...my Mac plays the &lt;strong&gt;Funk&lt;/strong&gt; sound. You know the one - that satisfying little &lt;code&gt;bonk&lt;/code&gt; that macOS has shipped since forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Is a Game Changer
&lt;/h2&gt;

&lt;p&gt;Before this, my workflow looked like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Give Claude a task
2. Switch to browser
3. Forget about Claude
4. Come back 15 minutes later
5. Realize it's been waiting for 14 of those minutes
6. Feel guilty
7. Repeat
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now it's:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Give Claude a task
2. Switch to browser / grab coffee / stretch
3. *bonk*
4. Switch back immediately
5. Continue where we left off
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My feedback loop went from "whenever I remember to check" to &lt;strong&gt;instant&lt;/strong&gt;. I'm not exaggerating when I say this cut my average task turnaround in half - not because Claude got faster, but because &lt;em&gt;I&lt;/em&gt; stopped being the bottleneck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick Your Sound
&lt;/h2&gt;

&lt;p&gt;macOS ships with a bunch of system sounds. Want something different? Try these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# List all available system sounds&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; /System/Library/Sounds/

&lt;span class="c"&gt;# Some favorites:&lt;/span&gt;
afplay /System/Library/Sounds/Glass.aiff    &lt;span class="c"&gt;# Subtle, clean&lt;/span&gt;
afplay /System/Library/Sounds/Ping.aiff     &lt;span class="c"&gt;# Classic notification&lt;/span&gt;
afplay /System/Library/Sounds/Hero.aiff     &lt;span class="c"&gt;# Triumphant finish&lt;/span&gt;
afplay /System/Library/Sounds/Purr.aiff     &lt;span class="c"&gt;# Gentle nudge&lt;/span&gt;
afplay /System/Library/Sounds/Funk.aiff     &lt;span class="c"&gt;# The OG (my pick)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Linux, you could use &lt;code&gt;paplay&lt;/code&gt;, &lt;code&gt;aplay&lt;/code&gt;, or even &lt;code&gt;espeak "done"&lt;/code&gt; if you want Claude to literally tell you it's finished. On Windows WSL, &lt;code&gt;powershell.exe -c "(New-Object Media.SoundPlayer 'C:\Windows\Media\notify.wav').PlaySync()"&lt;/code&gt; works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Going Further: Conditional Sounds
&lt;/h2&gt;

&lt;p&gt;Want different sounds for different situations? The Stop hook receives JSON on stdin with a &lt;code&gt;last_assistant_message&lt;/code&gt; field. You could parse that to play a success sound when tests pass and an error sound when something breaks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nv"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.last_assistant_message // empty'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qi&lt;/span&gt; &lt;span class="s2"&gt;"error&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;fail&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;blocked"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;afplay /System/Library/Sounds/Basso.aiff
&lt;span class="k"&gt;else
  &lt;/span&gt;afplay /System/Library/Sounds/Funk.aiff
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save that as &lt;code&gt;~/.claude/stop-sound.sh&lt;/code&gt;, make it executable, and point your hook at it instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Full Picture
&lt;/h2&gt;

&lt;p&gt;Combined with the &lt;a href="https://blog.jo4.io/claude-code-hooks/" rel="noopener noreferrer"&gt;PreToolUse hooks&lt;/a&gt; I set up earlier, my &lt;code&gt;~/.claude/settings.json&lt;/code&gt; now looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Stop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"afplay /System/Library/Sounds/Funk.aiff"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"~/.claude/block-git.sh"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"~/.claude/block-cd.sh"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PreToolUse hooks keep Claude safe. Stop hooks keep &lt;em&gt;me&lt;/em&gt; in the loop. Together they make Claude Code feel less like a tool and more like a teammate who knows when to wait and when to nudge.&lt;/p&gt;




&lt;p&gt;These are the little things that make your day as a nerdy engineer. A five-line config change, a system sound you've heard a thousand times, and suddenly your entire AI-assisted workflow just &lt;em&gt;clicks&lt;/em&gt;. It's not a groundbreaking feature. It's not going to make the front page of Hacker News. But it'll save you dozens of context-switch minutes every single day, and you'll wonder why you didn't set it up sooner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Have you set up Stop hooks yet? What sound did you pick?&lt;/strong&gt; Drop a comment - I'm genuinely curious what sounds people gravitate toward. Bonus points if you went the &lt;code&gt;espeak&lt;/code&gt; route and have Claude literally talking to you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; - a modern URL shortener with analytics, bio pages, and team workspaces.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>productivity</category>
      <category>devops</category>
    </item>
    <item>
      <title>We Built a 2048 Clone, Then Taught It to Play Itself</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Fri, 26 Jun 2026 01:50:48 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/we-built-a-2048-clone-then-taught-it-to-play-itself-13nd</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/we-built-a-2048-clone-then-taught-it-to-play-itself-13nd</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/1024-game-ai-autoplay/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We wanted a fun utility page for &lt;a href="https://jo4.io/u/1024" rel="noopener noreferrer"&gt;jo4.io/u/1024&lt;/a&gt;. Something people would open, mess around with for a minute, and then leave running in a tab. We ended up building a 2048-style tile-merging game with level-ups, confetti, and an AI that quietly takes over when you walk away.&lt;/p&gt;

&lt;p&gt;Here's how we went from "let's make a simple game" to "wait, we need to implement a search tree."&lt;/p&gt;




&lt;h2&gt;
  
  
  The Game Itself
&lt;/h2&gt;

&lt;p&gt;If you have played 2048, you know the drill. A 4x4 grid, tiles with numbers, slide them in four directions, matching tiles merge. Our twist: you start with base tiles [2, 4, 8] and the first milestone is 1024. Hit it, and you level up.&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;LEVEL_CONFIG&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="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;       &lt;span class="na"&gt;milestone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&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="na"&gt;milestone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2048&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&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="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;   &lt;span class="na"&gt;milestone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;milestone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8192&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2048&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;milestone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16384&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2048&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8192&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;milestone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;32768&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;Each level-up bumps your base tile values. Those tiny 2s and 4s that were cluttering the board? Gone. The &lt;code&gt;cleanResidualTiles&lt;/code&gt; function sweeps anything below the new minimum base value. Suddenly the board opens up and you are playing a different game.&lt;/p&gt;

&lt;p&gt;Six levels. The final milestone is 32,768. We have not reached it ourselves. The AI has.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making It Feel Good
&lt;/h2&gt;

&lt;p&gt;A tile game lives or dies on how it &lt;em&gt;feels&lt;/em&gt;. We spent more time on animations than on the game logic itself.&lt;/p&gt;

&lt;p&gt;Tiles slide via CSS &lt;code&gt;top&lt;/code&gt;/&lt;code&gt;left&lt;/code&gt; transitions with a smooth ease-out curve. When tiles merge, we run a 4-phase elastic bounce animation we call &lt;code&gt;mergeBurst&lt;/code&gt; -- the tile shrinks to 60%, overshoots to 135%, settles back to 90%, then lands at 100%. It sounds over-the-top on paper. In practice it feels like tiles are made of marshmallow and it is deeply satisfying.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;mergeBurst&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="err"&gt;30&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1.35&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="err"&gt;50&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="err"&gt;70&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="err"&gt;100&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&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 top of that, merged tiles get an expanding ring shockwave (&lt;code&gt;mergeRing&lt;/code&gt;) that scales out to 1.8x and fades. Big merges (64+) flash the value across the board. Hit a milestone and confetti explodes from the center, 60 particles with randomized angles, velocities, and spin.&lt;/p&gt;

&lt;p&gt;The key design decision: animations use &lt;code&gt;transform: scale()&lt;/code&gt; only. Positioning uses &lt;code&gt;top&lt;/code&gt;/&lt;code&gt;left&lt;/code&gt; with transitions. This prevents the classic bug where CSS animations fight with position transitions and tiles teleport around the board.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Idle Problem
&lt;/h2&gt;

&lt;p&gt;We noticed something during testing. We would open the game, play for a bit, then get distracted by actual work. The tab would just sit there. Wasted screen real estate.&lt;/p&gt;

&lt;p&gt;So we added autoplay. Go idle for 10 seconds and a countdown slider appears: "AI takeover in 30s." If you do not touch anything, the AI starts playing at one move per second. Press any key, click, or swipe to take back control instantly.&lt;/p&gt;

&lt;p&gt;The UX matters here. A big arrow overlay (on a dark circle) shows which direction the AI is moving each turn. You can watch the AI think. It is weirdly hypnotic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Teaching the AI: Expectimax Search
&lt;/h2&gt;

&lt;p&gt;The first AI was just "pick a random valid move." It was terrible. Watching it play was painful.&lt;/p&gt;

&lt;p&gt;Then we read &lt;a href="https://jdlm.info/articles/2018/03/18/markov-decision-process-2048.html" rel="noopener noreferrer"&gt;John Googler's writeup on Markov Decision Processes for 2048&lt;/a&gt; and realized the right approach is &lt;strong&gt;expectimax search&lt;/strong&gt; -- a decision tree that alternates between the player picking the best move and the universe randomly placing a new tile.&lt;/p&gt;

&lt;p&gt;Here is the core idea. The player moves (maximizing), then the game places a random tile (chance node). We alternate these layers and look ahead a few moves to evaluate which direction leads to the best expected outcome.&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;expectimax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isPlayer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;baseVals&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;depth&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;evaluateGrid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;grid&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;isPlayer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="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;dir&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;left&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;up&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;right&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;down&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;moveGrid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;moved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&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;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="nf"&gt;expectimax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;baseVals&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;v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;best&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="nf"&gt;evaluateGrid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Chance node: average over ALL possible random tile placements&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cells&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="c1"&gt;// all empty cells&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="o"&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;grid&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;cells&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&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;cells&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;evaluateGrid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;grid&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;weight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cells&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;baseVals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;total&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="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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;cells&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;val&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;baseVals&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;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&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;row&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
      &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;val&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;weight&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;
        &lt;span class="nf"&gt;expectimax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;baseVals&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;total&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;We run this at &lt;strong&gt;depth 4&lt;/strong&gt;, which means 2 full look-ahead moves: player, chance, player, chance, then evaluate. This is enough for the AI to avoid obvious traps without taking seconds to compute.&lt;/p&gt;

&lt;p&gt;The chance node is what makes this different from minimax. Instead of assuming the worst-case random tile, we average over &lt;em&gt;every&lt;/em&gt; possible placement. This reflects reality -- the game is not adversarial, it is stochastic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Board Evaluation: Snake Weights
&lt;/h2&gt;

&lt;p&gt;The evaluation function is where the real strategy lives. We use a &lt;strong&gt;snake-pattern weight matrix&lt;/strong&gt; that assigns exponentially decreasing values in a zig-zag:&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;W&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="mi"&gt;32768&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16384&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8192&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;  &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2048&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;  &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="mi"&gt;64&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="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;],&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="mi"&gt;8&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 encourages the AI to keep the biggest tile in the top-left corner and arrange tiles in descending order along a snake path. We also add a quadratic empty-cell bonus (&lt;code&gt;empty * empty * 200&lt;/code&gt;) because empty space is safety, and a merge-potential bonus when adjacent tiles share the same value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Corner-Lock: The Secret Sauce
&lt;/h2&gt;

&lt;p&gt;The expectimax alone is decent but it has a weakness. Sometimes the highest-scoring move pulls the max tile out of the corner, and recovery is expensive. So we added a &lt;strong&gt;corner-lock&lt;/strong&gt; filter.&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;getBestMove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tiles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;baseTileValues&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;grid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tilesToGrid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tiles&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Find the max tile and its corner&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;maxVal&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="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="o"&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;grid&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;c&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;maxVal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;maxVal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;c&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;corner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;findMaxCorner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;grid&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;maxInCorner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;corner&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="nx"&gt;corner&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="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;maxVal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Score all valid moves&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dir&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;left&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;up&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;right&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;down&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;moveGrid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;moved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&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;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="nf"&gt;expectimax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;baseTileValues&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;keepCorner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;maxInCorner&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;corner&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="nx"&gt;corner&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="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;maxVal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;scored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;keepCorner&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;scored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Prefer corner-safe moves; corner-breaking = last resort&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;safe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;scored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keepCorner&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;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;safe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;safe&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;scored&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&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;pool&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="nx"&gt;dir&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;Every move is classified as either "corner-safe" (the max tile stays put) or "corner-breaking." The AI only considers corner-breaking moves when there is literally no other option. This single rule dramatically improved how far the AI gets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance: Why Two Move Functions
&lt;/h2&gt;

&lt;p&gt;You might notice we have two move functions: &lt;code&gt;moveTiles&lt;/code&gt; (operates on &lt;code&gt;Tile&lt;/code&gt; objects with IDs and animation flags) and &lt;code&gt;moveGrid&lt;/code&gt; (operates on a raw 2D number array). The game UI uses &lt;code&gt;moveTiles&lt;/code&gt; because it needs tile identity for React keys and animation state. The AI uses &lt;code&gt;moveGrid&lt;/code&gt; because creating thousands of Tile objects per decision would be wasteful.&lt;/p&gt;

&lt;p&gt;At depth 4, the AI evaluates hundreds of board states per move. Keeping the AI path as pure number-crunching -- no object allocation, no ID generation -- keeps each move well under 100ms on a modern browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;The AI reliably reaches 1024, often gets to 2048, and occasionally pushes into the 4096+ levels. It is not perfect -- a deeper search or more sophisticated evaluation (monotonicity checks, smoothness penalties) could push it further. But for a browser game meant to be a fun background distraction, it hits the sweet spot.&lt;/p&gt;

&lt;p&gt;You can watch it right now at &lt;a href="https://jo4.io/u/1024" rel="noopener noreferrer"&gt;jo4.io/u/1024&lt;/a&gt;. Open the page, play a few rounds, then put your hands down and watch the AI take over. It is oddly relaxing.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What is the highest tile you have reached -- manually or with an AI?&lt;/strong&gt; Drop your score in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; -- a modern URL shortener with analytics, bio pages, and apparently, tile games.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>gamedev</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Generate Printable Calendar PDFs (Free, No Signup, Works Offline)</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Wed, 24 Jun 2026 01:46:32 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/how-to-generate-printable-calendar-pdfs-free-no-signup-works-offline-2gdm</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/how-to-generate-printable-calendar-pdfs-free-no-signup-works-offline-2gdm</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/free-printable-calendar-pdf-generator/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every January, we go through the same ritual. Search "printable calendar 2026," click the first result, dodge three pop-ups, decline a newsletter, close an autoplay video, and finally download a generic PDF that doesn't quite fit what we need.&lt;/p&gt;

&lt;p&gt;We got tired of the ritual. So we built a calendar PDF generator that does exactly what you'd expect and nothing you wouldn't.&lt;/p&gt;

&lt;p&gt;It's free, at &lt;a href="https://jo4.io/u/cal" rel="noopener noreferrer"&gt;jo4.io/u/cal&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What It Does
&lt;/h2&gt;

&lt;p&gt;The tool generates downloadable calendar PDFs in three view modes, each designed for a different use case.&lt;/p&gt;

&lt;h3&gt;
  
  
  Year View
&lt;/h3&gt;

&lt;p&gt;A landscape PDF with all 12 months arranged in a 4x3 grid of mini calendars. Print one page, pin it to your wall, and you've got the entire year at a glance.&lt;/p&gt;

&lt;p&gt;This is the view we use for long-range planning: vacations, project milestones, fiscal quarters. One sheet, no scrolling.&lt;/p&gt;

&lt;h3&gt;
  
  
  Month View
&lt;/h3&gt;

&lt;p&gt;A landscape PDF showing a single month in a full-size grid. Each day gets its own cell with enough room to write in tasks, appointments, or reminders after printing.&lt;/p&gt;

&lt;p&gt;If you've ever printed a Google Calendar and been disappointed by the layout, this is the fix. Clean grid, readable fonts, no clutter.&lt;/p&gt;

&lt;h3&gt;
  
  
  Weekly View
&lt;/h3&gt;

&lt;p&gt;A portrait PDF with a daily planner layout. Each day gets hourly lines from 8 AM to 8 PM, so you can block out your schedule for the week.&lt;/p&gt;

&lt;p&gt;This is useful for people who plan their days in time blocks. Print a fresh one every Monday, fill it in by hand, and keep it on your desk. Sometimes paper beats pixels.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Day Selector (This Is the Part People Love)
&lt;/h2&gt;

&lt;p&gt;Here's where it gets interesting. Below the view mode selector, you'll find a row of toggleable weekday buttons: Sun, Mon, Tue, Wed, Thu, Fri, Sat. By default, all seven days are selected.&lt;/p&gt;

&lt;p&gt;Toggle off Saturday and Sunday, and now you have a &lt;strong&gt;work-week calendar&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Toggle off everything except Monday, Wednesday, and Friday, and you have a calendar that only shows the days your class meets.&lt;/p&gt;

&lt;p&gt;You control which days appear on the final PDF. And you get to choose what happens to the days you deselect.&lt;/p&gt;

&lt;h3&gt;
  
  
  Grey Out vs. Remove
&lt;/h3&gt;

&lt;p&gt;When you deselect a day, you pick one of two modes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Grey Out&lt;/strong&gt; keeps all seven columns in the grid, but the deselected days appear in light grey. The headers are muted, the day numbers are faded. You can still see them for context, but they visually recede. This is great when you want to focus on work days without losing track of the full week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Remove&lt;/strong&gt; eliminates the deselected day columns entirely. The remaining days expand to fill the available space. A Monday-through-Friday calendar in Remove mode gives each day more horizontal room, which means bigger cells and more writing space when you print.&lt;/p&gt;

&lt;p&gt;We went back and forth on whether to include both options. Grey Out won for people who want context. Remove won for people who want space. So we kept both.&lt;/p&gt;




&lt;h2&gt;
  
  
  Practical Use Cases
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Work-Week Calendar
&lt;/h3&gt;

&lt;p&gt;Select Month view. Toggle off Saturday and Sunday. Set mode to Remove. Choose "Monday" as the week start.&lt;/p&gt;

&lt;p&gt;You get a clean five-column monthly calendar where every day cell is wider than usual. Print twelve of these (one per month), hole-punch them, and you have a work planner for the year.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Academic Schedule
&lt;/h3&gt;

&lt;p&gt;You have classes on Tuesday and Thursday only. Toggle off every other day. Set mode to Remove. Now your month view shows only the days you need. Each Tuesday and Thursday cell is wide enough to write in assignment due dates, lecture topics, or reading notes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Weekly Time Blocker
&lt;/h3&gt;

&lt;p&gt;Switch to Week view. Keep all days selected, or remove weekends if you only plan work hours. Print a fresh weekly planner every Monday. The hourly lines from 8 AM to 8 PM give you a structured grid for time blocking.&lt;/p&gt;

&lt;p&gt;We've found this is the fastest way to plan a week if you're a pen-and-paper person. Digital calendars are great for reminders and sharing. But there's something about physically writing "deep work" in a 2-hour block that makes it stick.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Event Planning Calendar
&lt;/h3&gt;

&lt;p&gt;Running a conference in October? Open Month view, select October 2026, and print it. The Year view also works here: print the full year and circle the key dates with a marker.&lt;/p&gt;




&lt;h2&gt;
  
  
  Paper Sizes and Week Start
&lt;/h2&gt;

&lt;p&gt;Two small options that matter more than you'd think.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Paper size&lt;/strong&gt; toggles between US Letter (8.5 x 11 inches) and A4 (210 x 297 mm). If you're in the US, you probably want Letter. Everywhere else, A4. Printing on the wrong paper size gives you awkward margins, so we made it easy to pick the right one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Week start&lt;/strong&gt; toggles between Sunday and Monday. The US convention starts weeks on Sunday. Most of Europe and ISO 8601 start on Monday. Whichever you prefer, the calendar grid adjusts accordingly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Today's Date Highlighting
&lt;/h2&gt;

&lt;p&gt;The generated PDF highlights the current date in bold blue. It's a small touch, but when you print a calendar and pin it up, it helps you orient immediately. You can spot "today" at a glance and start planning from there.&lt;/p&gt;




&lt;h2&gt;
  
  
  Privacy: Everything Runs in Your Browser
&lt;/h2&gt;

&lt;p&gt;The entire tool is client-side. When you click "Download PDF," the calendar is generated right in your browser using &lt;a href="https://github.com/parallax/jsPDF" rel="noopener noreferrer"&gt;jsPDF&lt;/a&gt;. No data is sent to any server. No API calls. No tracking of which dates you selected or which view you prefer.&lt;/p&gt;

&lt;p&gt;This means it also works offline. Load the page once, disconnect from the internet, and you can still generate PDFs. Useful if you're on a plane, in a cafe with spotty Wi-Fi, or just someone who appreciates tools that respect your bandwidth.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to Use It
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;a href="https://jo4.io/u/cal" rel="noopener noreferrer"&gt;jo4.io/u/cal&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Pick your view mode: Year, Month, or Week&lt;/li&gt;
&lt;li&gt;Select the month/year (or week, depending on view)&lt;/li&gt;
&lt;li&gt;Choose your paper size: Letter or A4&lt;/li&gt;
&lt;li&gt;Set week start: Sunday or Monday&lt;/li&gt;
&lt;li&gt;Toggle which days to include using the day selector buttons&lt;/li&gt;
&lt;li&gt;Choose whether deselected days are greyed out or removed&lt;/li&gt;
&lt;li&gt;Hit download&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. PDF appears in your downloads folder, ready to print.&lt;/p&gt;

&lt;p&gt;No account creation. No email required. No watermarks. No "upgrade to pro for more features." The full tool is free.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part of a Bigger Toolbox
&lt;/h2&gt;

&lt;p&gt;The calendar generator lives at &lt;a href="https://jo4.io/u/cal" rel="noopener noreferrer"&gt;jo4.io/u/cal&lt;/a&gt;, part of our free tools suite at &lt;a href="https://jo4.io/u" rel="noopener noreferrer"&gt;jo4.io/u&lt;/a&gt;. The suite includes 20+ utilities: JSON formatter, JWT decoder, password generator, QR code maker, and more. Same philosophy across all of them: client-side, no signup, no ads, just tools that work.&lt;/p&gt;

&lt;p&gt;We built these because we use them daily. The calendar generator was the latest addition because we kept printing bad calendars from other sites and thinking "we can do better."&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What does your ideal printable calendar look like?&lt;/strong&gt; If there's a view or feature you'd want, drop it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; - URL shortener with analytics, plus free tools at &lt;a href="https://jo4.io/u" rel="noopener noreferrer"&gt;jo4.io/u&lt;/a&gt; including a printable calendar PDF generator.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>webdev</category>
      <category>tools</category>
      <category>javascript</category>
    </item>
    <item>
      <title>We Built a Referral Program Tool Inside Our URL Shortener. Here's Why.</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Mon, 01 Jun 2026 01:55:21 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/we-built-a-referral-program-tool-inside-our-url-shortener-heres-why-pak</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/we-built-a-referral-program-tool-inside-our-url-shortener-heres-why-pak</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/referral-kit-launch-story/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every SaaS founder eventually asks the same question: "How do I get my existing users to bring me new ones?"&lt;/p&gt;

&lt;p&gt;The answer is referral programs. The problem is the tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem with Referral Tools
&lt;/h2&gt;

&lt;p&gt;I spent a week evaluating referral platforms for jo4. Here's what I found:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;What's Missing&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rewardful&lt;/td&gt;
&lt;td&gt;$29-299/mo&lt;/td&gt;
&lt;td&gt;No link analytics, Stripe-only payouts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FirstPromoter&lt;/td&gt;
&lt;td&gt;$49-149/mo&lt;/td&gt;
&lt;td&gt;Complex setup, steep learning curve&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ReferralCandy&lt;/td&gt;
&lt;td&gt;$47-299/mo&lt;/td&gt;
&lt;td&gt;E-commerce only, no API&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every single one required a &lt;em&gt;separate&lt;/em&gt; URL shortener for tracking. Every one locked you into Stripe for payouts. And the cheapest option started at $29/mo for basic features.&lt;/p&gt;

&lt;p&gt;Meanwhile, I was already running a URL shortener with click analytics, device tracking, and geo-location built in. The referral link infrastructure was &lt;em&gt;already there&lt;/em&gt;. I just needed to add programs, referrers, and reward tracking on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Referral Kit Actually Does
&lt;/h2&gt;

&lt;p&gt;The pitch is simple: create a referral program in 10 minutes, not 10 days.&lt;/p&gt;

&lt;h3&gt;
  
  
  For Program Owners
&lt;/h3&gt;

&lt;p&gt;You create a program at &lt;code&gt;/rk&lt;/code&gt; with a name, reward structure, and destination URL. You choose between two reward types:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixed Points&lt;/strong&gt; — every conversion earns the referrer the same amount. "Refer a friend, earn 100 points."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Percentage&lt;/strong&gt; — referrers earn a cut of the conversion value. "Earn 10% of every purchase your referral makes."&lt;/p&gt;

&lt;p&gt;You get a public landing page at &lt;code&gt;/rk/{your-program-slug}&lt;/code&gt; where anyone can sign up as a referrer. They pick a custom referral code (like &lt;code&gt;JOHN20&lt;/code&gt;), enter their payout email, and get a unique tracking link.&lt;/p&gt;

&lt;h3&gt;
  
  
  For Referrers
&lt;/h3&gt;

&lt;p&gt;After enrolling, you get a link like &lt;code&gt;https://jo4.io/r/{shortUrl}&lt;/code&gt;. Share it anywhere. Every click is tracked with the same analytics jo4 already provides — device, location, referrer source, timestamp.&lt;/p&gt;

&lt;p&gt;When someone converts (buys something, signs up, whatever your program defines as a conversion), you earn points. Your dashboard shows clicks, conversions, conversion rate, and pending/approved/paid points across all programs you're in.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Conversion Flow
&lt;/h3&gt;

&lt;p&gt;This is where it gets interesting. Program owners trigger conversions via API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://jo4-api.jo4.io/api/v1/public/referral/conversion &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Jo4-Conversion-Key: your_secret_key"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "programSlug": "summer-referral",
    "referralCode": "JOHN20",
    "conversionValue": 99.00,
    "externalId": "order_12345"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API attributes the conversion to the referrer, calculates the reward (fixed or percentage), and creates a pending reward. The program owner reviews and approves. The referrer requests payout. The owner pays (offline — no Stripe lock-in) and marks it done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Status flow:&lt;/strong&gt; &lt;code&gt;PENDING → APPROVED → PAYOUT_REQUESTED → PAID&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;No payment processor dependency. No percentage-of-a-percentage. You pay your referrers however you want — PayPal, Venmo, bank transfer, gift cards, crypto. We just track the state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Points Instead of Cash
&lt;/h2&gt;

&lt;p&gt;Most referral tools deal in dollars. We deal in points.&lt;/p&gt;

&lt;p&gt;Points are flexible. "100 points = $10" works. So does "100 points = 1 free month" or "100 points = a t-shirt." The program owner defines what points mean. We track the math.&lt;/p&gt;

&lt;p&gt;This matters for non-SaaS businesses. A nail salon running a referral program doesn't want to wire $10 to a customer's bank account. They want to give them a free manicure. Points let you do that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Technical Bits
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Attribution Window
&lt;/h3&gt;

&lt;p&gt;Every program has a configurable attribution window (default: 30 days). If someone clicks a referral link and converts within that window, the referrer gets credit. After the window closes, the conversion is organic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Event Deduplication
&lt;/h3&gt;

&lt;p&gt;The conversion API uses &lt;code&gt;externalId&lt;/code&gt; for idempotency. Send the same order ID twice, and we only credit the referrer once. This matters when your payment webhook fires multiple times (and it will).&lt;/p&gt;

&lt;h3&gt;
  
  
  Test Mode
&lt;/h3&gt;

&lt;p&gt;Prefix your &lt;code&gt;externalId&lt;/code&gt; with &lt;code&gt;test_&lt;/code&gt; and the conversion validates against your API secret but doesn't persist. Test your integration without polluting production data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Webhooks
&lt;/h3&gt;

&lt;p&gt;Every state change fires a webhook if you've configured one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;referral.signup&lt;/code&gt; — new referrer enrolled&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;referral.conversion&lt;/code&gt; — conversion attributed&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;referral.reward.approved&lt;/code&gt; — reward approved by owner&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;referral.reward.paid&lt;/code&gt; — payout completed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pipe these into Zapier, Slack, your CRM — whatever your workflow needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Got Right
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Building on existing infrastructure.&lt;/strong&gt; The URL shortener already had click tracking, device fingerprinting, and geo-location. Referral links are just URLs with attribution metadata. We didn't rebuild tracking — we extended it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Offline payouts.&lt;/strong&gt; Forcing Stripe integration would have narrowed our market to SaaS-only. The confirm-payout workflow is more manual, but it works for salons, clinics, restaurants, freelancers — anyone who pays referrers in-person or via their preferred method.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Points abstraction.&lt;/strong&gt; Cash rewards sound simpler, but they're actually harder. Currency conversion, tax implications, minimum payout thresholds — points sidestep all of that. Let the program owner decide what points are worth.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Got Wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Underestimating the UI complexity.&lt;/strong&gt; The &lt;code&gt;/rk&lt;/code&gt; page serves three audiences: program owners viewing their programs, referrers viewing their enrolled programs, and new users discovering programs. Getting the information hierarchy right took more iterations than the entire backend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reward approval workflow.&lt;/strong&gt; My first implementation auto-approved everything. Turns out program owners want to verify conversions before committing to payouts. Fraud is real, even in referral programs. Adding the PENDING → APPROVED step was the right call, but I should have started there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;p&gt;The whole feature is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1,084 lines of referral program service (Java)&lt;/li&gt;
&lt;li&gt;3 new database tables with proper indexes and constraints&lt;/li&gt;
&lt;li&gt;15+ API endpoints (public + protected)&lt;/li&gt;
&lt;li&gt;6 new frontend pages&lt;/li&gt;
&lt;li&gt;Full webhook integration&lt;/li&gt;
&lt;li&gt;Test mode for safe integration development&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And it ships with every jo4 plan. No add-on pricing. No "referral features start at Enterprise tier."&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Running a referral program or thinking about adding one to your product?&lt;/strong&gt; I'd love to hear what's working (or not) for you. Drop your experience in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; - URL shortener with analytics, QR codes, and now referral program management. Because your links should do more than redirect.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>startup</category>
      <category>buildinpublic</category>
      <category>saas</category>
      <category>marketing</category>
    </item>
    <item>
      <title>RevenueCat Integration for Indie SaaS: The Apple Tax Nobody Prepares You For</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Fri, 29 May 2026 01:47:54 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/revenuecat-integration-for-indie-saas-the-apple-tax-nobody-prepares-you-for-1cnk</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/revenuecat-integration-for-indie-saas-the-apple-tax-nobody-prepares-you-for-1cnk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/revenuecat-iap-integration-indie-saas/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I had Stripe working perfectly. Web dashboard payments, team billing, subscription upgrades and downgrades - all handled. Users happy. Revenue flowing.&lt;/p&gt;

&lt;p&gt;Then I submitted my React Native app to the App Store.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Apple's App Store Review Guideline 3.1.1 is clear: if your app offers subscriptions and the user can subscribe &lt;em&gt;in&lt;/em&gt; the app, you must use Apple's In-App Purchase system. No exceptions. No "just link to your website." If you show a paywall, it goes through Apple.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;Apple takes 15-30% of every transaction&lt;/li&gt;
&lt;li&gt;You can't use your existing Stripe flow on iOS&lt;/li&gt;
&lt;li&gt;You need a &lt;em&gt;second&lt;/em&gt; billing system running in parallel&lt;/li&gt;
&lt;li&gt;Both systems must keep the same user's subscription state in sync&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I'd heard of RevenueCat as the standard way to handle this. What I hadn't heard was how many moving parts are involved when you already have a working billing system.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture Decision
&lt;/h2&gt;

&lt;p&gt;The core question: where does the source of truth live?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A:&lt;/strong&gt; RevenueCat is the source of truth, and your backend reads from it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B:&lt;/strong&gt; Your backend database is the source of truth, and RevenueCat sends webhooks to update it.&lt;/p&gt;

&lt;p&gt;I went with &lt;strong&gt;Option B&lt;/strong&gt;. Here's why:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I already had &lt;code&gt;subscriptionTier&lt;/code&gt;, &lt;code&gt;subscriptionStatus&lt;/code&gt;, and &lt;code&gt;subscriptionExpiry&lt;/code&gt; on my &lt;code&gt;UserEntity&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;All my API authorization logic checked these fields&lt;/li&gt;
&lt;li&gt;Stripe webhooks already wrote to these same fields&lt;/li&gt;
&lt;li&gt;I wasn't about to rewrite every &lt;code&gt;@PreAuthorize&lt;/code&gt; check to call RevenueCat's API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the plan: RevenueCat sends webhook events to my backend, and my backend updates the database. Same pattern as Stripe. How hard could it be?&lt;/p&gt;

&lt;h2&gt;
  
  
  The Implementation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: The Webhook Handler (Spring Boot)
&lt;/h3&gt;

&lt;p&gt;RevenueCat sends a POST with a JSON payload containing an &lt;code&gt;event&lt;/code&gt; object. The event has a &lt;code&gt;type&lt;/code&gt; (like &lt;code&gt;INITIAL_PURCHASE&lt;/code&gt;, &lt;code&gt;RENEWAL&lt;/code&gt;, &lt;code&gt;CANCELLATION&lt;/code&gt;) and an &lt;code&gt;app_user_id&lt;/code&gt; that you set when the user logs in on mobile.&lt;/p&gt;

&lt;p&gt;The key insight: &lt;strong&gt;use your database user ID as RevenueCat's &lt;code&gt;app_user_id&lt;/code&gt;&lt;/strong&gt;. This makes the webhook handler trivial - you get the user ID directly from the event, look up the user, and update their subscription.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// On mobile login, after getting the backend user profile:&lt;/span&gt;
&lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="nc"&gt;Purchases&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;logIn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profileData&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// In the webhook handler, the app_user_id IS your database ID:&lt;/span&gt;
&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parseLong&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"app_user_id"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;asText&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="nc"&gt;UserEntity&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No mapping tables. No secondary lookup. Clean.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Product ID Conventions
&lt;/h3&gt;

&lt;p&gt;RevenueCat doesn't tell you the subscription tier directly. You infer it from the product ID you configured in App Store Connect. I used a simple convention:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Product IDs: "jo4_pro_monthly", "jo4_proplus_yearly", etc.&lt;/span&gt;
&lt;span class="nc"&gt;SubscriptionTier&lt;/span&gt; &lt;span class="nf"&gt;determineTierFromProductId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;productId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;lower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;productId&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toLowerCase&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"proplus"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;SubscriptionTier&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PRO_PLUS&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"pro"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;SubscriptionTier&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PRO&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;SubscriptionTier&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;FREE&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Order matters - check &lt;code&gt;proplus&lt;/code&gt; before &lt;code&gt;pro&lt;/code&gt;, otherwise every Pro+ user gets classified as Pro. Ask me how I found that one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Event Deduplication
&lt;/h3&gt;

&lt;p&gt;RevenueCat can send the same event multiple times (retries, network issues). Without deduplication, a single purchase could reset a user's URL count twice.&lt;/p&gt;

&lt;p&gt;Redis made this straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;tryMarkEventAsProcessed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;eventId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"revenuecat:webhook:event:"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;eventId&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="n"&gt;wasSet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redisTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;opsForValue&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setIfAbsent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"processed"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofHours&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TRUE&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wasSet&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Atomic &lt;code&gt;setIfAbsent&lt;/code&gt; with a 24-hour TTL. If it returns &lt;code&gt;false&lt;/code&gt;, we've already processed this event. Done.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: The Seven Lifecycle Events
&lt;/h3&gt;

&lt;p&gt;This is where the complexity lives. RevenueCat sends seven different event types, and each one needs different handling:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;What Happens&lt;/th&gt;
&lt;th&gt;Tier Change?&lt;/th&gt;
&lt;th&gt;Reset URL Count?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;INITIAL_PURCHASE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;New subscriber&lt;/td&gt;
&lt;td&gt;Yes - set tier&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RENEWAL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Auto-renewal succeeded&lt;/td&gt;
&lt;td&gt;Yes - confirm tier&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CANCELLATION&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;User cancelled (but still has access until expiry)&lt;/td&gt;
&lt;td&gt;No - keep tier&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EXPIRATION&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Access period ended&lt;/td&gt;
&lt;td&gt;Yes - back to FREE&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BILLING_ISSUE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Payment failed, grace period&lt;/td&gt;
&lt;td&gt;No - pause status&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PRODUCT_CHANGE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Upgrade or downgrade&lt;/td&gt;
&lt;td&gt;Yes - new tier&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UNCANCELLATION&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;User re-enabled auto-renew&lt;/td&gt;
&lt;td&gt;No - restore ACTIVE status&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The tricky one is &lt;code&gt;CANCELLATION&lt;/code&gt;. Your instinct says "downgrade them." But no - they've already &lt;em&gt;paid&lt;/em&gt; for the current period. You mark them as &lt;code&gt;CANCELLED&lt;/code&gt; but keep their tier until &lt;code&gt;EXPIRATION&lt;/code&gt; fires.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: The Mobile Side
&lt;/h3&gt;

&lt;p&gt;On the React Native side, RevenueCat's SDK handles the App Store / Play Store payment sheets. You wrap it in a hook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useSubscription&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;SubscriptionState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;offerings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setOfferings&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;customerInfo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setCustomerInfo&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Derived state from entitlements&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isPro&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;customerInfo&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;entitlements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;active&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pro&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="kc"&gt;undefined&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;isProPlus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;customerInfo&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;entitlements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;active&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pro_plus&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="kc"&gt;undefined&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;purchasePackage&lt;/span&gt; &lt;span class="o"&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;pkg&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;customerInfo&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Purchases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;purchasePackage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setCustomerInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customerInfo&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="c1"&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 subscription screen needs three states:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Web subscriber&lt;/strong&gt; - they subscribed via Stripe on the web. Show "Managed on Web" with a link to the dashboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Active IAP subscriber&lt;/strong&gt; - they subscribed through the app. Show "Manage in App Store/Play Store."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free user&lt;/strong&gt; - show the paywall with Pro and Pro+ packages.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Getting state 1 right was the part I almost missed. If a user has a Pro tier in the backend but &lt;em&gt;no&lt;/em&gt; active RevenueCat entitlements, they're a web subscriber. Don't show them a paywall. Don't let them accidentally buy a duplicate subscription through Apple.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Security Gotchas
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Webhook Authentication
&lt;/h3&gt;

&lt;p&gt;RevenueCat lets you set an authorization token in their dashboard. Your webhook endpoint verifies it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;webhookAuthToken&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;webhookAuthToken&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isBlank&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SECURITY: webhook auth token not configured"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;AppException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UNAUTHORIZED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Webhook processing unavailable"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fail closed.&lt;/strong&gt; If the auth token isn't configured (deployment mistake, missing env var), reject everything. Don't silently accept unverified webhooks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Return 200 for Unknown Events
&lt;/h3&gt;

&lt;p&gt;RevenueCat retries on non-200 responses. If you throw a 500 for an event type you don't handle, RevenueCat will retry it forever. Return 200 and log it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Unhandled event type: {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;eventType&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The webhook auth token is a secret. I stored it in GitHub Secrets and injected it at deploy time through the workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# In the deployment workflow .env template&lt;/span&gt;
&lt;span class="s"&gt;REVENUECAT_WEBHOOK_AUTH_TOKEN=${{ secrets.REVENUECAT_WEBHOOK_AUTH_TOKEN }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I initially had it in GitHub &lt;em&gt;variables&lt;/em&gt; (not secrets) because "it's just a webhook token, not a database password." Nope. Any token that gates financial transactions is a secret. Period.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Start with the webhook handler first.&lt;/strong&gt; I built the mobile paywall UI first, which meant I had a "Buy" button that worked but no backend to receive the purchase event. Webhook-first lets you test with RevenueCat's webhook tester before touching mobile code.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use the same product ID naming convention across platforms.&lt;/strong&gt; I initially had different Android and iOS product IDs. The &lt;code&gt;determineTierFromProductId&lt;/code&gt; logic doesn't care about platform, so matching conventions saves headaches.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Test cancellation and expiration separately.&lt;/strong&gt; These are different events with different behaviors. My first implementation treated &lt;code&gt;CANCELLATION&lt;/code&gt; as &lt;code&gt;EXPIRATION&lt;/code&gt; and immediately downgraded users. They were not thrilled.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;Two billing systems (Stripe for web, RevenueCat for mobile) writing to the same user record. The user doesn't know or care which one is active. They just see their subscription tier reflected consistently across web, iOS, and Android.&lt;/p&gt;

&lt;p&gt;Total implementation: ~250 lines of Java backend, ~120 lines of React Native hooks, ~350 lines of paywall UI. Plus 580 lines of tests, because the one thing worse than two billing systems is two &lt;em&gt;untested&lt;/em&gt; billing systems.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Have you dealt with the Apple IAP mandate on an existing SaaS?&lt;/strong&gt; I'd love to hear how you handled the dual-billing complexity. Drop your war stories in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; - a URL shortener with analytics for developers who'd rather not give Apple 30% but don't have a choice.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>ios</category>
      <category>mobile</category>
      <category>saas</category>
    </item>
    <item>
      <title>Why Our Android Build Was Signed with the Wrong Key (A Regex Cautionary Tale)</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Fri, 22 May 2026 01:47:58 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/why-our-android-build-was-signed-with-the-wrong-key-a-regex-cautionary-tale-281g</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/why-our-android-build-was-signed-with-the-wrong-key-a-regex-cautionary-tale-281g</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/expo-prebuild-android-signing-regex-bug/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"Your Android App Bundle is not signed with the correct key."&lt;/p&gt;

&lt;p&gt;That was the Google Play Console rejection after what should have been a routine release. The SHA1 fingerprint on the uploaded AAB didn't match the expected upload key. We'd been deploying to the Play Store for weeks with no issues. What changed?&lt;/p&gt;

&lt;p&gt;Nothing, as it turned out. The signing had been broken &lt;em&gt;the whole time&lt;/em&gt; -- we just hadn't noticed until Google tightened its fingerprint check.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;We use Expo with &lt;code&gt;expo prebuild --clean&lt;/code&gt; to generate the &lt;code&gt;android/&lt;/code&gt; directory before each build. Because it's regenerated every time, the entire &lt;code&gt;android/&lt;/code&gt; folder is gitignored. This means any customization to &lt;code&gt;build.gradle&lt;/code&gt; needs to happen through a post-prebuild injection script.&lt;/p&gt;

&lt;p&gt;Our &lt;code&gt;scripts/release.sh&lt;/code&gt; runs after prebuild and uses Node.js to patch the generated &lt;code&gt;build.gradle&lt;/code&gt; with the release signing configuration. It finds the &lt;code&gt;release&lt;/code&gt; buildType block and replaces &lt;code&gt;signingConfig signingConfigs.debug&lt;/code&gt; with &lt;code&gt;signingConfig signingConfigs.release&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Sounds straightforward. But the regex doing this work had a subtle, devastating bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Symptom
&lt;/h2&gt;

&lt;p&gt;After &lt;code&gt;release.sh&lt;/code&gt; ran, we expected &lt;code&gt;build.gradle&lt;/code&gt; to look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;buildTypes&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;debug&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;signingConfig&lt;/span&gt; &lt;span class="n"&gt;signingConfigs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;debug&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;release&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;signingConfig&lt;/span&gt; &lt;span class="n"&gt;signingConfigs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;release&lt;/span&gt;  &lt;span class="c1"&gt;// patched&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead, it looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;buildTypes&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;debug&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;signingConfig&lt;/span&gt; &lt;span class="n"&gt;signingConfigs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;release&lt;/span&gt;  &lt;span class="c1"&gt;// WRONG&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;release&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;signingConfig&lt;/span&gt; &lt;span class="n"&gt;signingConfigs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;debug&lt;/span&gt;    &lt;span class="c1"&gt;// WRONG&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The configs were &lt;strong&gt;swapped&lt;/strong&gt;. Debug was using the release keystore. Release was using the debug keystore. The build succeeded (both keystores are valid), but the release AAB was signed with the debug key.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Buggy Regex
&lt;/h2&gt;

&lt;p&gt;Here's the regex our script used:&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;patched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buildGradle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="sr"&gt;/release &lt;/span&gt;&lt;span class="se"&gt;\{[\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;signingConfig signingConfigs&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;debug/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signingConfig signingConfigs.debug&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signingConfig signingConfigs.release&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The intent: find &lt;code&gt;release {&lt;/code&gt; followed (lazily) by &lt;code&gt;signingConfig signingConfigs.debug&lt;/code&gt;, then replace that &lt;code&gt;debug&lt;/code&gt; with &lt;code&gt;release&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The problem: &lt;code&gt;release {&lt;/code&gt; appears &lt;strong&gt;twice&lt;/strong&gt; in the generated &lt;code&gt;build.gradle&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;signingConfigs&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;debug&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;release&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;        &lt;span class="c1"&gt;// &amp;lt;-- FIRST occurrence of "release {"&lt;/span&gt;
        &lt;span class="n"&gt;storeFile&lt;/span&gt; &lt;span class="nf"&gt;file&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"release.keystore"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;storePassword&lt;/span&gt; &lt;span class="s2"&gt;"..."&lt;/span&gt;
        &lt;span class="n"&gt;keyAlias&lt;/span&gt; &lt;span class="s2"&gt;"..."&lt;/span&gt;
        &lt;span class="n"&gt;keyPassword&lt;/span&gt; &lt;span class="s2"&gt;"..."&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;buildTypes&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;debug&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;signingConfig&lt;/span&gt; &lt;span class="n"&gt;signingConfigs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;debug&lt;/span&gt;   &lt;span class="c1"&gt;// &amp;lt;-- target line&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;release&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;        &lt;span class="c1"&gt;// &amp;lt;-- SECOND occurrence of "release {"&lt;/span&gt;
        &lt;span class="n"&gt;signingConfig&lt;/span&gt; &lt;span class="n"&gt;signingConfigs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;debug&lt;/span&gt;   &lt;span class="c1"&gt;// &amp;lt;-- intended target&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lazy quantifier &lt;code&gt;[\s\S]*?&lt;/code&gt; matched the &lt;strong&gt;first&lt;/strong&gt; &lt;code&gt;release {&lt;/code&gt; (inside &lt;code&gt;signingConfigs&lt;/code&gt;), then expanded minimally until it found &lt;code&gt;signingConfig signingConfigs.debug&lt;/code&gt;. The first &lt;code&gt;signingConfig signingConfigs.debug&lt;/code&gt; it encountered was inside the &lt;strong&gt;debug buildType&lt;/strong&gt;. So the regex matched from &lt;code&gt;signingConfigs.release {&lt;/code&gt; all the way down to the debug buildType's signing config -- and replaced it.&lt;/p&gt;

&lt;p&gt;This is the core misunderstanding: lazy quantifiers don't find the &lt;em&gt;closest&lt;/em&gt; &lt;code&gt;release {&lt;/code&gt; to the target. They find the &lt;em&gt;first&lt;/em&gt; &lt;code&gt;release {&lt;/code&gt; in the file, then minimize the gap from there. If the first match is in the wrong block, the lazy expansion crosses block boundaries to reach the target string.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Anchor the regex to the &lt;code&gt;buildTypes&lt;/code&gt; section:&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;patched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buildGradle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;buildTypes&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\{[\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;release&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\{[\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?)&lt;/span&gt;&lt;span class="sr"&gt;signingConfig signingConfigs&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;debug/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$1signingConfig signingConfigs.release&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;By requiring &lt;code&gt;buildTypes {&lt;/code&gt; before &lt;code&gt;release {&lt;/code&gt;, the regex skips the &lt;code&gt;signingConfigs.release&lt;/code&gt; block entirely. The capture group grabs everything from &lt;code&gt;buildTypes {&lt;/code&gt; through &lt;code&gt;release {&lt;/code&gt; and any content before the signing config line. Then we replace just the &lt;code&gt;signingConfig&lt;/code&gt; reference while preserving the surrounding structure.&lt;/p&gt;

&lt;p&gt;The key difference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BEFORE: /release \{[\s\S]*?signingConfig signingConfigs\.debug/
AFTER:  /(buildTypes\s*\{[\s\S]*?release\s*\{[\s\S]*?)signingConfig signingConfigs\.debug/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;buildTypes\s*\{&lt;/code&gt; anchor ensures we're in the right block before we ever look for &lt;code&gt;release {&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus Bug: versionCode Stuck at 1
&lt;/h2&gt;

&lt;p&gt;While debugging the signing issue, we found a second problem. Expo prebuild defaults &lt;code&gt;versionCode&lt;/code&gt; to &lt;code&gt;1&lt;/code&gt; in every generated &lt;code&gt;build.gradle&lt;/code&gt;. Google Play requires &lt;code&gt;versionCode&lt;/code&gt; to be strictly increasing -- you can never upload a version code equal to or lower than a previously uploaded one.&lt;/p&gt;

&lt;p&gt;Our fix: auto-generate &lt;code&gt;versionCode&lt;/code&gt; from epoch minutes in the release script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;VERSION_CODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This produces a value like &lt;code&gt;29432517&lt;/code&gt; that increases every minute. No manual tracking, no CI state to maintain, and no collisions as long as you don't release twice in the same minute.&lt;/p&gt;

&lt;p&gt;The Node.js injection then patches this into &lt;code&gt;build.gradle&lt;/code&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="nx"&gt;buildGradle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buildGradle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="sr"&gt;/versionCode &lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;+/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;`versionCode &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VERSION_CODE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Lazy quantifiers aren't always lazy enough.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;*?&lt;/code&gt; quantifier minimizes the match &lt;em&gt;after&lt;/em&gt; fixing the start position. If your start anchor (&lt;code&gt;release {&lt;/code&gt;) appears multiple times, the regex locks onto the first occurrence and expands from there. It doesn't backtrack to try the second occurrence unless the first one fails entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. When a pattern appears in multiple blocks, anchor to the surrounding context.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Don't match &lt;code&gt;release {&lt;/code&gt; when you mean &lt;code&gt;buildTypes { ... release {&lt;/code&gt;. The extra context eliminates ambiguity. This applies to any structured text you're patching with regex -- Gradle files, XML, YAML, anything with nested blocks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Gitignored generated files need persistent injection scripts -- and those scripts need tests.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We tested the app. We tested the build. We never tested &lt;code&gt;release.sh&lt;/code&gt; in isolation. A simple assertion -- "after running the script, the release buildType should have &lt;code&gt;signingConfigs.release&lt;/code&gt;" -- would have caught this immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Always verify build artifacts before pushing to a store.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A one-line check would have saved hours:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Verify the AAB is signed with the correct key&lt;/span&gt;
jarsigner &lt;span class="nt"&gt;-verify&lt;/span&gt; &lt;span class="nt"&gt;-verbose&lt;/span&gt; &lt;span class="nt"&gt;-certs&lt;/span&gt; app-release.aab | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"SHA1:"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the fingerprint doesn't match your expected upload key, stop. Don't submit and hope for the best.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Meta-Lesson
&lt;/h2&gt;

&lt;p&gt;Regex on structured data is inherently fragile. Every time &lt;code&gt;expo prebuild&lt;/code&gt; changes the generated &lt;code&gt;build.gradle&lt;/code&gt; format, our regex could break in new and creative ways. The real long-term fix is to use Expo's config plugins to inject signing configuration declaratively, removing the regex entirely. We're migrating to that approach now.&lt;/p&gt;

&lt;p&gt;But until then -- anchor your patterns, test your scripts, and verify your artifacts.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Have you been burned by regex matching across block boundaries?&lt;/strong&gt; What's your approach to patching generated build files? We'd love to hear about it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; -- a URL shortener with analytics, QR codes, and a mobile app that is now correctly signed.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>android</category>
      <category>debugging</category>
      <category>regex</category>
    </item>
    <item>
      <title>CDN Cache Invalidation: Why Deleted URLs Still Redirect (And How We Fixed It)</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Wed, 20 May 2026 01:48:55 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/cdn-cache-invalidation-why-deleted-urls-still-redirect-and-how-we-fixed-it-18mm</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/cdn-cache-invalidation-why-deleted-urls-still-redirect-and-how-we-fixed-it-18mm</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/cdn-cache-invalidation-stale-redirects/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You deleted the URL. Redis says it's gone. The database confirms it. But users click the link and still get redirected to the destination. &lt;code&gt;cf-cache-status: HIT&lt;/code&gt;. Cloudflare is happily serving a cached copy that nobody told it to forget.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;We run a URL shortener behind Cloudflare CDN. For performance, we cache redirect responses at the edge with a 2-hour TTL. This means popular short URLs resolve in under 50ms globally without touching our origin.&lt;/p&gt;

&lt;p&gt;The issue surfaced when a customer deleted a short URL and then clicked it to verify. Still working. They tried again 30 minutes later. Still working. They opened a support ticket.&lt;/p&gt;

&lt;p&gt;Same story with URL updates. A user changed the destination from &lt;code&gt;https://old-site.com&lt;/code&gt; to &lt;code&gt;https://new-site.com&lt;/code&gt;. The short URL kept redirecting to the old destination. OG metadata updates had the same problem — social cards showed stale titles and images because the HTML page was cached at the edge.&lt;/p&gt;

&lt;p&gt;Three distinct mutations, all broken:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Delete URL&lt;/strong&gt; — link stays alive via CDN cache&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update URL&lt;/strong&gt; — old destination served from CDN, old metadata in Redis&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Admin force-expire&lt;/strong&gt; — neither Redis nor CDN gets invalidated&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Root Cause: Layered Caching, Partial Invalidation
&lt;/h2&gt;

&lt;p&gt;Our caching architecture has two layers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User Request → Cloudflare CDN (edge cache) → Spring Boot API → Redis (app cache) → PostgreSQL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a URL is deleted, the service correctly invalidated the Redis cache. But it never told Cloudflare. The CDN layer continued serving stale responses until the TTL expired naturally.&lt;/p&gt;

&lt;p&gt;The update path was worse. &lt;code&gt;UrlService.updateUrl()&lt;/code&gt; wrote the new destination to the database but invalidated neither Redis nor Cloudflare. Reads hit Redis first, got the old cached value, and never saw the database update.&lt;/p&gt;

&lt;p&gt;Admin operations were the worst. &lt;code&gt;AdminService.forceExpireUrl()&lt;/code&gt; and &lt;code&gt;AdminService.deleteUrl()&lt;/code&gt; updated the database directly and skipped both cache layers entirely. Admin code had been written as direct repository calls, bypassing the service-layer cache invalidation that regular user operations went through.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Purge Both Layers on Every Mutation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Add &lt;code&gt;purgeUrls()&lt;/code&gt; to CloudflareService
&lt;/h3&gt;

&lt;p&gt;Cloudflare exposes &lt;code&gt;POST /zones/{zone_id}/purge_cache&lt;/code&gt; with a &lt;code&gt;{"files": [...]}&lt;/code&gt; body. We wrapped it in a service method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Async&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;purgeUrls&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;isEnabled&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Cloudflare API allows max 30 URLs per purge request&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;batches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;partition&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;batches&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;purgeUrlBatch&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;purgeUrlBatch&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;CLOUDFLARE_API_BASE&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/zones/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;zoneId&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/purge_cache"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;HttpHeaders&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;createHeaders&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"files"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;HttpEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;request&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;HttpEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CloudflareResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;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;restTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;exchange&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HttpMethod&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;POST&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CloudflareResponse&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;
        &lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBody&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBody&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;isSuccess&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Purged {} URL(s) from Cloudflare cache"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Cloudflare cache purge returned non-success for URLs: {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Failed to purge Cloudflare cache: {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two design decisions here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;@Async&lt;/code&gt; (fire-and-forget).&lt;/strong&gt; CDN purge should never block the user operation. If Cloudflare is slow or down, the delete/update still completes instantly. The cache will expire naturally via TTL as a fallback.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Batched in groups of 30.&lt;/strong&gt; Cloudflare's API limits purge requests to 30 URLs per call. A single short URL can produce up to 3 cacheable URLs (UI page, API endpoint, custom domain), so this limit matters for bulk operations.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Build the List of Cacheable URLs
&lt;/h3&gt;

&lt;p&gt;Each short URL can be cached under multiple paths. We need to purge all of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;addCacheableUrls&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;shortUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;customDomain&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// UI page (HTML with OG tags, served via Cloudflare CDN)&lt;/span&gt;
    &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uiHost&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/a/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;shortUrl&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// API endpoint (JSON, also cached by Cloudflare)&lt;/span&gt;
    &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiHost&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/api/v1/public/a/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;shortUrl&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Custom domain URL (if configured)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StringUtils&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isNotBlank&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customDomain&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;customDomain&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/a/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;shortUrl&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For updates where the short URL or custom domain itself changed, we purge both old and new URLs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;purgeCloudflareCache&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;shortUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;customDomain&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                                   &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;oldShortUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;oldCustomDomain&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;urlsToPurge&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;ArrayList&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;
    &lt;span class="n"&gt;addCacheableUrls&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urlsToPurge&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shortUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customDomain&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oldShortUrl&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;oldShortUrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shortUrl&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;addCacheableUrls&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urlsToPurge&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;oldShortUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;oldCustomDomain&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oldCustomDomain&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;oldCustomDomain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customDomain&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;urlsToPurge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;oldCustomDomain&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/a/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;shortUrl&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;urlsToPurge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;cloudflareService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;purgeUrls&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urlsToPurge&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Wire Into Every Mutation Path
&lt;/h3&gt;

&lt;p&gt;This is where the original bug lived. We had to audit every code path that mutates URL state:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;UrlService&lt;/strong&gt; (user-facing operations):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;updateUrl()&lt;/code&gt; — added Redis invalidation + Cloudflare purge&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;deleteUrl()&lt;/code&gt; — already had Redis invalidation, added Cloudflare purge&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;AdminService&lt;/strong&gt; (admin operations):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;forceExpireUrl()&lt;/code&gt; — added both Redis + Cloudflare invalidation&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;deleteUrl()&lt;/code&gt; — added both Redis + Cloudflare invalidation&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;refreshMetadata()&lt;/code&gt; — added Cloudflare purge (OG tags changed)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The admin fix required a dedicated helper since admin code was calling repositories directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;invalidateUrlCaches&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UrlEntity&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;urlCacheService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;invalidateCache&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getShortUrl&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;urlsToPurge&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;ArrayList&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;
    &lt;span class="n"&gt;urlsToPurge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uiHost&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/a/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getShortUrl&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;urlsToPurge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiHost&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/api/v1/public/a/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getShortUrl&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StringUtils&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isNotBlank&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCustomDomain&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;urlsToPurge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCustomDomain&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/a/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getShortUrl&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;cloudflareService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;purgeUrls&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urlsToPurge&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One method. Both cache layers. Called from every admin mutation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;@Async&lt;/code&gt; Is the Right Call
&lt;/h2&gt;

&lt;p&gt;CDN purge is a network call to Cloudflare's API. It adds 100-300ms of latency. If we made it synchronous:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;User deletes a URL — waits an extra 200ms for Cloudflare confirmation&lt;/li&gt;
&lt;li&gt;Cloudflare API is down — user's delete fails or hangs&lt;/li&gt;
&lt;li&gt;Bulk operations — each URL adds another round-trip&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With &lt;code&gt;@Async&lt;/code&gt;, the user operation completes immediately. The purge runs in the background thread pool. If it fails, the cache expires naturally via TTL (2 hours max). The user never notices.&lt;/p&gt;

&lt;p&gt;The tradeoff: there's a brief window (milliseconds to seconds) where the CDN might still serve stale content after an update. For a URL shortener, this is acceptable. For something like financial data, you'd want synchronous purge with error handling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Cache invalidation has layers.&lt;/strong&gt; If your architecture has &lt;code&gt;CDN → Redis → Database&lt;/code&gt;, you need to invalidate from the outside in. Clearing Redis doesn't help if Cloudflare is still serving cached responses. Most requests never reach your app server when the CDN has a hit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Admin code is a blind spot.&lt;/strong&gt; Admin operations often bypass service-layer abstractions. They call repositories directly for flexibility, but that means they skip whatever cache invalidation the service layer provides. Audit every mutation path, not just the user-facing ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Fire-and-forget is correct for CDN purge.&lt;/strong&gt; Don't block user operations on external API calls. Use &lt;code&gt;@Async&lt;/code&gt;, log failures, and rely on TTL expiration as your safety net. The worst case is stale content for a bounded time window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Enumerate all cacheable URLs.&lt;/strong&gt; A single logical resource can exist at multiple CDN URLs. Miss one and you have a partial purge. Our short URLs have three: the UI page, the API endpoint, and the custom domain variant. All three need purging.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Ever been bitten by a stale CDN cache hiding a "deleted" resource?&lt;/strong&gt; What's your cache invalidation strategy?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; - URL shortener with analytics, custom domains, and team workspaces.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>caching</category>
      <category>webdev</category>
      <category>java</category>
    </item>
    <item>
      <title>How to Track Link Clicks with Meta Pixel for Facebook Retargeting</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Mon, 18 May 2026 01:49:34 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/how-to-track-link-clicks-with-meta-pixel-for-facebook-retargeting-4g4n</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/how-to-track-link-clicks-with-meta-pixel-for-facebook-retargeting-4g4n</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/meta-pixel-retargeting-jo4/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You're running Facebook ads. You want to retarget people who clicked your links. But you're sending traffic to someone else's site (affiliate offer, client landing page, whatever) where you can't add your pixel.&lt;/p&gt;

&lt;p&gt;Here's how to fire Meta Pixel on every click using jo4's built-in retargeting.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;When someone clicks your jo4 short link, the redirect page loads your Meta Pixel before sending them to the destination. Facebook sees the &lt;code&gt;PageView&lt;/code&gt; event with your pixel ID.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User clicks jo4.io/abc123
    ↓
Pixel fires (PageView event)
    ↓
User redirected to destination
    ↓
Facebook audience updated
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This happens in milliseconds. Your audience grows with every click.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A jo4 account (free tier works)&lt;/li&gt;
&lt;li&gt;A Meta Pixel ID from Facebook Business Manager&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Get Your Meta Pixel ID
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;a href="https://business.facebook.com/events_manager" rel="noopener noreferrer"&gt;Facebook Events Manager&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Select your pixel (or create one)&lt;/li&gt;
&lt;li&gt;Copy the 15-16 digit Pixel ID&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It looks like: &lt;code&gt;123456789012345&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Add Pixel to Your Link
&lt;/h2&gt;

&lt;p&gt;When creating or editing a link in jo4:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Expand the &lt;strong&gt;Retargeting&lt;/strong&gt; section&lt;/li&gt;
&lt;li&gt;Paste your Pixel ID in the &lt;strong&gt;Meta Pixel ID&lt;/strong&gt; field&lt;/li&gt;
&lt;li&gt;Save the link&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. Every click on this link now fires your pixel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Verify It's Working
&lt;/h2&gt;

&lt;p&gt;Use the &lt;a href="https://chrome.google.com/webstore/detail/meta-pixel-helper/fdgfkebogiimcoedlicjlajpkdmockpc" rel="noopener noreferrer"&gt;Meta Pixel Helper&lt;/a&gt; Chrome extension:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install the extension&lt;/li&gt;
&lt;li&gt;Click your jo4 short link&lt;/li&gt;
&lt;li&gt;Check the extension icon - it should show a green checkmark&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You'll see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PageView&lt;/code&gt; event fired&lt;/li&gt;
&lt;li&gt;Your Pixel ID in the details&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Advanced: Track Multiple Pixels
&lt;/h2&gt;

&lt;p&gt;Need different pixels for different campaigns? Each jo4 link can have its own pixel ID. Create separate links for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Different ad accounts&lt;/li&gt;
&lt;li&gt;Different clients&lt;/li&gt;
&lt;li&gt;A/B testing audiences&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Advanced: Combine with UTM Parameters
&lt;/h2&gt;

&lt;p&gt;jo4 passes UTM parameters through to your destination. Combine with pixel tracking for full attribution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;jo4.io/abc123 (with Meta Pixel + UTMs)
    ↓
Pixel fires with PageView
    ↓
User lands on destination.com/?utm_source=facebook&amp;amp;utm_campaign=spring_sale
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Facebook audience data (for retargeting)&lt;/li&gt;
&lt;li&gt;UTM tracking (for conversion attribution)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Events Are Tracked?
&lt;/h2&gt;

&lt;p&gt;Currently, jo4 fires a &lt;code&gt;PageView&lt;/code&gt; event on every link click. This is what you need for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Building Custom Audiences ("People who clicked my links")&lt;/li&gt;
&lt;li&gt;Retargeting campaigns&lt;/li&gt;
&lt;li&gt;Lookalike audience creation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For &lt;code&gt;Purchase&lt;/code&gt;, &lt;code&gt;Lead&lt;/code&gt;, or other conversion events, those fire on your destination site where the action happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Solution&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;Complexity&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Add pixel to destination&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;Need site access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Third-party redirect service&lt;/td&gt;
&lt;td&gt;$50-200/mo&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;jo4 retargeting&lt;/td&gt;
&lt;td&gt;$0-16/mo&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most link shorteners charge extra for retargeting features. With jo4, it's included in every plan including free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Pitfalls
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Wrong Pixel ID format&lt;/strong&gt;: Make sure you're using the numeric Pixel ID, not the Pixel Name or Business Manager ID.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ad blockers&lt;/strong&gt;: Some users run ad blockers that prevent pixels from firing. This is normal - your tracked audience will be slightly smaller than total clicks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pixel not verified&lt;/strong&gt;: Facebook requires domain verification for some features. The pixel will still fire for building audiences even without verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;After setup, your Facebook audience grows automatically with every link click. No code changes to destination sites. No complex integrations.&lt;/p&gt;

&lt;p&gt;Check Events Manager after a few clicks - you'll see the PageView events with your short link URL.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Already using Meta Pixel with short links?&lt;/strong&gt; Share your setup in the comments!&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; - URL shortener with built-in retargeting pixels for marketers who track everything.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>marketing</category>
      <category>facebook</category>
      <category>analytics</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>iOS App Store Screenshots and Compliance: The Gotchas After Your Build Succeeds</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Sat, 16 May 2026 01:36:05 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/ios-app-store-screenshots-and-compliance-the-gotchas-after-your-build-succeeds-2aje</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/ios-app-store-screenshots-and-compliance-the-gotchas-after-your-build-succeeds-2aje</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/ios-appstore-screenshots-compliance/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Your EAS build succeeded. The IPA uploaded to App Store Connect. Time to submit for review, right?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Click.&lt;/em&gt; "Unable to Add for Review."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;App Store Connect has requirements that have nothing to do with your code. Screenshots need exact dimensions. Export compliance needs declarations for every country. Privacy questionnaires want to know about every SDK you use.&lt;/p&gt;

&lt;p&gt;Here's everything that blocked my submission and how I fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1: Screenshot Dimensions
&lt;/h2&gt;

&lt;p&gt;I ran the simulator, took screenshots, uploaded them. Error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Screenshots must be 1284 x 2778 pixels
Uploaded: 1320 x 2868 pixels
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;iPhone 16 Pro Max uses different dimensions than what App Store Connect expects for the "6.5-inch display" category.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Resize all iPhone screenshots to App Store requirements&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; ./assets/appstore/iphone/&lt;span class="k"&gt;*&lt;/span&gt;.png&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;sips &lt;span class="nt"&gt;-z&lt;/span&gt; 2778 1284 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sips&lt;/code&gt; is macOS's built-in image processing tool. The &lt;code&gt;-z&lt;/code&gt; flag resizes to exact dimensions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: iPad Screenshots - The Stretching Disaster
&lt;/h2&gt;

&lt;p&gt;"Easy," I thought. "Just resize the phone screenshots for iPad."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# DON'T DO THIS&lt;/span&gt;
sips &lt;span class="nt"&gt;-z&lt;/span&gt; 2732 2048 phone-screenshot.png &lt;span class="nt"&gt;--out&lt;/span&gt; ipad-screenshot.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result looked like someone grabbed my UI and pulled it sideways. Buttons were ovals. Text was bloated. Everything was wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The actual fix:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Boot an actual iPad simulator and take native screenshots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Start the iPad simulator&lt;/span&gt;
xcrun simctl boot &lt;span class="s2"&gt;"iPad Pro 13-inch (M4)"&lt;/span&gt;

&lt;span class="c"&gt;# Build and run on iPad&lt;/span&gt;
npx expo run:ios &lt;span class="nt"&gt;--device&lt;/span&gt; &lt;span class="s2"&gt;"iPad Pro 13-inch (M4)"&lt;/span&gt;

&lt;span class="c"&gt;# Take screenshots at native resolution&lt;/span&gt;
xcrun simctl io booted screenshot ./assets/appstore/ipad/screenshot01.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Phone and tablet are different form factors. The UI adapts. Resizing just stretches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 3: The Screenshot Content Problem
&lt;/h2&gt;

&lt;p&gt;Now I had the right dimensions. But my app requires login. Screenshots of a login screen aren't compelling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The solution: Onboarding carousel with demo data.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I created a branch (&lt;code&gt;ft/screenshots&lt;/code&gt;) with:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An &lt;code&gt;OnboardingCarousel&lt;/code&gt; component showing app features&lt;/li&gt;
&lt;li&gt;Hardcoded demo data (fake URLs, fake analytics)&lt;/li&gt;
&lt;li&gt;A flag to show this instead of the login screen
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Check if we're in "demo mode" for screenshots&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;isDemoMode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OnboardingCarousel&lt;/span&gt; &lt;span class="p"&gt;/&amp;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 carousel showed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Shorten Any URL" with a mock input and result&lt;/li&gt;
&lt;li&gt;"Track Every Click" with demo analytics charts&lt;/li&gt;
&lt;li&gt;"Share From Anywhere" showing the iOS share sheet integration&lt;/li&gt;
&lt;li&gt;"Generate QR Codes" with a sample QR code&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Took screenshots of each carousel page. Stashed the changes. Real users never see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 4: Export Compliance - The France Question
&lt;/h2&gt;

&lt;p&gt;Uploaded screenshots. Hit submit. New blocker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Missing Compliance: Export Compliance Information Required
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The form asks about encryption. My app uses HTTPS. Does that count?&lt;/p&gt;

&lt;p&gt;Then it asks specifically about France:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Does your app qualify for any exemptions provided under category 5 part 2?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The options mention DES, triple-DES, RC4... algorithms I'm not using.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The correct answer:&lt;/strong&gt; "None of the algorithms mentioned above"&lt;/p&gt;

&lt;p&gt;If your app only uses HTTPS (TLS) through iOS's built-in networking, you select that it uses encryption, but then confirm you're only using standard iOS APIs. No custom cryptographic implementations = no export restrictions beyond what Apple already handles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 5: Privacy Declarations
&lt;/h2&gt;

&lt;p&gt;App Store Connect wants to know every piece of data your app collects. For each data type, you specify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is it linked to the user's identity?&lt;/li&gt;
&lt;li&gt;Is it used for tracking?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My app uses Auth0 and Sentry. Here's what I declared:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auth0:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Email Address: Linked to identity, not for tracking&lt;/li&gt;
&lt;li&gt;Name: Linked to identity, not for tracking&lt;/li&gt;
&lt;li&gt;User ID: Linked to identity, not for tracking&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Sentry (crash reporting):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Crash Data: Not linked to identity, not for tracking&lt;/li&gt;
&lt;li&gt;Performance Data: Not linked to identity, not for tracking&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The gotcha:&lt;/strong&gt; If you use analytics, you need to declare it. If you use third-party login, you're collecting identity data. Be honest - Apple reviews this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Complete Screenshot Workflow
&lt;/h2&gt;

&lt;p&gt;For anyone doing this in the future:&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Capture iPhone Screenshots
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Boot iPhone 15 Pro Max (or similar 6.5" device)&lt;/span&gt;
xcrun simctl boot &lt;span class="s2"&gt;"iPhone 15 Pro Max"&lt;/span&gt;

&lt;span class="c"&gt;# Run your app&lt;/span&gt;
npx expo run:ios &lt;span class="nt"&gt;--device&lt;/span&gt; &lt;span class="s2"&gt;"iPhone 15 Pro Max"&lt;/span&gt;

&lt;span class="c"&gt;# Navigate to each screen and capture&lt;/span&gt;
xcrun simctl io booted screenshot ./assets/appstore/iphone/screenshot01.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Resize to App Store Dimensions
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# iPhone 6.5" requires 1284 x 2778&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; ./assets/appstore/iphone/&lt;span class="k"&gt;*&lt;/span&gt;.png&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;sips &lt;span class="nt"&gt;-z&lt;/span&gt; 2778 1284 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Capture iPad Screenshots Separately
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Boot iPad Pro 13"&lt;/span&gt;
xcrun simctl boot &lt;span class="s2"&gt;"iPad Pro 13-inch (M4)"&lt;/span&gt;

&lt;span class="c"&gt;# Run your app on iPad&lt;/span&gt;
npx expo run:ios &lt;span class="nt"&gt;--device&lt;/span&gt; &lt;span class="s2"&gt;"iPad Pro 13-inch (M4)"&lt;/span&gt;

&lt;span class="c"&gt;# Capture at native resolution (2048 x 2732)&lt;/span&gt;
xcrun simctl io booted screenshot ./assets/appstore/ipad/screenshot01.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4: Compliance Declarations
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Export Compliance: Standard iOS HTTPS = no additional export requirements&lt;/li&gt;
&lt;li&gt;Privacy: Declare Auth0 data as identity-linked, crash reporting as not linked&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Don't resize across form factors.&lt;/strong&gt; Phone screenshots stretched to iPad dimensions look terrible. Capture natively on each device type.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Create demo content for screenshots.&lt;/strong&gt; Login screens don't sell apps. Build an onboarding flow or demo mode, capture screenshots, then remove it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Export compliance isn't scary.&lt;/strong&gt; If you're using standard iOS networking (URLSession, Alamofire, etc.), you're just using Apple's TLS implementation. Select the exemption options.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Privacy declarations require honesty.&lt;/strong&gt; List every SDK that touches user data. Auth0, Sentry, analytics - all of it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;sips&lt;/code&gt; is your friend.&lt;/strong&gt; Built into macOS, handles resizing without installing ImageMagick or Photoshop.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;strong&gt;Submitting your first iOS app?&lt;/strong&gt; What unexpected blockers did you hit? Drop them in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; - URL shortener with a mobile app that survived App Store review.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ios</category>
      <category>mobile</category>
      <category>reactnative</category>
      <category>appstore</category>
    </item>
    <item>
      <title>One Push, Two App Stores: Parallel iOS and Android Builds with GitHub Actions</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Thu, 14 May 2026 01:39:58 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/one-push-two-app-stores-parallel-ios-and-android-builds-with-github-actions-36gh</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/one-push-two-app-stores-parallel-ios-and-android-builds-with-github-actions-36gh</guid>
      <description>&lt;p&gt;Liquid syntax error: Unknown tag 'endraw'&lt;/p&gt;
</description>
      <category>githubactions</category>
      <category>devops</category>
      <category>mobile</category>
      <category>cicd</category>
    </item>
  </channel>
</rss>
