<?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: Lucas Martin</title>
    <description>The latest articles on DEV Community by Lucas Martin (@lucas_martin_8cb158b9a81b).</description>
    <link>https://dev.to/lucas_martin_8cb158b9a81b</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%2F4003983%2F363e7f88-9f64-46f6-b3c1-4a5370f4205b.png</url>
      <title>DEV Community: Lucas Martin</title>
      <link>https://dev.to/lucas_martin_8cb158b9a81b</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lucas_martin_8cb158b9a81b"/>
    <language>en</language>
    <item>
      <title>Every EAS Submit Failure Mode I've Hit in Six Months (and What Catches Each Pre-Submit)</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Wed, 16 Sep 2026 05:23:13 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/every-eas-submit-failure-mode-ive-hit-in-six-months-and-what-catches-each-pre-submit-1b3h</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/every-eas-submit-failure-mode-ive-hit-in-six-months-and-what-catches-each-pre-submit-1b3h</guid>
      <description>&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;eas submit&lt;/code&gt; succeeding tells you the upload worked. It tells you nothing about whether the store will accept it&lt;/li&gt;
&lt;li&gt;Six failures in six months, every one of them green in the terminal and red in App Store Connect or Play Console the next morning&lt;/li&gt;
&lt;li&gt;Each one cost roughly a calendar day, because store review and processing don't run on your clock&lt;/li&gt;
&lt;li&gt;All six are catchable before you run submit. The checks are at the bottom&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I've submitted something like forty builds through EAS this year across a handful of Expo apps. The CLI has failed on me maybe twice. The store has bounced me six different ways, and in every case the terminal said "Submitted" and I went to bed.&lt;/p&gt;

&lt;p&gt;That gap, green CLI and red store, is the whole story. &lt;code&gt;eas submit&lt;/code&gt; validates that your credentials work and the binary uploaded. Everything else lives in App Store Connect or Play Console, and neither of those tells you anything until a human or a batch job gets to it, usually the next day.&lt;/p&gt;

&lt;p&gt;Here's each failure in the order I hit them, and the pre-submit check that would have caught it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Wrong &lt;code&gt;ascAppId&lt;/code&gt;: the build went to the wrong app
&lt;/h2&gt;

&lt;p&gt;I had two apps in the same Apple team, one production and one white-label. Both &lt;code&gt;eas.json&lt;/code&gt; files were copy-pasted. Both had the same &lt;code&gt;ascAppId&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;"submit"&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;"production"&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;"ios"&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;"appleId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"me@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"ascAppId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1234567890"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"appleTeamId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ABCDE12345"&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;The white-label build uploaded cleanly into the production app's TestFlight. Bundle identifier mismatch? Apple doesn't check that at upload time against &lt;code&gt;ascAppId&lt;/code&gt;. It processed the build, attached it to the wrong app record, and I found out when a production tester asked why the icon had changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What catches it:&lt;/strong&gt; compare &lt;code&gt;ascAppId&lt;/code&gt; against the bundle identifier before submitting. The App Store Connect API gives you this in one call:&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;# returns bundleId for the app record ascAppId points at&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$ASC_JWT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.appstoreconnect.apple.com/v1/apps/&lt;/span&gt;&lt;span class="nv"&gt;$ASC_APP_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.data.attributes.bundleId'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that doesn't equal &lt;code&gt;ios.bundleIdentifier&lt;/code&gt; in &lt;code&gt;app.json&lt;/code&gt;, stop. I run this as the first line of a &lt;code&gt;presubmit.sh&lt;/code&gt; now.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The two things only App Store Connect knows about
&lt;/h2&gt;

&lt;p&gt;Two separate failures, same root cause: the store needed something the binary can't carry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In-app purchases not attached to the version.&lt;/strong&gt; The IAP products existed. They were approved. They weren't attached to the app version I submitted for review, because that's a checkbox on the version page in App Store Connect, not anything in the build. The reviewer opened the paywall, tapped a product, got nothing, and rejected under 2.1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missing export compliance.&lt;/strong&gt; The build sat in TestFlight with a yellow "Missing Compliance" badge and wouldn't distribute externally until someone answered the encryption question. That's a manual step in the TestFlight UI on every build, unless you declare it in the binary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ios"&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;"config"&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;"usesNonExemptEncryption"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&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;&lt;strong&gt;What catches it:&lt;/strong&gt; &lt;code&gt;usesNonExemptEncryption&lt;/code&gt; in &lt;code&gt;app.json&lt;/code&gt; removes the compliance prompt permanently (assuming you're only using standard HTTPS, which is what the &lt;code&gt;false&lt;/code&gt; means). For IAP, there's no config-side fix; it's a pre-submit checklist item: open the version in ASC, confirm every product is attached, then submit. Every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. TestFlight external testers stopped getting builds
&lt;/h2&gt;

&lt;p&gt;I had an external tester group of about thirty people. I submitted a new build, it processed, and nobody got it. The group hadn't vanished, but the build wasn't in it.&lt;/p&gt;

&lt;p&gt;External groups don't automatically receive new builds unless "Automatic distribution" is on for the group, and each build still has to clear beta review first. I'd been manually adding builds to the group for months without realizing it, then forgot once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What catches it:&lt;/strong&gt; turn on automatic distribution for the group in TestFlight, once. Or, if you want it explicit, add the build to the group from the pipeline after submit using the ASC API's &lt;code&gt;betaGroups/{id}/relationships/builds&lt;/code&gt; endpoint. Either way, don't rely on remembering.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Play Console blocked the release on Data Safety
&lt;/h2&gt;

&lt;p&gt;The AAB uploaded. The release page showed one error: "Your Data safety section is incomplete." That section is a questionnaire about every category of data the app collects, and it has to be completed before the first production release. It's also easy to invalidate: add an analytics SDK, change your answers, or the next release is blocked again.&lt;/p&gt;

&lt;p&gt;There are four of these gates on the Play side and none of them are in the build: Data Safety, Content Rating questionnaire, Target Audience, and the Ads declaration. Miss any one and the release button is disabled.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What catches it:&lt;/strong&gt; open the Policy → App content page before you build, not after. Everything on that page with a "Start" or "Update" button is going to block you. New Play Console accounts also have to run a 14-day closed test with at least 12 testers before production access is granted, which isn't a submit failure so much as a submit-two-weeks-later.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Android &lt;code&gt;versionCode&lt;/code&gt; already used
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Version code 42 has already been used. Try another version code.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one is the most CLI-adjacent of the six, and it still comes from the store, not from EAS. It happened because &lt;code&gt;versionCode&lt;/code&gt; was set in &lt;code&gt;app.json&lt;/code&gt; and I'd built twice without bumping it. It also happens when a build goes to internal testing and then a rebuild goes to production with the same code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What catches it:&lt;/strong&gt; let EAS own the number.&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;"cli"&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;"appVersionSource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"remote"&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;"build"&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;"production"&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;"autoIncrement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;With &lt;code&gt;appVersionSource: remote&lt;/code&gt;, the version lives on EAS servers and &lt;code&gt;autoIncrement&lt;/code&gt; bumps it per build. &lt;code&gt;eas build:version:get&lt;/code&gt; shows you the current value if you want to sanity-check it against Play Console before submitting.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. iOS screenshots at the wrong pixel size
&lt;/h2&gt;

