<?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>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>