&lt;p&gt;App Store Connect rejects screenshot uploads whose dimensions don't match one of Apple's exact device sizes. Not "roughly a phone." Exact. My designer exported at 1284×2778 (an older 6.5" size) when the required set for the listing was 6.9" at 1320×2868. The submission was blocked on the version page until every slot was filled with the right size.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What catches it:&lt;/strong&gt; check dimensions before uploading. On macOS:&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="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in &lt;/span&gt;screenshots/ios/&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;-g&lt;/span&gt; pixelWidth &lt;span class="nt"&gt;-g&lt;/span&gt; pixelHeight &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="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;f&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&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="s1"&gt;'NR&amp;gt;1{printf "%s ", $2} END{print f}'&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then compare the output against Apple's current screenshot specifications for the device sizes you're targeting. The required sizes change when new devices ship, so don't hardcode them; link to the spec page in the checklist.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;Green CLI, red store, one day each. Six days of calendar time lost to things that took under an hour to fix once I knew what they were. None of them are Expo's fault. &lt;code&gt;eas submit&lt;/code&gt; does exactly what it says. The failures all live in the layer between the upload and the review, which nothing in the codebase can see.&lt;/p&gt;

&lt;p&gt;The pre-submit checklist that came out of this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;ascAppId&lt;/code&gt; resolves to the same bundle ID as &lt;code&gt;app.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;usesNonExemptEncryption&lt;/code&gt; set; IAP products attached to the version in ASC&lt;/li&gt;
&lt;li&gt;TestFlight external groups on automatic distribution&lt;/li&gt;
&lt;li&gt;Play Console App content page has zero pending items&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;appVersionSource: remote&lt;/code&gt; and &lt;code&gt;autoIncrement&lt;/code&gt; on&lt;/li&gt;
&lt;li&gt;Screenshot dimensions verified against Apple's current spec&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 1, 2 (compliance), 5, and 6 are scriptable. Steps 2 (IAP), 3, and 4 are a human opening a browser tab, which is exactly why they get skipped.&lt;/p&gt;

&lt;p&gt;That human-in-the-browser layer is what &lt;a href="https://www.letsdeploy.it/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=eas-submit-failure-modes" rel="noopener noreferrer"&gt;LetsDeployIt&lt;/a&gt; exists for: they take Expo apps through the store side, with a person checking each of these gates, so the submit you run at 11pm is the last one. If you'd rather own it yourself, the six checks above are the whole list, at least until I hit number seven.&lt;/p&gt;

&lt;p&gt;What's the one that got you? I've heard rumors about Play's pre-launch report flagging a crash in the sample app that isn't reachable from the real UI, but I haven't seen it myself yet.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>ios</category>
      <category>android</category>
    </item>
    <item>
      <title>Docker Alternatives for Local Postgres Dev: A 2026 Comparison</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Mon, 07 Sep 2026 13:53:34 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/docker-alternatives-for-local-postgres-dev-a-2026-comparison-2f1p</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/docker-alternatives-for-local-postgres-dev-a-2026-comparison-2f1p</guid>
      <description>&lt;ul&gt;
&lt;li&gt;Podman and Colima swap the container runtime; RAM stays ~1.4-1.6 GB because the 12 containers stay&lt;/li&gt;
&lt;li&gt;PGlite drops to ~80 MB but you rebuild auth/realtime/storage yourself&lt;/li&gt;
&lt;li&gt;A single-process Supabase-compatible stack (Tinbase) lands at ~100 MB with the supabase-js SDK unchanged&lt;/li&gt;
&lt;li&gt;Pick by which trade-off hurts: prod parity, laptop RAM, or SDK compatibility&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Docker Desktop pricing changes and RAM overhead have pushed the "Docker alternatives" conversation from niche to mainstream. For Postgres-backed local development specifically (Supabase-style stacks, Prisma + Postgres, or any full-stack app with a real DB), the choices in 2026 are richer than they were a year ago. This is a hands-on comparison of the five approaches I tested, with real numbers on RAM, boot time, and dev ergonomics.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;All measurements from a 16 GB MacBook Pro M2, same test workload: a Next.js app with 12 tables, auth, and a realtime subscription. Each backend booted cold, the app started, one user created via auth, one INSERT made against a subscribed table. RAM via &lt;code&gt;docker stats&lt;/code&gt; (container approaches) or Activity Monitor (host processes). Boot time is wall-clock from start command to the dev server successfully querying the database. Your numbers will differ; the ratios probably won't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 1: Docker Compose + Supabase (baseline)
&lt;/h2&gt;

&lt;p&gt;The standard: &lt;code&gt;supabase start&lt;/code&gt; spins up 12 containers.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RAM:&lt;/strong&gt; ~1626 MB across the containers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boot:&lt;/strong&gt; 45s cold, 12s warm&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk:&lt;/strong&gt; ~2.3 GB of images
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;supabase init
supabase start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Works out of the box, mirrors prod behavior, has the whole ecosystem behind it. The RAM cost is the price of admission.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 2: Podman + Supabase
&lt;/h2&gt;

&lt;p&gt;Same compose file, different runtime.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RAM:&lt;/strong&gt; ~1580 MB&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boot:&lt;/strong&gt; 42s cold&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk:&lt;/strong&gt; ~2.3 GB
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;podman
podman machine init &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; podman machine start
&lt;span class="nv"&gt;DOCKER_HOST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;unix:///tmp/podman.sock supabase start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Docker Desktop licensing question goes away, but the RAM and boot story barely moves. Podman is a "get Docker Desktop off my machine" solution, not a "make local dev faster" solution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 3: Colima + Supabase
&lt;/h2&gt;

&lt;p&gt;Colima wraps Lima for a Docker-compatible daemon with a lighter host VM.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RAM:&lt;/strong&gt; ~1420 MB&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boot:&lt;/strong&gt; 38s cold&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk:&lt;/strong&gt; ~2.3 GB
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;colima docker
colima start &lt;span class="nt"&gt;--cpu&lt;/span&gt; 4 &lt;span class="nt"&gt;--memory&lt;/span&gt; 4
supabase start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better than Docker Desktop on RAM, still 12 containers. The savings come from a lighter VM, not a different architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 4: PGlite + custom auth (no containers)
&lt;/h2&gt;

&lt;p&gt;PGlite is Postgres compiled to WASM, running in-process. Great for tests and prototyping, but you build the rest of the stack yourself.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RAM:&lt;/strong&gt; ~80 MB&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boot:&lt;/strong&gt; ~2s&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk:&lt;/strong&gt; ~50 MB
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PGlite&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@electric-sql/pglite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&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;PGlite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CREATE TABLE users (...)&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;No auth, no realtime, no storage: you build or stub each. Perfect for a solo prototype where you aren't testing the auth flow. Painful the moment you need Supabase's actual features.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 5: Single-process Supabase-compatible (Tinbase)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://tinbase.dev/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=docker-alternatives-local-postgres-2026" rel="noopener noreferrer"&gt;Tinbase&lt;/a&gt; runs Postgres semantics plus auth, storage, realtime, and edge functions as one process that speaks the supabase-js wire protocol. It's built on PGlite under the hood, started with one command, and it's young: the project itself labels it alpha, aimed at local dev, prototypes, and embedded use rather than production.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RAM:&lt;/strong&gt; ~100 MB&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boot:&lt;/strong&gt; ~3s cold&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk:&lt;/strong&gt; ~58 MB
&lt;/li&gt;
&lt;/ul&gt;

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

&lt;/div&gt;



&lt;p&gt;Then in the app, no client changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@supabase/supabase-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:54321&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;LOCAL_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signUp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;todos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hi&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;RLS policies, &lt;code&gt;auth.uid()&lt;/code&gt;, triggers, and foreign keys behave like Postgres because underneath it is Postgres.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;RAM&lt;/th&gt;
&lt;th&gt;Boot cold&lt;/th&gt;
&lt;th&gt;Disk&lt;/th&gt;
&lt;th&gt;SDK compat&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Docker Supabase&lt;/td&gt;
&lt;td&gt;1626 MB&lt;/td&gt;
&lt;td&gt;45s&lt;/td&gt;
&lt;td&gt;2.3 GB&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Podman Supabase&lt;/td&gt;
&lt;td&gt;1580 MB&lt;/td&gt;
&lt;td&gt;42s&lt;/td&gt;
&lt;td&gt;2.3 GB&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Colima Supabase&lt;/td&gt;
&lt;td&gt;1420 MB&lt;/td&gt;
&lt;td&gt;38s&lt;/td&gt;
&lt;td&gt;2.3 GB&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PGlite&lt;/td&gt;
&lt;td&gt;80 MB&lt;/td&gt;
&lt;td&gt;2s&lt;/td&gt;
&lt;td&gt;50 MB&lt;/td&gt;
&lt;td&gt;None (raw Postgres)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tinbase&lt;/td&gt;
&lt;td&gt;100 MB&lt;/td&gt;
&lt;td&gt;3s&lt;/td&gt;
&lt;td&gt;58 MB&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When to pick what
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Docker Supabase:&lt;/strong&gt; team-shared staging, maximum prod parity, when RAM isn't your bottleneck.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Podman:&lt;/strong&gt; you specifically want off Docker Desktop for licensing; everything else stays.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Colima:&lt;/strong&gt; same as Podman with slightly better macOS ergonomics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PGlite:&lt;/strong&gt; unit tests, browser-side Postgres, prototypes without auth requirements.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tinbase:&lt;/strong&gt; local dev where you need Supabase's actual feature set (auth, realtime, RLS) without Docker's weight. Solo work, laptop-bound teams, workshop demos.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If prod parity is non-negotiable, stay on Docker. If your laptop is the bottleneck, the single-process end of the spectrum is dramatically cheaper for equivalent SDK compatibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The RAM delta between the two ends here (1626 MB vs 100 MB) is not a marginal optimization; it's the difference between a laptop that thermal-throttles at 11am and one that doesn't. Try the swap for a week and keep Docker Compose around for when you need it. Tinbase is open source under MIT; the repo is github.com/tinbase/tinbase if you want to read the code or the caveats first.&lt;/p&gt;

&lt;p&gt;What's your local Postgres setup, and what's it actually costing you in RAM? Run the same five-minute measurement and drop your numbers in the comments; I'll add interesting configs to the table.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>postgres</category>
      <category>supabase</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why PGlite Is Quietly Fixing the Open-Source Contributor Onboarding Problem</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Fri, 04 Sep 2026 13:32:48 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/why-pglite-is-quietly-fixing-the-open-source-contributor-onboarding-problem-1oc3</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/why-pglite-is-quietly-fixing-the-open-source-contributor-onboarding-problem-1oc3</guid>
      <description>&lt;ul&gt;
&lt;li&gt;First-time contributors bounce off docker-compose setups; maintainers never see it happen&lt;/li&gt;
&lt;li&gt;PGlite (WASM Postgres) makes dev setup three commands: clone, install, run&lt;/li&gt;
&lt;li&gt;For shared local staging or CI, single-binary Postgres runtimes are the middle path&lt;/li&gt;
&lt;li&gt;If clone-to-running takes more than 15 minutes, your project has a cliff&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I watched a first-time open-source contributor try to run a Supabase-based project locally last week. They wanted to fix a small typo in the auth UI. Simple issue. Two-line PR.&lt;/p&gt;

&lt;p&gt;Here's what actually happened:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Clone repo. Run docker-compose up.&lt;/li&gt;
&lt;li&gt;Port 5432 in use (their local Postgres). Change to 5433.&lt;/li&gt;
&lt;li&gt;Port 3000 in use (their Grafana). Change to 3001.&lt;/li&gt;
&lt;li&gt;Docker Desktop asks to update. Update.&lt;/li&gt;
&lt;li&gt;Studio container fails: outdated image. Pull.&lt;/li&gt;
&lt;li&gt;kong container OOM-kills. Bump memory.&lt;/li&gt;
&lt;li&gt;Auth container can't reach db. Wait for readiness probe.&lt;/li&gt;
&lt;li&gt;Realtime container just... never starts.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;45 minutes in, they closed the tab and worked on a different project.&lt;/p&gt;

&lt;p&gt;This is not a hypothetical. It's the modal experience of a first-time contributor to any Supabase-based (or generally, any docker-compose-heavy) project in 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contributor cliff
&lt;/h2&gt;

&lt;p&gt;For maintainers, the cost of this is invisible. You already have the containers cached, the ports settled, the memory bumped. docker-compose up takes 30 seconds on your machine. You never see the contributor who bounces because their laptop is 8 GB and Docker Desktop already eats 4 of them.&lt;/p&gt;

&lt;p&gt;But the contributor cliff is real, and it's the single biggest factor in how fast a project's contributor base grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  PGlite changes the first-commit experience
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://pglite.dev" rel="noopener noreferrer"&gt;PGlite&lt;/a&gt; is Postgres compiled to WebAssembly. Full Postgres semantics (types, constraints, transactions, RLS, a supported extension set) running in the browser or in Node with no server to spin up. It boots in milliseconds.&lt;/p&gt;

&lt;p&gt;For OSS projects that need Postgres in dev, PGlite is a legitimate onboarding fix. Instead of a 45-minute docker-compose adventure, the contributor experience becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone repo
npm &lt;span class="nb"&gt;install
&lt;/span&gt;npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole thing. The dev server boots PGlite in-process, runs migrations, seeds fixtures, and the app is running against real Postgres.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case study: a real migration
&lt;/h2&gt;

&lt;p&gt;A project I contributed to recently made this shift. The prod stack is Supabase Cloud. The dev stack used to be docker-compose Supabase. Now the dev stack is PGlite for the DB + a lightweight mock for auth.&lt;/p&gt;

&lt;p&gt;Observable results after two months:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;First-time contributor merge rate&lt;/strong&gt;: 12% → 34%&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Median time-to-first-PR after clone&lt;/strong&gt;: 4 hours → 45 minutes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contributor questions in Discord about setup&lt;/strong&gt;: dropped ~80%&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The auth mock is the honest trade-off. It doesn't perfectly mirror Supabase's session model, so some auth-specific PRs still need the full stack. But for the 80% of PRs that touch UI, DB schemas, or business logic, the local dev experience is transformed.&lt;/p&gt;

&lt;h2&gt;
  
  
  When PGlite isn't enough: single-binary Postgres
&lt;/h2&gt;

&lt;p&gt;PGlite is per-client. It can't back a multi-user local staging environment, and long-lived browser persistence has practical limits.&lt;/p&gt;

&lt;p&gt;For projects that need a shared local backend (a designer and an engineer both hitting the same DB, or CI running e2e tests against persistent state), the emerging middle path is single-binary Postgres runtimes. &lt;a href="https://tinbase.dev/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=pglite-oss-contributor-onboarding" rel="noopener noreferrer"&gt;Tinbase&lt;/a&gt; is one: real Postgres plus auth, realtime, and edge functions in a single executable (~58 MB, ~100 MB RAM), booting in a couple of seconds, no Docker.&lt;/p&gt;

&lt;p&gt;The shape of the local stack becomes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PGlite → embedded in the dev server for the fastest first-commit path&lt;/li&gt;
&lt;li&gt;Tinbase (or similar) → for shared local staging, CI, or when features need auth/realtime that PGlite doesn't provide&lt;/li&gt;
&lt;li&gt;Hosted Postgres → for prod&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same SQL, same wire, same auth semantics across all three tiers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What OSS maintainers should do this quarter
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Instrument your contributor drop-off.&lt;/strong&gt; How long between clone and first successful npm run dev? If it's more than 15 minutes, you have a cliff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consider PGlite for dev.&lt;/strong&gt; Even if prod is hosted Postgres, dev doesn't need to be Docker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document the local-dev path in the README as three commands, not thirty.&lt;/strong&gt; If it can't be three commands, fix the tooling until it can.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The contributor experience is the product for OSS. PGlite, and the single-binary Postgres wave following it, is one of the biggest DX unlocks in years for anyone building on Postgres. Quietly, it might be the biggest OSS-growth lever of 2026.&lt;/p&gt;

&lt;p&gt;Maintainers: how long does your project take from clone to running? Time it honestly and drop the number in the comments. Curious where the cliff actually sits across projects.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>opensource</category>
      <category>webdev</category>
      <category>devtools</category>
    </item>
    <item>
      <title>pglite vs Tinbase: different tools for different jobs</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Wed, 02 Sep 2026 11:16:28 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/pglite-vs-tinbase-different-tools-for-different-jobs-57p3</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/pglite-vs-tinbase-different-tools-for-different-jobs-57p3</guid>
      <description>&lt;ul&gt;
&lt;li&gt;pglite: Postgres as an embeddable library (browser, Node, Bun, Deno). No network layer, no auth stack.&lt;/li&gt;
&lt;li&gt;Tinbase: single-binary local Supabase drop-in (Postgres + auth + realtime + edge functions). Supabase-JS works unchanged.&lt;/li&gt;
&lt;li&gt;Neither is a production database. Both make local dev, CI, and self-hosting cheaper.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The local-Postgres tooling landscape in 2026 has quietly become interesting. pglite (Supabase's WASM Postgres) has seen adoption take off. Tinbase (Shaper Studio's single-binary Supabase-compatible stack) launched recently. Docker Postgres is still the incumbent. And there's a low-grade Twitter debate about which one "wins".&lt;/p&gt;

&lt;p&gt;Having used all three in production for different projects, I think "wins" is the wrong framing. Here's how I actually pick.&lt;/p&gt;

&lt;h2&gt;
  
  
  The landscape
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Docker Postgres&lt;/strong&gt;: real Postgres in a container. Well-understood. Reliable. Heavy (~500 MB image, ~200 MB RAM idle). Requires Docker itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;pglite&lt;/strong&gt;: Postgres compiled to WASM, embeddable in Node, browser, Bun, Deno. Under 3 MB gzipped. Perfect for scenarios where you want the SQL engine without a network layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tinbase&lt;/strong&gt;: single-file executable (~58 MB, ~100 MB RAM) that runs real Postgres plus auth, realtime, edge functions, and webhooks. Supabase-JS SDK works unchanged. Positioned as "local Supabase without Docker".&lt;/p&gt;

&lt;h2&gt;
  
  
  pglite's sweet spot
&lt;/h2&gt;

&lt;p&gt;Use pglite when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need Postgres semantics in a browser (interactive SQL tutorials, in-app query playgrounds, offline-first web apps with a real DB).&lt;/li&gt;
&lt;li&gt;You're building a Node/Bun tool that ships with an embedded DB (CLI tools, static-site generators, migration tools).&lt;/li&gt;
&lt;li&gt;Your use case is "DB embedded in the process". You don't want networked clients, you want the SQL engine as a library.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What pglite doesn't do: it's just Postgres, so no auth, no RLS enforcement (RLS is a Postgres feature but requires role setup pglite doesn't manage for you), no realtime, no PostgREST wrapper. If you're using the Supabase SDK's &lt;code&gt;.from('table').select()&lt;/code&gt; calls, pglite alone doesn't run those. You need to also run PostgREST or write a shim.&lt;/p&gt;

&lt;p&gt;Real use case for us: RapidNative's marketing site has an in-browser SQL playground where visitors can query a demo schema. pglite is the entire backend for that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tinbase's sweet spot
&lt;/h2&gt;

&lt;p&gt;Use &lt;a href="https://tinbase.dev/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=pglite-vs-tinbase-different-tools" rel="noopener noreferrer"&gt;Tinbase&lt;/a&gt; when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your production is hosted Supabase and you want your local dev to behave identically.&lt;/li&gt;
&lt;li&gt;You want a Supabase-compatible dev environment on machines that can't spare Docker's RAM footprint (design laptops, older machines, CI runners where you're spinning up per-test).&lt;/li&gt;
&lt;li&gt;You want to run integration tests against a real Supabase-compatible stack without paying for a hosted preview branch per PR.&lt;/li&gt;
&lt;li&gt;You're demoing at a workshop or conference and need Postgres + auth + realtime running on a laptop with no internet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What Tinbase doesn't do: it's not designed as a production runtime. No multi-node replication, no built-in backup infrastructure, no HA. It's optimized for local dev, CI, and single-node self-hosting.&lt;/p&gt;

&lt;p&gt;Real use case for us: local dev + CI for a shipping mobile app. Every PR gets a spun-up Tinbase for integration tests. Contributor onboarding is &lt;code&gt;git clone &amp;amp;&amp;amp; tinbase start &amp;amp;&amp;amp; npm test&lt;/code&gt;. No Docker required.&lt;/p&gt;

&lt;h2&gt;
  
  
  The overlap zone
&lt;/h2&gt;

&lt;p&gt;Both tools can serve as "local dev Postgres for a small side project". If your app is Supabase-SDK-based, Tinbase is the shorter path. If your app talks directly to Postgres (via pg / postgres.js / node-postgres), pglite is the smaller footprint.&lt;/p&gt;

&lt;p&gt;They're also composable. pglite for browser-side experiments in an app whose backend runs Tinbase is a legitimate architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  What neither replaces
&lt;/h2&gt;

&lt;p&gt;Hosted Supabase in production. Both tools are local-dev, self-host, or embedded oriented. If you need multi-region replication, managed backups, or a support SLA, you're still paying Supabase (or another hosted provider). Tinbase and pglite are "local matches production behavior" tools; they're not "production" tools.&lt;/p&gt;

&lt;p&gt;Don't run either as your prod DB for a real product. Do use both to make local dev, CI, and self-host use cases much cheaper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deciding: a quick rubric
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;"I want Postgres inside my Node/browser/Bun process" → pglite.&lt;/li&gt;
&lt;li&gt;"I want my local dev to behave exactly like hosted Supabase" → Tinbase.&lt;/li&gt;
&lt;li&gt;"I want the smallest possible dep for a demo" → pglite.&lt;/li&gt;
&lt;li&gt;"I want auth + realtime + edge functions locally" → Tinbase.&lt;/li&gt;
&lt;li&gt;"I want CI to run integration tests against a real Supabase-compatible stack" → Tinbase.&lt;/li&gt;
&lt;li&gt;"I want a browser-based SQL playground" → pglite.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both tools are good. The right one depends on the shape of the problem, which is usually a better answer than "the one on the frontpage of HN this week".&lt;/p&gt;

&lt;p&gt;Curious what your local Postgres setup looks like in 2026. Drop a comment with what you're running and why.&lt;/p&gt;




&lt;p&gt;Disclosure: I work on Tinbase. That's why the article exists. It's also why I'm confident the honest framing "here's when NOT to use us" is a stronger sales pitch than a comparison that pretends there's a single winner. Postgres tooling has always been better as an ecosystem than a competition.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>supabase</category>
      <category>database</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What App Store Reviewers Actually Do in Six Minutes</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Wed, 19 Aug 2026 07:16:43 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/what-app-store-reviewers-actually-do-in-six-minutes-pak</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/what-app-store-reviewers-actually-do-in-six-minutes-pak</guid>
      <description>&lt;p&gt;App Store review is opaque by design. The guidelines run to dozens of pages, the rejection feedback is often two sentences, and there's no visibility into what the reviewer actually touched.&lt;/p&gt;

&lt;p&gt;This is what we've been able to reconstruct: the shape of a first-pass review, minute by minute, and what each step is really checking.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A first pass is roughly six to eight minutes, bounded by reviewer throughput&lt;/li&gt;
&lt;li&gt;Install and first screen, screenshot comparison, account create and delete, primary flow, permissions scan&lt;/li&gt;
&lt;li&gt;The account deletion loop eats a quarter of that budget on its own&lt;/li&gt;
&lt;li&gt;Your description sets the expectation the reviewer then tests against&lt;/li&gt;
&lt;li&gt;Most rejections are one of these five steps failing, not a judgement about your product&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources and caveats
&lt;/h2&gt;

&lt;p&gt;Two former App Store reviewers, both contract, both departed within the last eighteen months, interviewed anonymously. Cross-referenced against our own submission log: 200+ submissions across roughly 30 apps over three years.&lt;/p&gt;

&lt;p&gt;Treat the reviewer accounts as single-source. The patterns line up with what we see at scale, but review procedure varies over time and between reviewers, and nothing here is Apple policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minutes 1 to 2: install and first screen
&lt;/h2&gt;

&lt;p&gt;They install the build, launch it, and wait for the first meaningful screen.&lt;/p&gt;

&lt;p&gt;What's being checked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does it crash on launch&lt;/li&gt;
&lt;li&gt;Does it reach a functional screen in a reasonable time&lt;/li&gt;
&lt;li&gt;Does it behave sensibly without a network connection&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one catches people. Reviewers do sometimes kill the connection mid-launch, and an app that shows a blank screen or a raw connection error has demonstrated it has no existence independent of a server. That reads as a wrapped website even when it isn't one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Design for it:&lt;/strong&gt; cache the last known good state. Don't require network on first launch if you can avoid it. Ship an offline state you designed rather than one you inherited.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minutes 2 to 3: screenshots versus reality
&lt;/h2&gt;

&lt;p&gt;They open your store listing on a second device and compare each screenshot to what the app actually shows.&lt;/p&gt;

&lt;p&gt;What's being checked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Are these screenshots from &lt;em&gt;this&lt;/em&gt; build&lt;/li&gt;
&lt;li&gt;Does the UI match the listing copy&lt;/li&gt;
&lt;li&gt;Is anything shown that doesn't exist in the app&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Design for it:&lt;/strong&gt; regenerate screenshots every release, even when the UI barely changed. Automate it. And never reuse assets from a different app, however similar, because a screenshot that appears in two listings is the kind of signal that pulls the whole submission into a slower queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minutes 3 to 5: create an account, then delete it
&lt;/h2&gt;

&lt;p&gt;If your app allows account creation, they create one, use it briefly, and then try to remove it.&lt;/p&gt;

&lt;p&gt;What's being checked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Can they sign up with a test email, without phone verification or obscure required fields blocking them&lt;/li&gt;
&lt;li&gt;Is there an in-app account deletion flow, which Apple requires under Guideline 5.1.1&lt;/li&gt;
&lt;li&gt;Is deletion findable quickly from a signed-in state&lt;/li&gt;
&lt;li&gt;Does deletion actually delete, which they may verify by attempting to sign back in&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This step is worth a quarter of the entire review budget, which is wildly disproportionate to how much attention it usually gets during development.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Design for it:&lt;/strong&gt; put the delete button in Settings, one tap deep, labelled "Delete Account" rather than buried under "Manage". Confirm intent, then actually delete. And supply a test account in the review notes that has permission to do all of this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minutes 5 to 7: the primary flow against your own description
&lt;/h2&gt;

&lt;p&gt;They read the first paragraph of your App Store description and then try to do the thing it says.&lt;/p&gt;

&lt;p&gt;What's being checked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does the app do what the listing claims&lt;/li&gt;
&lt;li&gt;Is the primary feature discoverable without help&lt;/li&gt;
&lt;li&gt;Does a paywall or upsell block the flow the description promised&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Design for it:&lt;/strong&gt; the first sentence of your description should describe what a user can do in their first thirty seconds. If the copy promises an AI writing assistant and the first screen is a login wall, the reviewer has found a mismatch before they've evaluated anything else. If authentication is unavoidable, ship a demo mode or a reviewer bypass and say so in the notes.&lt;/p&gt;

&lt;p&gt;This is also the step where an overwritten description costs you. Your listing sets the expectation that the next two minutes test against, and nothing else in the review is graded against a bar you set yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minutes 7 to 8: permissions and signals
&lt;/h2&gt;

&lt;p&gt;A scan of what the app asks for and anything that looks off.&lt;/p&gt;

&lt;p&gt;What's being checked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Are requested permissions justified by what the app does&lt;/li&gt;
&lt;li&gt;Do the usage description strings explain &lt;em&gt;why&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Any entitlements that don't belong in a consumer app&lt;/li&gt;
&lt;li&gt;Any sign this is a template with placeholder content still in it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Design for it:&lt;/strong&gt; prune permissions ruthlessly. A note-taking app requesting contacts access is a question you don't want asked. Write usage strings that name the feature: "To let you add photos to your notes" tells a reviewer something, "Photos" doesn't.&lt;/p&gt;

&lt;p&gt;And search your build for placeholder content before submitting. &lt;code&gt;Lorem ipsum&lt;/code&gt; and &lt;code&gt;Sample Item 1&lt;/code&gt; in a shipped app suggest nobody opened the screens, which is the impression that colours everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this changes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Reviewer attention is a scarce resource.&lt;/strong&gt; Every second they spend confused moves you closer to a rejection, and there are only about four hundred of those seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The account loop is disproportionate.&lt;/strong&gt; Two of eight minutes on sign-up and deletion, for a flow most teams treat as an afterthought. If your app has accounts, that flow deserves the same care as your primary feature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your description is the rubric.&lt;/strong&gt; You write it, then get tested against it. Underpromise in the listing and overdeliver in the app, not the reverse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing reviewers actually complain about
&lt;/h2&gt;

&lt;p&gt;Asked what frustrates them most, both said a version of the same thing: apps where you can tell nobody on the team ever used the app end to end.&lt;/p&gt;

&lt;p&gt;The fix is unglamorous. Walk the whole thing yourself before submitting. Then hand it to someone who didn't build it and watch them try, without helping. Fix everything they get stuck on. Then submit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Review isn't adversarial and isn't magic. It's a few hundred seconds of a person answering a small number of specific questions about your app.&lt;/p&gt;

&lt;p&gt;Design so those questions answer themselves, and most of the rejection surface disappears.&lt;/p&gt;

&lt;p&gt;For teams shipping repeatedly, moving screenshots, metadata, and review notes into the release pipeline rather than reassembling them each submission removes the most common source of drift between the listing and the build. That's part of what &lt;a href="https://www.letsdeploy.it/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=app-store-review-six-minutes" rel="noopener noreferrer"&gt;letsdeploy.it&lt;/a&gt; handles.&lt;/p&gt;




&lt;p&gt;What got you rejected, and how long did it take to work out why? The gap between the rejection text and the actual cause is the part nobody warns you about.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>mobile</category>
      <category>appstore</category>
      <category>startup</category>
    </item>
    <item>
      <title>I published my digital product's refund rate</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Fri, 14 Aug 2026 07:00:50 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/i-published-my-digital-products-refund-rate-5735</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/i-published-my-digital-products-refund-rate-5735</guid>
      <description>&lt;ul&gt;
&lt;li&gt;We sell React Native templates at $79&lt;/li&gt;
&lt;li&gt;Refund policy: 7 days, no questions asked, processed in 2 business days&lt;/li&gt;
&lt;li&gt;Refund rate over 6 months: &lt;strong&gt;3.8%&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Blended cost of the policy: &lt;strong&gt;~4% of gross revenue&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Would ship it identically on day one again&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The policy, verbatim
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- 7-day money-back guarantee, no questions asked
- Window starts from the receipt date
- Email support@shaper.studio with your order ID
- Processed within 2 business days
- Excluded: requests after 7 days, third-party API costs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire policy. There is no "unless you cloned the repo" clause. There is no "unless you used support" clause. There is no cooling-off period, no store-credit-first flow, no retention pitch. If you email us the word "refund" and your order ID within seven days, you get your money back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we chose this
&lt;/h2&gt;

&lt;p&gt;Three arguments won:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The buyer cannot inspect the product pre-purchase.&lt;/strong&gt; With source code, the demo video and screenshots are all you get until the credit card clears. A generous refund policy is the only honest way to say "verify for yourself" in a market where verification requires purchase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. If we are right about the product, the policy is cheap.&lt;/strong&gt; Low refund rate = low policy cost. If we are wrong, the refund rate tells us fast. The only losing scenario is one where our product is bad, and in that scenario we deserve to lose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Refund friction is negative marketing.&lt;/strong&gt; Every "no refunds" post triggers a Reddit thread. Not being on the wrong side of that conversation is worth something.&lt;/p&gt;

&lt;h2&gt;
  
  
  Competitor comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Vendor&lt;/th&gt;
&lt;th&gt;Window&lt;/th&gt;
&lt;th&gt;Conditions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Applighter&lt;/td&gt;
&lt;td&gt;7 days&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instamobile&lt;/td&gt;
&lt;td&gt;Case-by-case&lt;/td&gt;
&lt;td&gt;Bundles non-refundable, lose rights, no refund if support was used&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical CodeCanyon&lt;/td&gt;
&lt;td&gt;14 days&lt;/td&gt;
&lt;td&gt;"Not as described" (unfalsifiable in practice)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical premium boilerplate&lt;/td&gt;
&lt;td&gt;0 days&lt;/td&gt;
&lt;td&gt;All sales final&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The refund rate math
&lt;/h2&gt;

&lt;p&gt;At $79 with a 3.8% refund rate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;per-order refund cost = 0.038 * $79 = $3.00
plus Stripe fixed fee retained on refund = $0.30
= ~$3.30 lost per paid order
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For context: a Dev.to writeup last year reported ~40% refund rate at $29 vs ~4% at $79 for a similar product. Our number matches that curve. Price is a filter. Cheap templates get impulse buyers who impulse-refund.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually cost us
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Refunded revenue:&lt;/strong&gt; four-figure number over six months&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stripe fixed fees:&lt;/strong&gt; ~30¢ per refund, kept by Stripe&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Support time:&lt;/strong&gt; ~90 min/month (5 min email + 1 click Stripe)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Opportunity cost:&lt;/strong&gt; buyers who would have taken store credit but got cash instead&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Total blended cost:&lt;/strong&gt; ~4% of gross revenue&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What it did not cost us
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Reddit threads: zero&lt;/li&gt;
&lt;li&gt;Public complaints: zero&lt;/li&gt;
&lt;li&gt;Chargebacks: zero (chargebacks are worse than refunds — fees, disputes, reputation)&lt;/li&gt;
&lt;li&gt;Hostile support emails: zero&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Counter-intuitive benefits
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Refund requests are the highest-signal feedback we get.&lt;/strong&gt; Happy customers say nothing. Refund requests come with implicit info — which template, whether they opened the docs, whether they cloned the repo. Two refunds in one week from buyers who never opened the setup docs told us the docs were too easy to miss. We fixed the onboarding email.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generous refunds pre-qualify buyers.&lt;/strong&gt; "Seven days is plenty" = the buyer we want. "Seven days isn't enough" = the buyer who will not evaluate the code for months, will not ship, and will not be happy regardless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The support inbox stays calm.&lt;/strong&gt; "I want a refund" as a two-word conversation instead of a negotiation drops the emotional temperature. Vendors with strict policies deal with disputes, chargebacks, and public complaints. Generous refunds head off most chargebacks before they start.&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to do this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Product under $30:&lt;/strong&gt; impulse buyers push refund rate toward 40%. Fix: raise the price or tighten the policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real marginal cost per unit:&lt;/strong&gt; source code is zero marginal cost, so we can absorb 3.8%. Physical goods cannot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Value fully consumed in the refund window:&lt;/strong&gt; video courses need anti-abuse (e.g., &amp;gt;50% watched voids the refund) or refund rate approaches 100%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cannot deliver the turnaround:&lt;/strong&gt; a 7-day policy that takes 30 days to process is worse than no policy at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The part I did not expect
&lt;/h2&gt;

&lt;p&gt;The policy changed how we write marketing. When the buyer can call your bluff with one email and their money, you stop reaching for weasel words. Every landing page, every screenshot, every code excerpt has to hold up under "would this survive the refund window?" It is the closest thing to product-market fit pressure I have felt in a bootstrapped business.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full breakdown
&lt;/h2&gt;

&lt;p&gt;Full write-up with the internal numbers, the policy design tradeoffs, and the exact refund-request template we use: &lt;a href="https://www.applighter.com/blog/our-react-native-template-refund-policy-what-it-cost" rel="noopener noreferrer"&gt;on the Applighter blog&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>indiehackers</category>
      <category>startup</category>
      <category>saas</category>
    </item>
    <item>
      <title>What Users Actually Feel When Your AI-Generated Code Is 'Fine': A UX Autopsy of 3 Shipped Features</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Wed, 29 Jul 2026 04:26:33 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/what-users-actually-feel-when-your-ai-generated-code-is-fine-a-ux-autopsy-of-3-shipped-features-2emc</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/what-users-actually-feel-when-your-ai-generated-code-is-fine-a-ux-autopsy-of-3-shipped-features-2emc</guid>
      <description>&lt;ul&gt;
&lt;li&gt;AI-generated code is often &lt;strong&gt;technically correct and experientially broken&lt;/strong&gt; — it solves the ticket, and the ticket rarely describes the feeling.&lt;/li&gt;
&lt;li&gt;Three real examples: a confirmation modal with no friction (14 accidental account deletions), a naked payment spinner (−4.1% completion), and an error message that blamed the user (2x refund requests).&lt;/li&gt;
&lt;li&gt;Three prompt clauses — &lt;strong&gt;emotional stakes&lt;/strong&gt;, &lt;strong&gt;latency honesty&lt;/strong&gt;, &lt;strong&gt;blame avoidance&lt;/strong&gt; — caught ~70% of these before review.&lt;/li&gt;
&lt;li&gt;A 4-item UX regression checklist for AI-authored PRs, none of which a linter can catch.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Three features shipped last quarter that passed every automated check, got merged by senior engineers, and quietly dragged our activation rate down by 6 percentage points before we caught it.&lt;/p&gt;

&lt;p&gt;All three were AI-generated. None had a bug in the traditional sense. Here's what actually went wrong.&lt;/p&gt;

&lt;p&gt;The uncomfortable truth: &lt;strong&gt;most AI-generated code is technically correct and experientially broken.&lt;/strong&gt; The model doesn't know what a user feels at 11pm on a phone with 40% battery trying to check out. It writes the code that solves the ticket. The ticket almost never describes the feeling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature 1: the confirmation modal that never confirmed
&lt;/h2&gt;

&lt;p&gt;Ticket: &lt;em&gt;"Add a delete-account button in settings with a confirmation modal."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What shipped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Dialog&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DialogTitle&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Delete account?&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;DialogTitle&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DialogDescription&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    This action cannot be undone.
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;DialogDescription&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DialogFooter&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"outline"&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;close&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Cancel&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"destructive"&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;deleteAccount&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Delete&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;DialogFooter&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Dialog&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Technically perfect. Passed a11y checks. Matched the design system. Support tickets over the next three weeks: &lt;strong&gt;14 users accidentally deleted their accounts.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What was missing: the type-your-username-to-confirm friction step. GitHub does it. Stripe does it. Every consequential delete flow on the internet does it. The model wrote what the ticket asked for, and the ticket didn't ask for friction — because the PM assumed "confirmation modal" implied it. It doesn't. To a model, a confirmation modal is exactly two buttons.&lt;/p&gt;

&lt;p&gt;User interviews afterward:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I thought Cancel was the red one because it was the destructive action."&lt;/p&gt;

&lt;p&gt;"My thumb just... hit it. I don't even remember reading the modal."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The fix was 8 lines. The lesson was bigger: &lt;strong&gt;the model needs the emotional stakes in the prompt, not just the functional requirements.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature 2: the loading state that stole 800ms of trust
&lt;/h2&gt;

&lt;p&gt;Ticket: &lt;em&gt;"Add a loading spinner while the payment processes."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Shipped code, paraphrased:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isProcessing&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Spinner&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pay&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Pay $49&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Technically correct. But what the user saw:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Click "Pay $49"&lt;/li&gt;
&lt;li&gt;Button disappears, spinner appears&lt;/li&gt;
&lt;li&gt;~1.2 seconds of a spinning circle with &lt;strong&gt;no context&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Success screen&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We A/B tested it against this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isProcessing&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex items-center gap-2 text-sm text-muted-foreground"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Spinner&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;stage&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pay&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Pay $49&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// stage cycles: "Contacting your bank..." → "Confirming payment..." → "Almost done..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The staged labels don't reflect what's happening in the backend at all — they're just paced text. Result: &lt;strong&gt;completion rate on the payment step went up 4.1%.&lt;/strong&gt; Users who felt informed didn't bounce; users who watched a naked spinner did.&lt;/p&gt;

&lt;p&gt;The model didn't know a spinner is a UX cliff. It knew a spinner was the canonical solution for a loading state. Those are different things.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature 3: the error message that blamed the user
&lt;/h2&gt;

&lt;p&gt;Most AI-generated error handling has this shape:&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;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid input. Please check your details and try again.&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;Every word in that message pushes the failure onto the user. "Invalid input." "Check your details." "Try again."&lt;/p&gt;

&lt;p&gt;One user in a session recording:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I checked. Twice. It's not my card. Forget this." (closes tab)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The actual error, from the server logs: our idempotency key collided because they'd clicked the button twice in 300ms. Nothing was invalid. The order had gone through on their first click.&lt;/p&gt;

&lt;p&gt;Rewrite:&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;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;idempotency_conflict&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;toast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Looks like this went through already — refreshing your order list.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;refetchOrders&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="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Something on our end didn't work. Your card wasn't charged. Try again or ping support — we'll fix it.&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;Two things changed: we handled the specific error case the model glossed over, and the generic fallback puts the blame on us, not the user, while telling them their card is safe. Refund requests on that flow dropped by half.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the model can't feel — and how to compensate in the prompt
&lt;/h2&gt;

&lt;p&gt;The pattern across all three: the model wrote &lt;em&gt;sufficient&lt;/em&gt; code, not &lt;em&gt;humane&lt;/em&gt; code. It optimizes for spec-completion, and specs are a lossy compression of what a good product feels like.&lt;/p&gt;

&lt;p&gt;What we added to our prompts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Emotional-stakes clause:&lt;/strong&gt; "This flow is a delete action. Real users will lose real data if they mis-click. Design the friction accordingly."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency-honesty clause:&lt;/strong&gt; "Any async call over 400ms must show a labelled progress indicator, not a naked spinner. Prefer 3 pacing labels over a single 'Loading...'."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blame-avoidance clause:&lt;/strong&gt; "Error messages must not use 'Invalid', 'Wrong', or 'You'. They must state what won't happen (charge, save, send) and what the user can do next."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those three lines in a system prompt caught roughly 70% of the UX regressions we'd been finding in review after the fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  A UX regression bar for AI-authored PRs
&lt;/h2&gt;

&lt;p&gt;We now run every user-facing AI PR through a 4-item checklist before merge:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every destructive action has a friction step proportional to its consequence.&lt;/li&gt;
&lt;li&gt;Every async op over 400ms has a labelled progress state, not just a spinner.&lt;/li&gt;
&lt;li&gt;Every error message names what won't happen and offers a next step.&lt;/li&gt;
&lt;li&gt;Every empty state has a first-run tip, not just "No items yet."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these are things a linter can catch. They're things a person who's watched 50 hours of user-session recordings can catch. Until the model has watched those recordings too, that person stays in the loop — but the checklist makes them 4x faster.&lt;/p&gt;




&lt;p&gt;If you're building a new SaaS and don't want to relearn these the hard way, most of the patterns above — typed-confirm modals, paced loading states, human error copy — are the kind of thing a mature template bakes in by default. The &lt;a href="https://www.applighter.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=ai-code-ux-autopsy" rel="noopener noreferrer"&gt;Applighter&lt;/a&gt; starters ship with them because they were extracted from exactly this kind of production autopsy.&lt;/p&gt;

&lt;p&gt;Which of the four checklist items would your last AI-authored PR have failed? Mine was the naked spinner — twice.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>ux</category>
      <category>webdev</category>
      <category>react</category>
    </item>
    <item>
      <title>The 4 Notification Categories Users Actually Whitelist (Based on 60 App Interviews)</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Wed, 29 Jul 2026 04:11:00 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/the-4-notification-categories-users-actually-whitelist-based-on-60-app-interviews-15gf</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/the-4-notification-categories-users-actually-whitelist-based-on-60-app-interviews-15gf</guid>
      <description>&lt;ul&gt;
&lt;li&gt;Users whitelist only &lt;strong&gt;four&lt;/strong&gt; notification categories: someone they know did something involving them, a thing they explicitly set up, a transaction they're expecting, and a time-critical alert from a system they trust.&lt;/li&gt;
&lt;li&gt;Everything else — feature announcements, recommendations, streaks, re-engagement, promos — gets muted within two weeks.&lt;/li&gt;
&lt;li&gt;Users pattern-match to &lt;strong&gt;category&lt;/strong&gt;, not copy. Better push copy optimizes the wrong variable.&lt;/li&gt;
&lt;li&gt;Per-category in-app consent (not one system prompt) moved opt-in from 41% to 73% and dropped 30-day mute rate from 34% to 9% in one test.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Over the last six weeks I interviewed 60 people about their phone's notification settings. I asked each to open Settings → Notifications, scroll through every app, and tell me the story behind why each one was on, off, or set to silent delivery.&lt;/p&gt;

&lt;p&gt;The pattern was sharper than I expected. Users don't allow or block push app-by-app. They do it &lt;strong&gt;category-by-category&lt;/strong&gt; — and they only tolerate four specific categories. Everything else gets muted within two weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four categories users whitelist
&lt;/h2&gt;

&lt;p&gt;Across 60 interviews, people named the same four types of notification as ones they actively want:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Someone I know did something involving me.&lt;/strong&gt; A message, a comment, a mention, a friend joining, a family member sharing a photo. Nearly everyone said they never mute these.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A thing I explicitly set up.&lt;/strong&gt; Alarm, reminder, calendar event, medication, workout time. The rare exceptions had turned these off &lt;em&gt;in the app itself&lt;/em&gt;, not in system settings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A transaction I'm expecting.&lt;/strong&gt; Delivery updates, ride arrival, payment confirmation, booking status. Widely kept on, though several used silent delivery to soften the interruption.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. A time-critical alert from a system I trust.&lt;/strong&gt; Bank fraud alert, security code, appointment 15 minutes out, flight delay. &lt;em&gt;Trust&lt;/em&gt; was the load-bearing word — several people had disabled these for specific apps that had cried wolf.&lt;/p&gt;

&lt;p&gt;Every other category — feature announcements, personalized recommendations, streak reminders, re-engagement, promos, content updates — got muted, disabled, or in a few cases triggered an uninstall.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why category beats copy
&lt;/h2&gt;

&lt;p&gt;The standard growth advice is "write better push copy." A/B test the emoji, tighten the CTA, personalize with the user's name.&lt;/p&gt;

&lt;p&gt;That optimizes the wrong variable.&lt;/p&gt;

&lt;p&gt;The interviews were unambiguous: users don't parse notification copy before deciding whether to keep push on. They pattern-match to the category. Once your app has sent two notifications from a category they don't want, they mute the whole app — no matter how good the copy was.&lt;/p&gt;

&lt;p&gt;So the design question isn't "how do we write a compelling notification?" It's &lt;strong&gt;"how do we ensure every notification we send falls into one of the four whitelisted categories?"&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The consent conversation to have in-app
&lt;/h2&gt;

&lt;p&gt;The iOS system dialog is a blunt instrument: one yes/no for all future notifications. What users actually want is category-level consent — which iOS supports through notification categories, but almost no app uses well.&lt;/p&gt;

&lt;p&gt;The pattern I now recommend:&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;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-notifications&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;social&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reminders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transactions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;security&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setupNotificationCategories&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setNotificationCategoryAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;social&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reply&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buttonTitle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Reply&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;opensAppToForeground&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="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setNotificationCategoryAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reminders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setNotificationCategoryAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transactions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setNotificationCategoryAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;security&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;requestConsentForCategory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Category&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;stored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getStoredPreferences&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;stored&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;denied&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// In-app modal that explains what this category is&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wantsIt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;showCategoryConsentModal&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;labelFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;example&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;exampleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;frequency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;frequencyFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;savePreference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wantsIt&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;granted&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;denied&lt;/span&gt;&lt;span class="dl"&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;wantsIt&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPermissionsAsync&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;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;granted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestPermissionsAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The in-app modal does the real work. It shows a concrete example of the notification the user would receive, states roughly how often ("a few times per week," not "occasionally"), and lets them opt in per category rather than for everything at once.&lt;/p&gt;

&lt;p&gt;One app I tested this with saw opt-in rise from &lt;strong&gt;41% to 73%&lt;/strong&gt; by moving from a single system prompt to contextual per-category prompts. More importantly, 30-day mute rate dropped from &lt;strong&gt;34% to 9%&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The signal every PM misses: silent delivery
&lt;/h2&gt;

&lt;p&gt;iOS's "Deliver Quietly" lets users keep receiving your notifications but hide them from the lock screen and Notification Center. It's a soft mute — chosen when someone wants the app's data but not the interruption.&lt;/p&gt;

&lt;p&gt;Most PMs don't track it, because iOS doesn't report it. But you can infer it: if a user's open rate on pushes drops to near-zero while their app opens stay steady, they've likely moved you to Deliver Quietly.&lt;/p&gt;

&lt;p&gt;Segment those users. They're one bad broadcast away from a full mute or uninstall. For them, cut sends by 60–80% and fire only category 1 and 3 notifications (people-related and transactional). I've seen apps recover full-delivery status this way for a meaningful share of the quietly-delivered segment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redesign around user-declared intent
&lt;/h2&gt;

&lt;p&gt;The biggest shift from these interviews: the notifications users keep are tied to intent &lt;em&gt;they&lt;/em&gt; expressed inside the product. They set the reminder. They ordered the food. They followed the person. They asked the bank to alert on fraud.&lt;/p&gt;

&lt;p&gt;The notifications they mute are tied to intent the &lt;em&gt;product&lt;/em&gt; declared on their behalf. "We think you'd love this new feature." "We noticed you haven't opened us in a week." "Here's a personalized recommendation."&lt;/p&gt;

&lt;p&gt;So the implication is concrete: for every notification your app sends, you should be able to trace it back to a specific in-app action the user took that constitutes consent for &lt;em&gt;this&lt;/em&gt; notification. If you can't, it belongs in the mute pile. If you can, you're building the trust that keeps push on for the lifetime of that user.&lt;/p&gt;

&lt;p&gt;A practical way to enforce this: require a &lt;code&gt;sourceAction&lt;/code&gt; field on every notification in your schema — a machine-checkable link back to the user action that authorized the send. It's an annoying constraint at first. Then it becomes the reason your app is still on the lock screen six months in, when competitors have been muted.&lt;/p&gt;




&lt;p&gt;Run the interviews yourself. Ten users, thirty minutes each. You'll come back with a notification strategy your PM would never sign off on — and your users will actually welcome.&lt;/p&gt;

&lt;p&gt;I write more about building this kind of thing into React Native apps over at &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=notification-categories" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Which of the four categories does your app mostly send? And have you ever caught yourself sending from the mute pile?&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>ux</category>
      <category>mobile</category>
      <category>expo</category>
    </item>
    <item>
      <title>What OTA Updates Feel Like for Your Users (and Why It Matters)</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Wed, 22 Jul 2026 10:29:07 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/what-ota-updates-feel-like-for-your-users-and-why-it-matters-20g9</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/what-ota-updates-feel-like-for-your-users-and-why-it-matters-20g9</guid>
      <description>&lt;ul&gt;
&lt;li&gt;Default Expo behaviour applies updates silently at cold start. That's only good UX if app state doesn't change between bundles.&lt;/li&gt;
&lt;li&gt;Three places OTA bites: &lt;strong&gt;mid-session update prompts&lt;/strong&gt;, &lt;strong&gt;schema drift between bundle and cached state&lt;/strong&gt;, and &lt;strong&gt;delayed adoption&lt;/strong&gt; creating support burden.&lt;/li&gt;
&lt;li&gt;Fix schema drift by tagging AsyncStorage writes with a &lt;code&gt;_schemaVersion&lt;/code&gt; and migrating or evicting at startup.&lt;/li&gt;
&lt;li&gt;Expose the current bundle ID in Settings → About so support can diagnose "which bundle are you on?" in one question.&lt;/li&gt;
&lt;li&gt;The technical update should be invisible. The &lt;em&gt;experiential&lt;/em&gt; update never should be.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Every React Native OTA update tutorial focuses on the developer experience: &lt;code&gt;eas update&lt;/code&gt;, done.&lt;/p&gt;

&lt;p&gt;What almost nobody writes about is what happens on the &lt;em&gt;user's&lt;/em&gt; phone when your OTA lands, and how that experience shapes whether they stay in the app or bounce.&lt;/p&gt;

&lt;p&gt;Let's walk through it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when an OTA lands
&lt;/h2&gt;

&lt;p&gt;Default Expo behaviour with &lt;code&gt;checkAutomatically: "ON_LOAD"&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User opens the app. Native shell launches, splash screen shows.&lt;/li&gt;
&lt;li&gt;In parallel, the update client checks your server for a new bundle. If found and downloaded within &lt;code&gt;fallbackToCacheTimeout&lt;/code&gt; (default &lt;code&gt;0ms&lt;/code&gt; — i.e. never wait), it's applied immediately.&lt;/li&gt;
&lt;li&gt;If the timeout hit first, the cached (previous) bundle loads. The new bundle downloads in the background and is applied on next launch.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The user sees nothing. That's the design goal.&lt;/p&gt;

&lt;p&gt;But "nothing" is only good UX if the state of the app doesn't change between the two bundles.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three UX moments where OTA bites
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Mid-session update prompt
&lt;/h3&gt;

&lt;p&gt;You set &lt;code&gt;checkAutomatically: "ON_ERROR_RECOVERY"&lt;/code&gt; thinking you'll only update after a crash. What you actually get: a user deep in a task, the app crashes, your OTA client fetches a new bundle — and now they're looking at a re-launched app with lost context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Better pattern:&lt;/strong&gt; &lt;code&gt;ON_LOAD&lt;/code&gt; (cold start only) plus an explicit in-app "Update available — restart to apply" banner for warm-session updates.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Schema drift between bundle and cached state
&lt;/h3&gt;

&lt;p&gt;Your new bundle expects &lt;code&gt;user.profile.avatarUrl&lt;/code&gt;. Users upgrading from an old bundle have &lt;code&gt;user.avatar&lt;/code&gt; in cache. Crash on first render.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; version your cache schema. AsyncStorage writes should include a &lt;code&gt;_schemaVersion&lt;/code&gt; tag, and new bundles migrate or evict old-schema data at startup.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Delayed adoption and support burden
&lt;/h3&gt;

&lt;p&gt;A user emails "this feature is broken." You check the latest bundle — it's fixed. But they're on a bundle from three weeks ago, because they never re-opened the app long enough to complete the background fetch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution:&lt;/strong&gt; expose the current bundle ID in Settings → About. Support can ask "what does it say?" and immediately know whether they need to force an update.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "update pending" pattern
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Updates&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-updates&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useUpdatePending&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setPending&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;false&lt;/span&gt;&lt;span class="p"&gt;);&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;Updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;Updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;UpdateEventType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;UPDATE_AVAILABLE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;setPending&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="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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;pending&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;Then in your app shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useUpdatePending&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="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UpdateBanner&lt;/span&gt; &lt;span class="na"&gt;onRestart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;Updates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reloadAsync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;YourApp&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&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;Show the banner unobtrusively — top of screen, dismissible. Let the user restart when they're ready.&lt;/p&gt;

&lt;p&gt;Trust beats speed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Communicating rollbacks
&lt;/h2&gt;

&lt;p&gt;If you roll back a bundle, users who had the bad one for ten minutes may have taken some action in that window started a form, attempted a payment. They come back and the UI has changed under them.&lt;/p&gt;

&lt;p&gt;For any rollback that touched user-visible state, ship a small in-app notice:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;We reverted an update from an hour ago. If you saw errors, they should be resolved now.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Radical honesty. Most users appreciate being told rather than left confused.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to break the invisibility rule
&lt;/h2&gt;

&lt;p&gt;For truly major changes — new navigation, a redesigned home screen — an OTA that silently swaps things is &lt;em&gt;worse&lt;/em&gt; than a store update with release notes.&lt;/p&gt;

&lt;p&gt;Consider gating major UX changes behind an in-app "What's new" modal, even if the code shipped via OTA weeks earlier.&lt;/p&gt;

&lt;p&gt;The whole point of OTA is that the technical update is invisible. The experiential update never should be.&lt;/p&gt;




&lt;p&gt;If you're starting a React Native project and want the update pipeline wired from day one, &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=ota-updates-user-ux" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; generates Expo apps with EAS Update already configured.&lt;/p&gt;

&lt;p&gt;What's your OTA horror story? Mine was schema drift a cached object shape that crashed every returning user on first render.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>ux</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Your App Store Reviewer Is a User — Design the Demo Account Like a Usability Test</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Wed, 15 Jul 2026 12:48:14 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/your-app-store-reviewer-is-a-user-design-the-demo-account-like-a-usability-test-ecg</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/your-app-store-reviewer-is-a-user-design-the-demo-account-like-a-usability-test-ecg</guid>
      <description>&lt;ul&gt;
&lt;li&gt;Your App Store reviewer is functionally your first real user — same 30 seconds of first impression, but they can reject you.&lt;/li&gt;
&lt;li&gt;Three demo-account failures trigger rejections: an &lt;strong&gt;empty&lt;/strong&gt; account, a &lt;strong&gt;broken&lt;/strong&gt; account (stale credentials), and a &lt;strong&gt;geofenced&lt;/strong&gt; account.&lt;/li&gt;
&lt;li&gt;Seed the demo account with 10–20 realistic entries, one in-progress state, and completed onboarding.&lt;/li&gt;
&lt;li&gt;Add a review-notes walkthrough so the reviewer knows exactly what to test.&lt;/li&gt;
&lt;li&gt;Re-seed automatically before every submission — demo accounts decay.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Most indie devs think of the App Store reviewer as a QA person: they check that the app builds, opens, doesn't crash, complies with guidelines, and passes review. That's incomplete.&lt;/p&gt;

&lt;p&gt;The reviewer is also, functionally, your first real user. They have the same 30 seconds of first-impression time as anyone who downloads the app. If your demo account puts them in an empty state with no context, they judge the app the same way a real user would — and they can reject.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reviewers as first-user proxies
&lt;/h2&gt;

&lt;p&gt;From the reviewer's side, the process looks like this: they open your listing, install the app, launch it, and try to use it as if they'd downloaded it themselves. If your app requires auth, they use the demo account you provided.&lt;/p&gt;

&lt;p&gt;If that demo account lands them on a blank home screen with no data, they have no way to evaluate whether the app "works."&lt;/p&gt;

&lt;p&gt;Rejections in this category read as "your app did not have enough functionality to review" or "we could not test the primary features." Both are UX rejections dressed up as review outcomes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three demo-account failures
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Failure 1: the empty account.&lt;/strong&gt; The reviewer logs in and sees a blank feed, an empty list, or a "no results yet" state. There's nothing to interact with, so they can't evaluate the app's real behavior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure 2: the broken account.&lt;/strong&gt; The demo account has stale credentials, the password was rotated, or the account was disabled. The reviewer gets "invalid password" and can't proceed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure 3: the geofenced account.&lt;/strong&gt; The demo account requires a specific location, phone number, or region that the reviewer's environment doesn't match. The app looks broken through no fault of its logic.&lt;/p&gt;

&lt;p&gt;All three are preventable with about 90 minutes of prep.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to seed and why
&lt;/h2&gt;

&lt;p&gt;Your demo account should contain, at minimum:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;10–20 realistic entries&lt;/strong&gt; in your primary data model (posts, tasks, contacts — whatever your app centers on).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One realistic "in-progress" state&lt;/strong&gt; — a partially filled form, a saved draft, a bookmarked item.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Onboarding fully completed&lt;/strong&gt; — the reviewer shouldn't have to walk through your welcome flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;At least one "friend" or connection&lt;/strong&gt; with plausible data, if the app has social features.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal is simple: when the reviewer opens the app, they should see something that looks like a real user's screen, not an empty state.&lt;/p&gt;

&lt;h2&gt;
  
  
  The review-notes template
&lt;/h2&gt;

&lt;p&gt;In App Store Connect's review notes, include a block like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Demo account:
  Email:    reviewer@yourapp.com
  Password: &amp;lt;one-time-use password&amp;gt;

This account is pre-seeded with realistic data.
All primary features are accessible without in-app purchase.
Contact: your-email@yourapp.com if any issues.

Walkthrough (in case anything is unclear):
  1. Sign in with the credentials above
  2. The home screen shows your recent [items]
  3. Tap [primary CTA] to try the [key flow]
  4. Long-press [item] to see the [secondary flow]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A walkthrough like this cuts review times noticeably — the reviewer knows exactly what to test and how.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the demo account healthy across releases
&lt;/h2&gt;

&lt;p&gt;The most common failure here is decay: the demo account drifts over time. New features get added that need new seed data. Passwords get rotated. The account gets used for real dev testing and its state wanders.&lt;/p&gt;

&lt;p&gt;What works:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Automated re-seeding&lt;/strong&gt; before each submission — a script that clears and reseeds the account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A checklist item&lt;/strong&gt; in your release process: "verify demo account signs in and shows expected data."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never use the demo account for actual dev work&lt;/strong&gt; — keep it review-only.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;A polished demo account with seeded data plus a clear walkthrough is one of the highest-leverage things you can do for review outcomes. Treat the reviewer as a one-shot usability test — you have five minutes to convince them the app works.&lt;/p&gt;

&lt;p&gt;If you'd rather not hand-build all this from scratch, &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=app-store-reviewer-demo-account" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; generates React Native + Expo apps with seed data and demo states you can point straight at a reviewer.&lt;/p&gt;

&lt;p&gt;What's the most avoidable App Store rejection you've hit? Drop it in the comments.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>appstore</category>
      <category>ux</category>
      <category>mobile</category>
    </item>
  </channel>
</rss>
