<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Amanda Gama</title>
    <description>The latest articles on Forem by Amanda Gama (@aoligama).</description>
    <link>https://forem.com/aoligama</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1453761%2F32756b65-e3be-43cc-aac8-b842a460b89b.png</url>
      <title>Forem: Amanda Gama</title>
      <link>https://forem.com/aoligama</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/aoligama"/>
    <language>en</language>
    <item>
      <title>A Live Activity isn't a notification</title>
      <dc:creator>Amanda Gama</dc:creator>
      <pubDate>Tue, 05 May 2026 01:59:54 +0000</pubDate>
      <link>https://forem.com/aoligama/a-live-activity-isnt-a-notification-2k9o</link>
      <guid>https://forem.com/aoligama/a-live-activity-isnt-a-notification-2k9o</guid>
      <description>&lt;p&gt;You're in another app and there's a timer counting down at the top of your phone. You lock the screen and the same timer is sitting there. You swipe down to the Notification Center and it's there too, still ticking. It looks like a notification, but a notification can't tick.&lt;/p&gt;

&lt;p&gt;That's a Live Activity. It looks like three different surfaces (Dynamic Island, lock-screen banner, Notification Center entry), but they're the same widget, rendered three ways by the OS. I wired one up for Tomoe, a focus timer I built. The punch line: it took a weekend. Most of that weekend was unlearning what I thought a Live Activity was. Once the shape clicked, the code was small.&lt;/p&gt;

&lt;p&gt;This post is the shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Live Activity actually is
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdhmpqd1lzwadea2fvder.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdhmpqd1lzwadea2fvder.jpeg" alt=" " width="800" height="168"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three things that get glossed over in most tutorials:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's a widget, not a notification.&lt;/strong&gt; The view code lives in a Widget Extension target, separate from your app. The app pushes state; the extension renders pixels. If you've shipped a Home Screen widget before, this is the same story.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's driven by a typed &lt;code&gt;ContentState&lt;/code&gt;.&lt;/strong&gt; You declare a Codable struct of "things that change" and call &lt;code&gt;.update()&lt;/code&gt; with a new instance. There's no general "set arbitrary text" API. The schema is the contract.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The OS renders the timer.&lt;/strong&gt; If you reach for &lt;code&gt;Timer&lt;/code&gt; or a 1-second &lt;code&gt;TimelineView&lt;/code&gt;, you're already off the path. SwiftUI's &lt;code&gt;Text(timerInterval:countsDown:)&lt;/code&gt; lets the OS rasterise the countdown for you. Your widget doesn't wake every second. It can't; the budget would never allow it.&lt;/p&gt;

&lt;p&gt;Two pieces of plumbing before any of this works. Declare &lt;code&gt;NSSupportsLiveActivities&lt;/code&gt; in the app's &lt;code&gt;Info.plist&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;NSSupportsLiveActivities&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…and target iOS 16.2 or later. The 16.0 / 16.1 ActivityKit surface churned, and the workarounds aren't worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data model
&lt;/h2&gt;

&lt;p&gt;Every Live Activity is keyed by an &lt;code&gt;ActivityAttributes&lt;/code&gt; type. You split it into "stuff that's fixed for the life of the activity" (the attributes themselves) and "stuff that changes" (the nested &lt;code&gt;ContentState&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Tomoe's looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;TomoeActivityAttributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ActivityAttributes&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;ContentState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Codable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Hashable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;
        &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;isPaused&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Bool&lt;/span&gt;
        &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;pausedRemainingSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The task name doesn't change once a session starts, so it's an attribute. The end timestamp, pause flag, and the snapshot for rendering paused state all change, so they're in &lt;code&gt;ContentState&lt;/code&gt;. Codable + Hashable is how the OS serialises state across the app/widget process boundary.&lt;/p&gt;

&lt;p&gt;The detail that costs everyone an afternoon: &lt;strong&gt;this file has to be a member of both targets&lt;/strong&gt;, the app target and the widget extension target. Xcode won't warn you. The activity will start, and then the widget will silently fail to decode the state and render nothing. Check the file inspector before you debug anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four Dynamic Island slots
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj6derhc8b14h3qgg3s9z.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj6derhc8b14h3qgg3s9z.jpeg" alt=" " width="800" height="127"&gt;&lt;/a&gt;&lt;br&gt;
Here's where the mental model clicks. The &lt;code&gt;DynamicIsland { … }&lt;/code&gt; builder gives you four slots, each for a different state of the same activity:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;compactLeading&lt;/code&gt;&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;compactTrailing&lt;/code&gt;&lt;/strong&gt;: the two tiny views you see hugging the camera cutout when only your activity is active.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;minimal&lt;/code&gt;&lt;/strong&gt;: what you're demoted to when &lt;em&gt;another&lt;/em&gt; app also has an active Live Activity. You're now a circle next to a dot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;expanded&lt;/code&gt;&lt;/strong&gt;: what the user sees when they long-press.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus the lock-screen / Notification Center view, which is the top-level body of the &lt;code&gt;ActivityConfiguration&lt;/code&gt;. The same view code renders in both places. The OS just changes the chrome around it.&lt;/p&gt;

&lt;p&gt;Trimmed to the load-bearing parts, Tomoe's whole widget is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;struct&lt;/span&gt; &lt;span class="kt"&gt;TomoeWidgetLiveActivity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Widget&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;some&lt;/span&gt; &lt;span class="kt"&gt;WidgetConfiguration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;ActivityConfiguration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;for&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TomoeActivityAttributes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="kt"&gt;LockScreenView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;activityBackgroundTint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;activitySystemActionForegroundColor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ink&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nv"&gt;dynamicIsland&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="kt"&gt;DynamicIsland&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="kt"&gt;DynamicIslandExpandedRegion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;leading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* mark + task name */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="kt"&gt;DynamicIslandExpandedRegion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trailing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="kt"&gt;TimerView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;38&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;white&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="kt"&gt;DynamicIslandExpandedRegion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bottom&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="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isPaused&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"paused"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nv"&gt;compactLeading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="kt"&gt;TomoeMark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nv"&gt;compactTrailing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="kt"&gt;TimerView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;white&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nv"&gt;minimal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="kt"&gt;TomoeMark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keylineTint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accent&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;Two things to keep in mind.&lt;/p&gt;

&lt;p&gt;The compact regions are tiny. Tomoe's brand mark is 22pt; the trailing timer is 22pt at a minimum width of 56pt. Anything bigger overflows. Design for ~40pt of width and treat anything more as a bonus.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;minimal&lt;/code&gt; view appears without warning, the moment any other app starts an activity. Don't put information in &lt;code&gt;compactLeading&lt;/code&gt; that needs to also be in &lt;code&gt;minimal&lt;/code&gt;. Those are different layers, and you don't get a transition between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let the OS tick
&lt;/h2&gt;

&lt;p&gt;The single most useful API in this whole stack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;timerInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;countsDown&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="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monospacedDigit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You give it a date range; you get back a &lt;code&gt;Text&lt;/code&gt; that the OS counts down for you. It updates without your widget doing anything. This is what makes the timer feel native: no flicker, no off-by-one, no battery cost.&lt;/p&gt;

&lt;p&gt;The catch: you can't pause it. The OS counts to the end of the range, period. So when the user pauses, Tomoe swaps to a static label:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isPaused&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;pausedTimeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pausedRemainingSeconds&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;timerInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;countsDown&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On pause, I push an update with &lt;code&gt;isPaused: true&lt;/code&gt; and a snapshot of the remaining seconds. The widget renders that snapshot until the next state change. On resume, I push a new &lt;code&gt;endDate = now + remaining&lt;/code&gt; and we're back on the OS-driven interval.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pausedRemainingSeconds&lt;/code&gt; is the non-obvious part: I have to send the value the widget should display, because the widget process has no idea how long ago I paused.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pushing updates from the app
&lt;/h2&gt;

&lt;p&gt;The lifecycle is three calls. From Tomoe's bridge module, trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;activity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="kt"&gt;Activity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;staleDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nv"&gt;pushType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// later, on pause/resume&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;newState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;staleDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;// done&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;dismissalPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;immediate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pushType: nil&lt;/code&gt; means "I'll push state from this app process." That's right for a timer. The app already knows when the user paused or finished. The other option is &lt;code&gt;.token&lt;/code&gt;, which lets you push from a server via APNs. Useful for Uber-style "your driver is here" updates that the app itself can't observe; overkill for anything the foreground app can drive.&lt;/p&gt;

&lt;p&gt;The budget you should know about, so you don't design something that fights it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An activity can live for about 8 hours before the OS forces it stale.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ContentState&lt;/code&gt; is capped at ~4 KB encoded.&lt;/li&gt;
&lt;li&gt;Updates are rate-limited; push every 100ms and the OS quietly drops most of them.&lt;/li&gt;
&lt;li&gt;A stale activity dims on the lock screen but doesn't disappear until you call &lt;code&gt;.end()&lt;/code&gt; or the user dismisses it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one matters more than it sounds: &lt;strong&gt;passing an &lt;code&gt;endDate&lt;/code&gt; in the past does not auto-end the activity.&lt;/strong&gt; The countdown will show &lt;code&gt;00:00&lt;/code&gt; and just sit there. You have to call &lt;code&gt;.end()&lt;/code&gt; yourself when the timer reaches zero. In Tomoe I do this from the JS layer when the timer fires, since the app is React Native. But the Swift &lt;code&gt;Activity.request&lt;/code&gt; / &lt;code&gt;.update&lt;/code&gt; / &lt;code&gt;.end&lt;/code&gt; API is the same regardless of host. Flutter, RN, native, doesn't matter; the shape is identical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that bit me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Shared types break easily.&lt;/strong&gt; The &lt;code&gt;ActivityAttributes&lt;/code&gt; file has to belong to both targets. Add → File Inspector → check both Target Memberships. If your activity starts but the widget renders empty, this is it. Every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dynamic Island regions ignore overflow.&lt;/strong&gt; SwiftUI happily lets you put a wide view in &lt;code&gt;compactTrailing&lt;/code&gt;. The OS will clip it without a warning at build time. Use &lt;code&gt;.minimumScaleFactor(0.6)&lt;/code&gt; and &lt;code&gt;.lineLimit(1)&lt;/code&gt; defensively, especially on numeric content where the digit count varies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Asset catalogs are flaky in widget extensions.&lt;/strong&gt; I had a brand image that loaded fine in the simulator and failed silently on device. I switched to a SwiftUI-drawn mark (a coloured rectangle with an SF Symbol on top) and the problem went away. If a widget asset isn't appearing on a real iPhone, that's the first thing to try.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;What surprised me about Live Activities is how little there is to them once you've named the parts. A typed &lt;code&gt;ContentState&lt;/code&gt;, four Dynamic Island slots, one OS-driven timer view, three lifecycle calls. That's the whole API surface for a focus timer. Most of the hard work happens before you write code: deciding what to put in the compact view, what's worth a long-press to expand, what the paused state should feel like.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://tomoe-bibt.onrender.com/" rel="noopener noreferrer"&gt;Tomoe&lt;/a&gt; to scratch my own itch for a focus timer that doesn't shout. Three calm scenes: rain, stars, fireflies. Four session lengths. No accounts, no tracking, no streaks to break. The timer follows you out of the app, into the Dynamic Island and onto the lock screen, exactly the way this post describes. &lt;a href="https://apps.apple.com/br/app/tomoe/id6762488332?l=en-GB" rel="noopener noreferrer"&gt;Available on the App Store&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>mobile</category>
      <category>showdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Becoming a tech lead, what I wish someone had told me</title>
      <dc:creator>Amanda Gama</dc:creator>
      <pubDate>Mon, 04 May 2026 13:07:30 +0000</pubDate>
      <link>https://forem.com/aoligama/becoming-a-tech-lead-what-i-wish-someone-had-told-me-10j9</link>
      <guid>https://forem.com/aoligama/becoming-a-tech-lead-what-i-wish-someone-had-told-me-10j9</guid>
      <description>&lt;p&gt;Becoming a tech lead was the goal from pretty early in my career. I had a clear picture of what the role was. More responsibility, more influence over the work, more of the interesting problems landing on my desk because someone had to figure them out and that someone, finally, would be me. It read like the natural next step. The thing you graduate to once you're good enough.&lt;/p&gt;

&lt;p&gt;What that picture didn't include was the part of the job that's about leading people. Not management. I knew management was a separate track. I mean the quieter stuff. Sitting with someone else's frustration and not fixing it for them. Holding a position you're 60% sure of in a room of people who want a confident answer. Realizing the thing blocking the team isn't a technical problem, and that nothing in your background really prepared you for the actual one.&lt;/p&gt;

&lt;p&gt;This is the post I wish someone had written for me when I was still aiming for the role. Not the ladder advice. The hard parts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in the day
&lt;/h2&gt;

&lt;p&gt;The honest version of the shift isn't what you read in leadership posts. Yes, there are more meetings. Everyone says that. The meeting count is the easy part to see and the easy part to adapt to. The ones I took longer to notice were quieter.&lt;/p&gt;

&lt;p&gt;The first one is your relationship to shipping. As an engineer, the day has a clean ledger. You opened a PR, you closed an issue, you merged a branch. Even on a slow day there's a diff that proves you were here. As a tech lead, half of your most important work doesn't show up that way. You sat in a meeting and changed the direction of a project. You unblocked someone in a thirty-minute conversation. You wrote a short doc that prevented two weeks of wasted work next month. None of that has a green checkmark next to it. The first few months I felt vaguely guilty at the end of every day, because I couldn't point to a thing I'd built. The fix wasn't to build more. It was to stop measuring my day in commits.&lt;/p&gt;

&lt;p&gt;The second is the time horizon. Engineering work runs in days. A bug, a feature, a refactor: a thing you can hold in your head start to finish. The lead work runs in weeks and quarters. A hire decision today shapes the team six months out. A standard you set now shows up in code reviews a year from now. The horizon stretches and the feedback loops stretch with it. You stop knowing whether a call you made was a good one for a long time, and sometimes you never really know.&lt;/p&gt;

&lt;p&gt;The third is that the day stops having a clean end. When you closed the laptop as an engineer, you were done. The remaining work was on a list, sitting still. As a lead, the open threads keep moving while you sleep. Someone hit something hard at 11pm. A project is drifting and you can feel it without anyone saying so. There's always a thing you could think about more. The skill is learning to put it down anyway.&lt;/p&gt;

&lt;p&gt;The fourth is a new kind of fatigue. Coding is mentally taxing in a contained way. You're tired, you go for a walk, you come back. The fatigue from a day of decisions and context switches is different. It doesn't lift the same way. It's lower-grade and longer-tailed. Nobody warned me about it, and I spent the better part of a year thinking I was just under-sleeping.&lt;/p&gt;

&lt;h2&gt;
  
  
  What advice I stopped giving
&lt;/h2&gt;

&lt;p&gt;There's a list of things I used to say to junior engineers with full conviction. Most of them I'd picked up from people I respected, and some I'd defended in arguments. Sitting in the lead chair quietly took a few of them apart.&lt;/p&gt;

&lt;p&gt;I stopped telling people to always push back on bad requirements. The advice isn't wrong, exactly. It's too neat. The version I gave didn't account for what bad requirements actually look like in the wild: usually a partial picture from someone who's already negotiated three other constraints you can't see. The right move is rarely "push back." It's "ask what changes if X." Most of the time the requirement isn't bad. You just don't know what it's load-bearing for yet.&lt;/p&gt;

&lt;p&gt;I stopped saying ship fast as a default. Speed matters. The trouble is that ship-fast gets treated as a virtue independent of context, which is how teams end up shipping things that take a year to undo. Some decisions deserve to be slow. Anything that's expensive to reverse (a data model, a hire, a public commitment, a foundational dependency) deserves the time. Speed on the reversible stuff, slowness on the rest. I should have been more careful about which one I was preaching.&lt;/p&gt;

&lt;p&gt;I stopped telling people to always speak up in meetings. The advice was pitched at people who under-contribute, which is a real failure mode. But there's an opposite one I didn't account for: the person who fills every silence and pushes the team into worse decisions because nobody slowed them down. Sometimes the best thing you can do in a room is wait. Let the question sit. See what someone else says.&lt;/p&gt;

&lt;p&gt;I stopped saying the right tech wins. It doesn't. Alignment wins. The team that picked the okay-but-aligned tool will out-ship the team that picked the better-but-contested one almost every time. The energy you spend defending a technical choice is energy you don't spend using it. I'd watched this happen to other teams and thought we were the exception. We weren't.&lt;/p&gt;

&lt;p&gt;The pattern under all of those: the advice I used to give was advice for the version of the job I'd imagined. Sitting in the chair changed which trade-offs I could see.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rituals that survived
&lt;/h2&gt;

&lt;p&gt;Most of what I did to stay organized as an engineer didn't survive the role change. Tickets I lived in stopped being where my work happened. The PR queue stopped being a useful daily anchor. The deep-work blocks I'd guarded for years got eaten by a calendar I no longer fully controlled. I had to rebuild a lot of it.&lt;/p&gt;

&lt;p&gt;What survived is small. Two habits, one rule.&lt;/p&gt;

&lt;p&gt;The first habit is a written end-of-day. Five lines, plain text, takes three minutes. What I decided today. What I'm still unsure about. Who's blocked on me. Who I'm blocked on. One thing I want to start with tomorrow. I tried more elaborate systems and they all collapsed within a month. The five-line version has stuck because it's small enough that I actually do it on bad days, which are the days you most need it.&lt;/p&gt;

&lt;p&gt;The second habit is reading code I didn't write, on purpose, every week. Not to review it. Just to read it. As a lead it's easy to drift away from the codebase, and a few weeks of that is enough that your suggestions in design reviews start feeling slightly off to the people doing the work. An hour a week of just reading what the team is shipping keeps the drift smaller. It doesn't make me an expert in everything. It makes me less wrong.&lt;/p&gt;

&lt;p&gt;The rule is: don't bring up pattern-y problems without sitting with them for 24 hours first. Not the urgent ones. Those go straight up. I mean the "something feels off about how we're approaching X" ones. If I raise that in the moment, half the time I'm reacting to one bad meeting, not a real problem. If it still feels true the next day, it's worth bringing up, and I bring it up better. The 24-hour rule has saved me from a lot of needless team-level drama, almost all of it self-inflicted.&lt;/p&gt;

&lt;p&gt;What I assumed would stick and didn't: the deep-work blocks. I tried to defend them and lost. Most days now my best heads-down hour is the one between waking up and the team coming online, not a block I scheduled. Recreating engineer rhythms in a lead's calendar was a year of frustration before I gave up.&lt;/p&gt;

&lt;p&gt;Also gone: my old habit of replying to messages in batches. As an engineer it was a productivity move. As a lead, leaving four people waiting six hours each is its own kind of cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell my earlier self
&lt;/h2&gt;

&lt;p&gt;A short list of things I'd say to the version of me who was aiming for this role.&lt;/p&gt;

&lt;p&gt;The job is mostly about people, even the technical parts. The technical parts that are still purely technical mostly aren't yours to do anymore. Make peace with that earlier than I did.&lt;/p&gt;

&lt;p&gt;You don't need to have the answer. You need to make sure the team gets to one. Those are different jobs and they require different muscles. The first one is the one you spent a decade training. The second one is mostly new.&lt;/p&gt;

&lt;p&gt;Confidence will be asked of you in moments when you don't have it. Faking it doesn't work, because people can tell. Saying "I'm not sure, here's how we'll find out" works almost every time, and the people you respect most are the ones who already do this.&lt;/p&gt;

&lt;p&gt;Most of the advice you give right now is for the role you have, not the one you're aiming at. That's not a problem. Just know that some of it will quietly stop being true.&lt;/p&gt;

&lt;p&gt;The role isn't a promotion in the way you imagine. It's a different job. You'll be a beginner again at parts of it, and the bits where you're a beginner will turn out to be the most important bits.&lt;/p&gt;

&lt;p&gt;The hard parts are the job. Not a side effect of it. Not something to optimize away. If you want the role, the part you're nervous about is most of what you'd be doing.&lt;/p&gt;

</description>
      <category>career</category>
      <category>devjournal</category>
      <category>leadership</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Push notifications, the iceberg under one feature</title>
      <dc:creator>Amanda Gama</dc:creator>
      <pubDate>Mon, 04 May 2026 02:29:09 +0000</pubDate>
      <link>https://forem.com/aoligama/push-notifications-the-iceberg-under-one-feature-4ka</link>
      <guid>https://forem.com/aoligama/push-notifications-the-iceberg-under-one-feature-4ka</guid>
      <description>&lt;p&gt;It's a one-line item on the roadmap. "Send a push notification when X happens." Estimate is two days, three if the backend doesn't have FCM credentials yet. There's a library for it.&lt;/p&gt;

&lt;p&gt;The library is the visible part. The other 90% is platform lifecycle, registration state machines, race conditions with navigation, payload archaeology, and a half-dozen iOS and Android quirks. Nobody writes them down. You learn them after you ship, when the bug reports start coming in.&lt;/p&gt;

&lt;p&gt;I built this stack with custom native modules, wrapping APNs on iOS and FCM on Android directly, instead of reaching for Notifee, React Native Firebase, or OneSignal. The trade was the obvious one. I gave up the abstraction the libraries provide and got control over every edge case in return. The decision wasn't ideological. The failure modes I cared about were already filed against those libraries, unfixed.&lt;/p&gt;

&lt;p&gt;This post is what's underneath. Not the library you import. The work the library is hiding from you, or trying to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The waterline: registration is a state machine, not a function call
&lt;/h2&gt;

&lt;p&gt;The tutorial version is one line:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;messaging&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getToken&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;sendToBackend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What's actually happening is a few rounds of back-and-forth between the OS, your app, and the push provider. At least three places it can quietly stop working.&lt;/p&gt;

&lt;p&gt;On iOS, registration is split across delegate methods. The token comes back as &lt;code&gt;NSData&lt;/code&gt;, not a string, and you hex-encode it before anyone outside the AppDelegate gets to see it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight objective_c"&gt;&lt;code&gt;&lt;span class="k"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;application&lt;/span&gt;&lt;span class="p"&gt;:(&lt;/span&gt;&lt;span class="n"&gt;UIApplication&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;application&lt;/span&gt;
&lt;span class="nf"&gt;didRegisterForRemoteNotificationsWithDeviceToken&lt;/span&gt;&lt;span class="p"&gt;:(&lt;/span&gt;&lt;span class="n"&gt;NSData&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;deviceToken&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;unsigned&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;deviceToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;NSMutableString&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;hex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;NSMutableString&lt;/span&gt; &lt;span class="nf"&gt;stringWithCapacity&lt;/span&gt;&lt;span class="p"&gt;:(&lt;/span&gt;&lt;span class="n"&gt;deviceToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NSUInteger&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;deviceToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;i&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="n"&gt;hex&lt;/span&gt; &lt;span class="nf"&gt;appendFormat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;@"%02x"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;i&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="n"&gt;NSNotificationCenter&lt;/span&gt; &lt;span class="nf"&gt;defaultCenter&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="nl"&gt;postNotificationName:&lt;/span&gt;&lt;span class="s"&gt;@"DeviceTokenRegistered"&lt;/span&gt;
                    &lt;span class="nl"&gt;object:&lt;/span&gt;&lt;span class="nb"&gt;nil&lt;/span&gt;
                  &lt;span class="nl"&gt;userInfo:&lt;/span&gt;&lt;span class="p"&gt;@{&lt;/span&gt; &lt;span class="s"&gt;@"token"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hex&lt;/span&gt; &lt;span class="p"&gt;}];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the documented part. Three things aren't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;iOS 18+ silently drops the first registration call&lt;/strong&gt; if you make it immediately after the user grants permission. No error, no callback, nothing. A 1.5 to 2 second delay and one retry recovers from it almost every time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight objective_c"&gt;&lt;code&gt;&lt;span class="n"&gt;dispatch_after&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dispatch_time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DISPATCH_TIME_NOW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int64_t&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;NSEC_PER_SEC&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
               &lt;span class="n"&gt;dispatch_get_main_queue&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;UIApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sharedApplication&lt;/span&gt; &lt;span class="nf"&gt;isRegisteredForRemoteNotifications&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="n"&gt;UIApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sharedApplication&lt;/span&gt; &lt;span class="nf"&gt;registerForRemoteNotifications&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn't in any Apple doc. I found it after enough TestFlight users on iOS 18 reported notifications "just not working."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tokens change.&lt;/strong&gt; APNs rotates them on app reinstall, restore-from-backup, or migration to a new device. FCM rotates on its own schedule. If you don't dedupe registrations, you re-register the same device on every cold start and your backend's device tables grow forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token callbacks outlive their context.&lt;/strong&gt; A token refresh that fired pre-logout can land post-logout, and re-bind the device to the previous user. The cheap fix: hold a &lt;code&gt;lastRegisteredToken&lt;/code&gt; ref and short-circuit any callback whose token matches what you just sent.&lt;/p&gt;

&lt;p&gt;That's three places one line of pseudocode is hiding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Permissions are three different things
&lt;/h2&gt;

&lt;p&gt;iOS, Android pre-13, Android 13+. Each one is a different model, and you handle all three in one codebase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;iOS&lt;/strong&gt; is the cleanest. Request &lt;code&gt;Alert | Sound | Badge&lt;/code&gt; once, the OS gives you a yes/no. There are extras most apps never use: provisional authorization (notifications go straight to the notification center), critical alerts (bypass Do Not Disturb), time-sensitive alerts on iOS 15+. The core flow is one async call. Catch: if the user denies, you can never re-prompt. They go to Settings or nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Android pre-13&lt;/strong&gt; has no runtime permission for notifications at all. As long as the user installed your app, you can post. &lt;em&gt;But&lt;/em&gt; on Android 8+ you have to create a notification channel first or the notification is silently dropped. The channel is also where importance, sound, and vibration live, not the notification itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;ensureChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Context&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="nc"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;VERSION&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SDK_INT&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nc"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;VERSION_CODES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;O&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;channel&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;NotificationChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nc"&gt;CHANNEL_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s"&gt;"General"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nc"&gt;NotificationManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;IMPORTANCE_HIGH&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"App notifications"&lt;/span&gt;
      &lt;span class="nf"&gt;enableVibration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;manager&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSystemService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NOTIFICATION_SERVICE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nc"&gt;NotificationManager&lt;/span&gt;
    &lt;span class="n"&gt;manager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createNotificationChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;channel&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;&lt;code&gt;IMPORTANCE_HIGH&lt;/code&gt; is what enables heads-up banners. &lt;code&gt;IMPORTANCE_DEFAULT&lt;/code&gt; puts the notification in the tray with no popup. The user can override your importance from settings, and you have no recourse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Android 13+&lt;/strong&gt; added &lt;code&gt;POST_NOTIFICATIONS&lt;/code&gt; as a runtime permission. You declare it in the manifest, request it at runtime, &lt;em&gt;and&lt;/em&gt; check the SDK level before requesting because the API doesn't exist below 33. Like iOS, a denied permission has to be re-granted from system settings.&lt;/p&gt;

&lt;p&gt;The branch you actually write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;VERSION&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SDK_INT&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nc"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;VERSION_CODES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TIRAMISU&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="nc"&gt;ContextCompat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;checkSelfPermission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;POST_NOTIFICATIONS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="nc"&gt;GRANTED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;ActivityCompat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestPermissions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;arrayOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;POST_NOTIFICATIONS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;ensureChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three platforms, three permission models, one codebase. None of this is in the tutorial.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three app states, three completely different code paths
&lt;/h2&gt;

&lt;p&gt;This is the centerpiece of the iceberg. The same notification has to be handled three ways, depending on what the app is doing when it arrives.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;iOS callback&lt;/th&gt;
&lt;th&gt;Android callback&lt;/th&gt;
&lt;th&gt;OS shows banner?&lt;/th&gt;
&lt;th&gt;App can react before tap?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Foreground&lt;/td&gt;
&lt;td&gt;&lt;code&gt;willPresentNotification:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;onMessageReceived&lt;/code&gt; (FCM service)&lt;/td&gt;
&lt;td&gt;No (you choose)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;didReceiveNotificationResponse:&lt;/code&gt; (on tap)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;onMessageReceived&lt;/code&gt; + tap intent&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quit / killed&lt;/td&gt;
&lt;td&gt;&lt;code&gt;launchOptions[...RemoteNotificationKey]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Intent extra in &lt;code&gt;MainActivity&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each row hides something.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Foreground.&lt;/strong&gt; iOS does &lt;em&gt;not&lt;/em&gt; show a system banner when the app is open. You decide. The decision is one option mask returned from &lt;code&gt;willPresentNotification:&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight objective_c"&gt;&lt;code&gt;&lt;span class="k"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;userNotificationCenter&lt;/span&gt;&lt;span class="p"&gt;:(&lt;/span&gt;&lt;span class="n"&gt;UNUserNotificationCenter&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;center&lt;/span&gt;
       &lt;span class="nf"&gt;willPresentNotification&lt;/span&gt;&lt;span class="p"&gt;:(&lt;/span&gt;&lt;span class="n"&gt;UNNotification&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;notification&lt;/span&gt;
         &lt;span class="nf"&gt;withCompletionHandler&lt;/span&gt;&lt;span class="p"&gt;:(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;^&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="n"&gt;UNNotificationPresentationOptions&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="nv"&gt;handler&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;NSDictionary&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;aps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;userInfo&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;@"aps"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="n"&gt;BOOL&lt;/span&gt; &lt;span class="n"&gt;silent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;aps&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;@"content-available"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="nf"&gt;intValue&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;aps&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;@"alert"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;silent&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;UNNotificationPresentationOptionNone&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;UNNotificationPresentationOptionAlert&lt;/span&gt;
        &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;UNNotificationPresentationOptionSound&lt;/span&gt;
        &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;UNNotificationPresentationOptionBadge&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;Skip this delegate method and foreground notifications vanish. You'll spend a week wondering why testing-on-device fails to reproduce what users are seeing.&lt;/p&gt;

&lt;p&gt;Android has no equivalent. The FCM service runs on every notification regardless of state, so you detect foreground yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;info&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ActivityManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RunningAppProcessInfo&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nc"&gt;ActivityManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getMyMemoryState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;isForeground&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;importance&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;IMPORTANCE_FOREGROUND&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Foreground: suppress the system notification, let JS render its own banner. Background: build the system notification and post it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Background.&lt;/strong&gt; The OS shows the banner. Your code runs only when the user taps. iOS hands you the payload via &lt;code&gt;didReceiveNotificationResponse:&lt;/code&gt;. Android hands you the intent in &lt;code&gt;MainActivity.onCreate&lt;/code&gt; or &lt;code&gt;onNewIntent&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quit.&lt;/strong&gt; The app process is dead. The OS shows the banner. On tap, the app cold-starts, and your notification handler doesn't exist yet. JavaScript hasn't been loaded. The payload is delivered as a launch option (iOS) or an intent extra (Android), and you have to retrieve it &lt;em&gt;after&lt;/em&gt; React mounts:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;initial&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;getInitialNotification&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;initial&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setRedirectTarget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parseNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;bootingApp&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Android, the cleanest move is to write the payload to SharedPreferences in the FCM service when the app isn't running, then read it back when JS comes online. Skip this and the launch intent gets consumed by the OS before your code is alive to see it.&lt;/p&gt;

&lt;p&gt;The mistake I made first was writing one handler. They look like the same event from JS. They are not. The cold-start payload on iOS is shaped differently from the foreground one. The Android intent doesn't look like the FCM message your foreground handler receives. We'll get to that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The race nobody warns you about
&lt;/h2&gt;

&lt;p&gt;A user taps a notification. The app was killed. The OS hands you the payload before:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;React has mounted&lt;/li&gt;
&lt;li&gt;Your navigation container is ready&lt;/li&gt;
&lt;li&gt;Auth state has hydrated from storage&lt;/li&gt;
&lt;li&gt;The initial data fetch has resolved&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Call &lt;code&gt;navigation.navigate(...)&lt;/code&gt; here and three things can happen, all bad. You crash. You no-op silently and the user is stuck on the splash screen. Or you navigate, but auth is empty. The screen renders unauthenticated and bounces them to login. Confusing, since they came in through a notification that implied they were already signed in.&lt;/p&gt;

&lt;p&gt;The fix is a "deferred redirect" pattern. Notifications never navigate directly. They write a target into context state. A separate effect watches both the target and a &lt;code&gt;readyToRedirect&lt;/code&gt; flag, and only fires when every precondition holds:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;readyToRedirect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;isLoggedIn&lt;/span&gt;
     &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isFirstLogin&lt;/span&gt;
     &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;hasSeenOnboarding&lt;/span&gt;
     &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isHydrating&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isLoggedIn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isFirstLogin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hasSeenOnboarding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isHydrating&lt;/span&gt;&lt;span class="p"&gt;]&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;readyToRedirect&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;redirectTarget&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;navigationRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;CommonActions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Home&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Detail&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;redirectTarget&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="nf"&gt;setRedirectTarget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;readyToRedirect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;redirectTarget&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same pattern handles a more annoying case: the user is in the foreground, a notification comes in, they tap the in-app banner, but their session expired between the banner appearing and the tap. The redirect waits in state until auth re-hydrates, then fires. The tap doesn't get lost.&lt;/p&gt;

&lt;p&gt;The corollary is about &lt;em&gt;when&lt;/em&gt; you register listeners. Don't register on app startup. Register only when &lt;code&gt;appStateVisible === 'active' &amp;amp;&amp;amp; isLoggedIn&lt;/code&gt;. Otherwise the very first launch (fresh install, logged out, no auth state) fires a redirect to a screen the user can't open, and the navigator throws.&lt;/p&gt;

&lt;p&gt;A "wait until ready" gate sounds trivial until you count how many things have to be ready. In production I count four: auth, navigation, hydrated storage, first paint. Miss any one and the bug only reproduces on cold starts triggered by notifications. Every notification.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payload is not the payload
&lt;/h2&gt;

&lt;p&gt;A notification on iOS arrives in three or four shapes depending on app state.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Foreground&lt;/strong&gt;: top-level &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;body&lt;/code&gt;, custom data on &lt;code&gt;customData&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold start&lt;/strong&gt; (from &lt;code&gt;launchOptions&lt;/code&gt;): &lt;code&gt;aps.alert.title&lt;/code&gt;, &lt;code&gt;aps.alert.body&lt;/code&gt;, custom data still on &lt;code&gt;customData&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent push&lt;/strong&gt;: &lt;code&gt;aps.content-available: 1&lt;/code&gt;, no alert, custom data only&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Android adds another twist. FCM only allows string-string maps in the &lt;code&gt;data&lt;/code&gt; field, so any structured custom data is &lt;strong&gt;JSON-stringified&lt;/strong&gt; by the sender. You parse it on the JS side, in a try/catch, with a logged warning on failure. Skip the try/catch and a single malformed payload from the backend silently breaks your entire notification handler.&lt;/p&gt;

&lt;p&gt;The only sane move is a normalizer. One &lt;code&gt;parseNotification&lt;/code&gt; that returns a single shape, and the rest of the app only ever sees that 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="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ParsedNotification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;redirect&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;silent&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;broadcast&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;targetId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RawNotification&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ParseOpts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;ParsedNotification&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;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;android&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;safeJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;broadcast&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;targetId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&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;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bootingApp&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="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customData&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;broadcast&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="na"&gt;targetId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customData&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customData&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;broadcast&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="na"&gt;targetId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customData&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tests cover each variant: cold-start iOS, foreground iOS, Android with valid JSON, Android with malformed JSON. The malformed-JSON test is the one that catches the bug. Every time the backend ships a payload change without telling mobile, the test fails before it ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bits you'll only learn by shipping
&lt;/h2&gt;

&lt;p&gt;Things that aren't in any tutorial. I learned each of these from a bug report.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Notification Service Extensions have a 30-second budget.&lt;/strong&gt; If you're modifying notifications in a Service Extension (decryption, downloading rich media, sending delivery receipts), the OS kills the extension and delivers the original payload if you overshoot. Set a 5-second timeout on any HTTP work the extension does. The user gets the notification either way, but you don't want a silently-truncated payload because the network was slow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;App icon badges are yours to manage.&lt;/strong&gt; The OS doesn't decrement them when the user reads a notification. You set the count from the app, from the server, or both, and reset to zero when the relevant view mounts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delivery receipts are a fourth code path.&lt;/strong&gt; If you want to know which notifications were actually delivered, you wire receipts in three places: the Service Extension fires "received" before the user sees the banner, the foreground/tap handler fires "read on tap," and silent push fires "received" again. None of these deduplicate. The backend has to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token refresh fires while you're logged out.&lt;/strong&gt; FCM rotates on its own schedule. If your registration code doesn't check whether anyone's logged in, you'll re-bind the device to a stale or wrong user. Guard with a stored &lt;code&gt;lastRegisteredFor&lt;/code&gt; and a "user is currently logged in" check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Direct boot.&lt;/strong&gt; If you want notifications to surface while the device is encrypted-and-locked (post-reboot, pre-unlock), your FCM service needs &lt;code&gt;directBootAware="true"&lt;/code&gt; in the manifest, and you can only access protected storage. Most apps don't need this. The ones that do really need it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Foreground listener leaks.&lt;/strong&gt; Register on resume, unregister on background. Otherwise context updates fire after the relevant component unmounts, and you log a &lt;code&gt;setState on unmounted component&lt;/code&gt; warning every time a notification arrives. The user sees nothing wrong. Your error logs fill up.&lt;/p&gt;

&lt;p&gt;None of these are in the README. They become the README, after.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got back
&lt;/h2&gt;

&lt;p&gt;What I have now is a push notification system that survives every state combination I've actually seen: cold start, mid-login, kill-and-relaunch, OS reboot, denied-then-granted permissions, foreground notification while a session is timing out. The path the user actually takes is rarely the one in the demo video.&lt;/p&gt;

&lt;p&gt;A few concrete things came out of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;parseNotification&lt;/code&gt; normalizer with tests covering four payload shapes, runnable in plain Node, no native bridges required&lt;/li&gt;
&lt;li&gt;A clean seam: native owns registration, foreground display, OS plumbing, intent retrieval. JS owns parsing, navigation, product logic. They don't drift because they speak through one event shape.&lt;/li&gt;
&lt;li&gt;A deferred-redirect pattern that handles cold-start, foreground, and re-auth races with one piece of code&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the cost. Every iOS major version, every Android API bump, I have to revisit this. The libraries would have absorbed some of it. I chose not to use them because the failure modes I cared about (iOS 18 registration, cold-start payload divergence, silent push receipts, the navigation race) were already filed against those libraries, unfixed. That's the trade I made. I'd make it again, knowing the maintenance cost up front.&lt;/p&gt;

&lt;p&gt;Push notifications were a one-line item on the roadmap. The line item was true. It just wasn't the whole shape.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>mobile</category>
      <category>ios</category>
      <category>android</category>
    </item>
    <item>
      <title>The loop that changed how I write mobile tests</title>
      <dc:creator>Amanda Gama</dc:creator>
      <pubDate>Sun, 03 May 2026 03:59:22 +0000</pubDate>
      <link>https://forem.com/aoligama/the-loop-that-changed-how-i-write-mobile-tests-3mf3</link>
      <guid>https://forem.com/aoligama/the-loop-that-changed-how-i-write-mobile-tests-3mf3</guid>
      <description>&lt;p&gt;Mobile tests are where the bugs actually live. A signup flow that works on an iPhone 15 falls apart on a lower-end Android because the keyboard pushes a button off-screen. A push notification mid-flow leaves the app in a state nothing else reproduces. Memory pressure on a four-year-old Android does things you can't make a simulator do.&lt;/p&gt;

&lt;p&gt;I wrote simulator-only tests anyway, for years. Real-device runs took ten to thirty minutes per cycle, the device farm queue was unpredictable, and CI on physical hardware was expensive enough that someone always wanted to talk about cost. So tests got written for the simulator, the device-specific bugs found their way to production, and we caught them in Sentry instead of in CI.&lt;/p&gt;

&lt;p&gt;The thing that changed wasn't a better device farm or a faster CI. It was Claude Code writing the tests, BrowserStack MCP driving the devices, and the whole loop closing inside a single session. Claude writes a test, BrowserStack runs it on an actual iPhone, Claude reads the failure (screenshot, view tree, error trace) and fixes the test. Then it runs again. The cycle is under a minute. I never opened the test file.&lt;/p&gt;

&lt;p&gt;That's the post. The tools matter, but what changed test authoring was the loop closing fast enough that I could stay in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape that changed
&lt;/h2&gt;

&lt;p&gt;The mental model is simple. Old loop: write a test, push, wait for CI, read logs, guess at the failure, fix, push again. Best case ten minutes per iteration, often half an hour. Most of the wait is travel time: uploading the build, queuing for a device, tearing down. Almost none of it is the test itself.&lt;/p&gt;

&lt;p&gt;New loop: Claude writes a test, the BrowserStack MCP runs it on a real device that's already provisioned, Claude reads the structured failure (screenshot, accessibility tree, console output, error stack), edits the test, runs it again. End-to-end under a minute on the happy path.&lt;/p&gt;

&lt;p&gt;The speed isn't the value. The value is that the loop stays tight enough to stay in. Ten-minute iterations mean you context-switch out and come back cold. Sub-minute iterations mean the same problem is still in your head when the next result lands. You think about the test, not about what you were doing while you waited for CI.&lt;/p&gt;

&lt;p&gt;The MCP is mostly invisible in this. It's just "Claude can drive real devices the way it drives anything else."&lt;/p&gt;

&lt;h2&gt;
  
  
  What the loop actually looks like
&lt;/h2&gt;

&lt;p&gt;The clearest example was when I asked Claude to build an onboarding flow test from scratch: fresh install through to the home screen, with sign-up, email verification, and a multi-step intro along the way.&lt;/p&gt;

&lt;p&gt;I described it in plain English. Claude wrote the first version: a script with the steps, selectors pulled from the screenshot it took on app launch, and an assertion at each milestone. It started a session on an iPhone 15, ran the test, and the test failed at step two.&lt;/p&gt;

&lt;p&gt;The failure was a selector ambiguity. There were two elements matching &lt;code&gt;[name="email"]&lt;/code&gt;: the visible field and a hidden form used for autofill detection. Claude saw both in the accessibility tree, picked the wrong one, and the typed text vanished into the hidden one. The test waited for the "verify your email" screen and timed out.&lt;/p&gt;

&lt;p&gt;Claude fixed the selector to &lt;code&gt;getByRole('textbox', { name: 'Email address' })&lt;/code&gt; and re-ran. Past step two. Failed at step four. The third onboarding screen has an animated transition, and the tap on the next button fired before the animation finished, so the tap landed on a different element underneath. Claude added a wait on the transition end. Past step four. Eventually green.&lt;/p&gt;

&lt;p&gt;Then I asked it to run the same test on a lower-end Android. Same code, different device. Failed on step three. The email verification deep link opens differently on Android. The Gmail app intercepts before the browser does, and the test was driving Chrome. Claude rewrote the verification step to use an alternative verification strategy for tests instead of clicking the link in the email. Re-ran on both devices. Green on both.&lt;/p&gt;

&lt;p&gt;This whole sequence took maybe fifteen minutes. I didn't open a test file. I read every diff in the chat, said "yes" or "wait, why" three or four times, and the test ended up in the repo. The thing that made it work wasn't any single capability. It was that I could stay in the loop the whole time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where my role moved
&lt;/h2&gt;

&lt;p&gt;I wasn't writing test code. I was reading it.&lt;/p&gt;

&lt;p&gt;The most useful thing I did was catch the moments where Claude "fixed" a test by making it less strict. The pattern I learned to watch for: a test starts failing intermittently, Claude adds a wait longer than the original timeout, the test goes green. The wait isn't fixing flakiness. The wait is hiding it.&lt;/p&gt;

&lt;p&gt;The cleanest example was a test that asserted the home screen rendered within 200ms after login. It started failing on the lower-end Android. Claude bumped the wait to ten seconds, the test passed, and we moved on. I caught it in review: the home screen was actually taking five seconds to render on that device because of an image-decode regression that had landed two days earlier. The test was supposed to catch exactly that. The "fix" deleted the catch.&lt;/p&gt;

&lt;p&gt;This is the part the "LLM writes the tests" pitch always undersells. Claude is fast enough at authoring that it'll write a hundred tests in the time it would take me to write ten. Most of those tests are fine. Plenty pass because they assert close to nothing. If you don't read them, you ship a green suite that catches nothing, which is worse than no suite at all.&lt;/p&gt;

&lt;p&gt;The bottleneck moved from authoring to judgment. Authoring is the part Claude is fast at. Judgment (does this test catch the right thing, is this assertion meaningful, is this wait masking a regression) is the part that's still mine. The work didn't disappear. It changed shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  The MCP setup is where the time went
&lt;/h2&gt;

&lt;p&gt;The loop only works when the MCP layer is reliable. When it isn't, you spend the day debugging the harness instead of writing tests.&lt;/p&gt;

&lt;p&gt;The first day was almost entirely setup. BrowserStack credentials, MCP server config, tool permissions in Claude Code, the allowed device pool, network rules for the test backend. Each piece is documented somewhere. None of them are documented in the same place. I had four tabs open the whole time.&lt;/p&gt;

&lt;p&gt;The tool surface is the bigger thing to plan for. The BrowserStack MCP exposes a finite set of actions: start session, run command, take screenshot, fetch logs. Most of what you want maps onto that cleanly. A few things don't. Driving a native permission dialog on iOS isn't a clean MCP action; you end up wrapping platform-specific helpers or carving a backdoor into the app for tests. Anything biometric is off the table without that backdoor.&lt;/p&gt;

&lt;p&gt;Session lifetime caught me twice. BrowserStack sessions time out after a fixed window, devices disconnect under load, and the queue backs up during US business hours. The loop has to survive a session dying mid-test. I added a thin wrapper that re-acquires the device when the MCP returns a session-ended error and retries from the last clean assertion. That wrapper isn't optional. Without it, every long test run had a 30% chance of dying for no reason related to the code under test.&lt;/p&gt;

&lt;p&gt;Permissions in Claude Code itself were the smaller surprise. By default, Claude asks before each tool call. The fiftieth time it asks to call the same BrowserStack action is the moment you regret it. I set a permission rule allowing that specific tool without prompts, and the loop got noticeably tighter. Skip this and you spend the day clicking "yes."&lt;/p&gt;

&lt;p&gt;None of that is the loop changing test authoring. It's the boring tax for getting there. But it's a real day or two of setup, and skipping it in this post would be misleading. The pitch is "Claude writes the tests"; the reality is "Claude writes the tests after you spend a day making the harness work."&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually get
&lt;/h2&gt;

&lt;p&gt;Coverage we wouldn't have written by hand. The cost of writing a test dropped enough that the question stopped being "is this worth a test" and started being "is this worth reading." We added tests for flows we'd been ignoring for years (the edge paths, the rare-but-real failure modes) because the marginal cost was small enough not to argue about.&lt;/p&gt;

&lt;p&gt;Real-device coverage in CI, instead of simulator-only with prayers. The lower-end Android catches the things the iPhone 15 doesn't. The 200ms-versus-five-second image regression I mentioned earlier would have shipped if the test had stayed on a simulator.&lt;/p&gt;

&lt;p&gt;The hidden cost is the one I keep coming back to: you have to read what Claude writes. If you don't, you accumulate tests that pass without testing anything, and the suite stops being a signal. The discipline shifts from authoring to reviewing. Most teams assume whoever writes the test is the one reviewing it. That works when writing is the slow step. When writing is fast and reviewing is the slow step, the assumption breaks. I haven't seen many teams notice yet.&lt;/p&gt;

&lt;p&gt;It's not autonomous. The day it is, the discipline that survives won't be writing tests. It'll be deciding which ones still mean something.&lt;/p&gt;

</description>
      <category>android</category>
      <category>ios</category>
      <category>mobile</category>
      <category>testing</category>
    </item>
    <item>
      <title>Migrating a legacy React app from webpack to Vite</title>
      <dc:creator>Amanda Gama</dc:creator>
      <pubDate>Sun, 03 May 2026 02:57:49 +0000</pubDate>
      <link>https://forem.com/aoligama/migrating-a-legacy-react-app-from-webpack-to-vite-9kl</link>
      <guid>https://forem.com/aoligama/migrating-a-legacy-react-app-from-webpack-to-vite-9kl</guid>
      <description>&lt;p&gt;The codebase was old. React 16 with class components everywhere. React Router v3 with routes-as-children. A webpack 4 config that had been edited by a dozen people over five years and contained loaders nobody could explain. The dev server took 45 seconds to come up. Hot reload was 8 seconds on a good day, 20 on a bad one. The production build was 6 minutes. CI deploys took 14 minutes end to end.&lt;/p&gt;

&lt;p&gt;I'd been dismissing Vite for two years. "Webpack works." It did work. It was also bleeding an hour a day off the team in dev server restarts, slow HMR cycles, and CI queues piling up behind each other.&lt;/p&gt;

&lt;p&gt;Here's the catch: you don't migrate a legacy webpack project to Vite on its own. You end up migrating webpack, React, React Router, your test runner, and half your dependencies, because the things that make Vite fast are the same things that won't tolerate code from 2018. This post is about what that actually looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Vite actually does differently
&lt;/h2&gt;

&lt;p&gt;The mental model shift is small, but it changes everything that follows.&lt;/p&gt;

&lt;p&gt;Webpack bundles your app before serving it. Every dev server start walks the dependency graph, compiles every module it touches, links them, and produces a bundle. The bigger your app, the longer the wait, and it scales linearly. HMR is fast in theory, but in practice the graph invalidation is heavy enough that a 200ms code change turns into a 4-second rebuild.&lt;/p&gt;

&lt;p&gt;Vite serves source files directly to the browser as native ES modules. Your &lt;code&gt;App.tsx&lt;/code&gt; is requested by the browser, transformed on demand by esbuild, and sent back. Nothing gets bundled in dev. The cost of starting the server is the cost of starting a Node process plus reading config, not the cost of compiling your app.&lt;/p&gt;

&lt;p&gt;For production, Vite uses Rollup. Bundling still happens, just not in the dev loop where it hurts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;webpack dev:    parse all -&amp;gt; compile all -&amp;gt; bundle -&amp;gt; serve
vite dev:       start server -&amp;gt; compile what the browser asks for
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole insight. Everything else is a consequence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;The dev server times told the story I expected:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;webpack&lt;/th&gt;
&lt;th&gt;Vite&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cold start&lt;/td&gt;
&lt;td&gt;42s&lt;/td&gt;
&lt;td&gt;1.1s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HMR (small change)&lt;/td&gt;
&lt;td&gt;4-8s&lt;/td&gt;
&lt;td&gt;50-200ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full reload&lt;/td&gt;
&lt;td&gt;6-9s&lt;/td&gt;
&lt;td&gt;400ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The build times were less dramatic but still real:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;webpack&lt;/th&gt;
&lt;th&gt;Vite&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Production build&lt;/td&gt;
&lt;td&gt;5m 40s&lt;/td&gt;
&lt;td&gt;1m 50s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type check (separate)&lt;/td&gt;
&lt;td&gt;45s&lt;/td&gt;
&lt;td&gt;45s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The interesting part is type checking. Vite doesn't type-check your code. It strips types with esbuild and trusts you. If you want type safety in CI, you run &lt;code&gt;tsc --noEmit&lt;/code&gt; separately. That stays exactly as slow as it was. So don't expect Vite to magically speed up TypeScript. It makes the &lt;em&gt;transformation&lt;/em&gt; faster, not the checking.&lt;/p&gt;

&lt;h2&gt;
  
  
  What CI actually looked like before and after
&lt;/h2&gt;

&lt;p&gt;The CI deploy was the win I didn't expect. Total deploy time went from 14 minutes to 5. The build itself only accounted for about 4 minutes of that improvement. The other 5 minutes came from second-order effects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Smaller Docker layer.&lt;/strong&gt; Rollup tree-shakes more aggressively than my webpack config ever did. Bundle size dropped 18%, and the image pushed faster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No more cache thrashing.&lt;/strong&gt; Webpack's persistent cache was 800MB, and CI was spending 90 seconds restoring it every run. Vite doesn't need one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parallel-friendly.&lt;/strong&gt; Type check, lint, and build can run as three independent jobs. With webpack, the build dominated everything else, so splitting was pointless.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CI cost dropped about in line with wall-clock time. Not surprising, but watching the bill go down felt good.&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration itself
&lt;/h2&gt;

&lt;p&gt;The webpack-to-Vite swap on its own is a two-day job. On a legacy codebase, you don't get that luxury. The whole thing took closer to three weeks once you count the React upgrade, the router rewrite, and the test runner change that came along with it.&lt;/p&gt;

&lt;p&gt;Start with the easy part: the build tool config.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--save-dev&lt;/span&gt; vite @vitejs/plugin-react
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A minimal &lt;code&gt;vite.config.ts&lt;/code&gt;:&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&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;vite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;react&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;@vitejs/plugin-react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;path&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;path&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;react&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
  &lt;span class="na"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;alias&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;__dirname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./src&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;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;outDir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dist&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;sourcemap&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;index.html&lt;/code&gt; moves to the project root and becomes the entry point, not a template anymore. That's a real shift. Webpack treated HTML as an output. Vite treats it as the input. Your script tag points at your TS entry directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"module"&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/src/main.tsx"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once that clicks, half the config changes make sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  What came along for the ride
&lt;/h2&gt;

&lt;p&gt;This is the part the tutorials skip. Vite assumes a modern stack. A legacy app doesn't have one, and that gap is where most of the real work hides.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;React itself.&lt;/strong&gt; React 16 will technically run under Vite, but &lt;code&gt;@vitejs/plugin-react&lt;/code&gt; expects the new JSX transform that landed in React 17. You can pin an older plugin and limp along, or take the upgrade. I took the upgrade. Going to React 18 was straightforward once the build was clean, and Strict Mode caught a handful of effect bugs that had been hiding for years. Class components keep working. They just look increasingly out of place next to everything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;React Router.&lt;/strong&gt; This was the biggest single chunk of work. v3's API (routes-as-children, &lt;code&gt;browserHistory&lt;/code&gt; as a singleton, &lt;code&gt;onEnter&lt;/code&gt; hooks for auth) is just not compatible with v6's declarative &lt;code&gt;&amp;lt;Routes&amp;gt;&lt;/code&gt; and &lt;code&gt;useNavigate&lt;/code&gt;. There's no codemod that does it cleanly. I migrated in stages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// v3&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Router&lt;/span&gt; &lt;span class="na"&gt;history&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;browserHistory&lt;/span&gt;&lt;span class="si"&gt;}&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;Route&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt; &lt;span class="na"&gt;component&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;App&lt;/span&gt;&lt;span class="si"&gt;}&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;Route&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"users/:id"&lt;/span&gt; &lt;span class="na"&gt;component&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;UserPage&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onEnter&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;requireAuth&lt;/span&gt;&lt;span class="si"&gt;}&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;Route&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;Router&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// v6&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;BrowserRouter&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;Routes&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;Route&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt; &lt;span class="na"&gt;element&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;lt;&lt;/span&gt;&lt;span class="nc"&gt;App&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;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Route&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"users/:id"&lt;/span&gt; &lt;span class="na"&gt;element&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;lt;&lt;/span&gt;&lt;span class="nc"&gt;RequireAuth&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UserPage&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;RequireAuth&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;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Route&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;Routes&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;BrowserRouter&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;The mechanical translation is easy. The painful part is everywhere the old code reached into the router imperatively: &lt;code&gt;browserHistory.push()&lt;/code&gt; from a saga, &lt;code&gt;withRouter&lt;/code&gt; HOCs wrapping class components, route lifecycle hooks doing data fetching. Each one needs a real rewrite.&lt;/p&gt;

&lt;p&gt;Budget honestly for this. The router upgrade took longer than the actual Vite swap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Babel plugins you forgot you had.&lt;/strong&gt; The old webpack config was running half a dozen Babel plugins for proposal-stage syntax that's been in the language for years: optional chaining, nullish coalescing, class properties. esbuild handles all of those natively, so you can delete them. But if any plugin was doing real work (a custom transform, an i18n extractor, an instrumentation pass), that has to become a Vite plugin or move to a separate step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The dependency graveyard.&lt;/strong&gt; Every legacy project has one. A charting library that hasn't published since 2019. A date picker pinned to a specific patch version. A state library the company forked internally. Anything shipping pure CommonJS without an &lt;code&gt;exports&lt;/code&gt; field is going to fight Vite's dep optimizer. Some you can pre-bundle. Some need replacing. A few you'll end up patching with &lt;code&gt;patch-package&lt;/code&gt; because the maintainer is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vite-specific gotchas
&lt;/h2&gt;

&lt;p&gt;Beyond the legacy upgrade work, these are the traps that hit any webpack-to-Vite migration:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Environment variables.&lt;/strong&gt; &lt;code&gt;process.env.FOO&lt;/code&gt; becomes &lt;code&gt;import.meta.env.VITE_FOO&lt;/code&gt;, and only variables prefixed with &lt;code&gt;VITE_&lt;/code&gt; are exposed to the client. That's a security feature (webpack would happily leak any env var you referenced), but it broke about 40 references in my codebase. Find-and-replace is your friend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dynamic imports with variable paths.&lt;/strong&gt; This works in webpack and breaks in Vite:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mod&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`./locales/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vite needs to know the set of possible matches at build time. Use &lt;code&gt;import.meta.glob&lt;/code&gt;:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;modules&lt;/span&gt; &lt;span class="o"&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;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./locales/*.json&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;mod&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;modules&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`./locales/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json`&lt;/span&gt;&lt;span class="p"&gt;]()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;CommonJS dependencies.&lt;/strong&gt; Vite is ESM-first. Most modern packages are fine. Older ones with &lt;code&gt;module.exports&lt;/code&gt; need &lt;code&gt;optimizeDeps.include&lt;/code&gt; to pre-bundle them. Otherwise you get cryptic "default export" errors at runtime, and they only show up in production, because dev doesn't bundle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Jest doesn't speak Vite.&lt;/strong&gt; If you used &lt;code&gt;jest&lt;/code&gt; with &lt;code&gt;babel-jest&lt;/code&gt; configured to match webpack, your test setup is now misaligned with your build. The cleanest path is Vitest, which shares Vite's transform pipeline. The migration is mostly mechanical (&lt;code&gt;describe&lt;/code&gt;, &lt;code&gt;it&lt;/code&gt;, &lt;code&gt;expect&lt;/code&gt; are the same), but the mocking syntax differs from Jest in spots. Budget half a day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Public path / base URL.&lt;/strong&gt; If your app is served from a subpath, webpack's &lt;code&gt;publicPath&lt;/code&gt; becomes Vite's &lt;code&gt;base&lt;/code&gt;. Forget this and you get a working dev build and a broken production deploy. I learned that one the hard way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What didn't get better
&lt;/h2&gt;

&lt;p&gt;Bundle analysis is worse. Webpack's analyzer ecosystem is mature. Vite's is fine, but less detailed. If you spend real time tuning bundle size, you'll feel the gap.&lt;/p&gt;

&lt;p&gt;The plugin ecosystem is smaller. Most things you need exist, but expect to occasionally write a Rollup plugin where webpack would've had three options.&lt;/p&gt;

&lt;p&gt;SSR setup is more hands-on. Vite supports it, but you wire it together yourself. If you were running a framework on top of webpack that handled SSR for you, switch to a framework on top of Vite (Remix, Next, SvelteKit) instead of doing it raw.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth it
&lt;/h2&gt;

&lt;p&gt;For a greenfield project, Vite is a no-brainer. For a legacy app, it's messier. You're not just swapping a build tool. You're paying down years of tech debt because Vite forces you to. That's a feature, not a bug. Just budget for it honestly.&lt;/p&gt;

&lt;p&gt;The dev experience win paid for itself the first week the team was on the new stack. After years of training your muscle memory to tolerate slow HMR, watching it feel instant is a small daily joy.&lt;/p&gt;

&lt;p&gt;The deploy time win mattered more than I expected. Halving CI meant we merged to main more often, which meant smaller PRs and faster review cycles. That compound effect over a quarter was bigger than the raw numbers suggest.&lt;/p&gt;

&lt;p&gt;The hidden win was the dependency cleanup, and I didn't see it coming. The migration forced a real audit of every package in &lt;code&gt;package.json&lt;/code&gt;. A third of them were dead code or had modern replacements. The codebase that came out the other side was lighter and easier to onboard onto, and that part had nothing to do with Vite directly.&lt;/p&gt;

&lt;p&gt;If you're on legacy webpack and your dev server takes more than 20 seconds to start, migrate. Just don't pitch it to your manager as a two-day job. It's a quarter of modernization work with a fast build tool at the end of it, and the payoff keeps showing up long after you're done.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>performance</category>
      <category>react</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Working remotely, the parts that actually mattered</title>
      <dc:creator>Amanda Gama</dc:creator>
      <pubDate>Sun, 03 May 2026 02:51:28 +0000</pubDate>
      <link>https://forem.com/aoligama/working-remotely-the-parts-that-actually-mattered-299o</link>
      <guid>https://forem.com/aoligama/working-remotely-the-parts-that-actually-mattered-299o</guid>
      <description>&lt;p&gt;I've been working remotely for a while, and most of what I picked up in the first six months turned out to be wrong, or wildly overrated. Not bad advice exactly. Most of it sounds reasonable when you read it. It just isn't doing the work it claimed to. The "wake up at 5am, dedicate a workspace, use the Pomodoro technique, journal every morning" stack is a kind of theater. Some of it helps a little. Most of it is energy you spend trying to feel productive instead of being productive.&lt;/p&gt;

&lt;p&gt;What actually helped was less visible. Almost all of it was about removing small frictions, not adding discipline. The good weeks came from making fewer parts of the day cost mental energy. Not from squeezing more output out of willpower.&lt;/p&gt;

&lt;p&gt;Here's what's stuck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your setup matters, but most of it doesn't
&lt;/h2&gt;

&lt;p&gt;The setup advice you find online tends to confuse two different things: gear that looks good on your desk and gear that actually changes how you work. The list that genuinely helped me is short and boring.&lt;/p&gt;

&lt;p&gt;A second monitor. This is the only piece of "setup" I'd call non-negotiable. Doing most knowledge work on a single laptop screen is the equivalent of writing in a notebook with the previous page glued shut.&lt;/p&gt;

&lt;p&gt;A chair you can sit in for six hours without your back giving notice. You don't need the $1,500 ergonomic one. You need one that doesn't make you fidget by 3pm.&lt;/p&gt;

&lt;p&gt;A decent headset. Not for the audio quality, though that helps. For the "I'm wearing headphones" signal that lets you ignore the world without feeling rude. The mic built into most laptops is fine.&lt;/p&gt;

&lt;p&gt;Enough light. Especially in winter, especially for video calls. A cheap overhead light plus something at face level is the floor.&lt;/p&gt;

&lt;p&gt;Things that didn't change anything for me: a mechanical keyboard, a fancy webcam, a standing desk, a third monitor, cable management trays, a "cozy" lamp, a houseplant. Some of those are nice. None of them changed how much I got done. If you're spending more than a weekend setting up your workspace, you're probably procrastinating with extra steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Routines beat motivation, but lighter than you'd think
&lt;/h2&gt;

&lt;p&gt;The hardcore routine content (wake at 5am, cold shower, journal, gym, deep work block before email) is impressive on Instagram and unsustainable in real life. I tried versions of it. The version that survived was much smaller.&lt;/p&gt;

&lt;p&gt;Two rituals, one rule.&lt;/p&gt;

&lt;p&gt;The first ritual is a fixed start. Same first action every morning, regardless of what's on the calendar. For me it's coffee plus ten minutes reviewing what I want to get done before lunch. Not the whole day. Just the morning. The point isn't planning. It's the transition from "not working" to "working," so you don't burn the first hour ramping up.&lt;/p&gt;

&lt;p&gt;The second is a fixed end. Before closing the laptop, write down the first task you'll start tomorrow. One sentence, on paper or in a note. This sounds stupid until you realize how much energy you waste at the start of every day re-deriving where you left off.&lt;/p&gt;

&lt;p&gt;The rule is: don't open Slack first thing. Anything genuinely urgent will still be urgent in 90 minutes. The first block of the day is the only one where you reliably have full focus, and burning it on reactive replies is the single biggest productivity leak I had to fix.&lt;/p&gt;

&lt;p&gt;What I gave up: time-blocking the whole day, color-coded calendars, hourly check-ins with myself, gratitude journals. Not because they're bad, but because for me they were maintenance overhead disguised as productivity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Communication is half the job
&lt;/h2&gt;

&lt;p&gt;The thing nobody really warns you about is how much of remote knowledge work is writing. Slack messages, PR comments, design docs, status updates, weekly summaries. If you can't write reasonably clearly, remote work is going to be harder than it was in the office, where tone and presence covered a lot of gaps.&lt;/p&gt;

&lt;p&gt;A few things I've changed:&lt;/p&gt;

&lt;p&gt;I default to written updates over meetings. The standing weekly check-in I used to do is now a five-bullet update I write on Friday afternoons. Read in two minutes, no scheduling cost, searchable for later. The meetings I keep are the ones that need actual discussion. Not status, not "alignment," not "syncing."&lt;/p&gt;

&lt;p&gt;I'm slow to reply on purpose. If you answer every Slack within ten minutes, you've trained the team to use you as a synchronous resource, which is the opposite of remote. An informal four-hour SLA on non-urgent threads is usually enough to let people figure things out themselves before pinging.&lt;/p&gt;

&lt;p&gt;I write outcomes, not activities. "Shipped the new auth flow" is useful. "Worked on auth all afternoon" isn't. Everyone's calendar is full of work; what people actually want to know is what came out of it.&lt;/p&gt;

&lt;p&gt;I avoid the 15-minute "quick sync." It's never quick. Either it's a question that could be answered in writing in three minutes, or it's a real conversation that deserves 30 minutes and a doc. The middle case mostly doesn't exist, and treating it like it does is how a calendar gets eaten.&lt;/p&gt;

&lt;h2&gt;
  
  
  Protect deep work like it's a billable hour
&lt;/h2&gt;

&lt;p&gt;Two hours of uninterrupted focus is more valuable than eight hours of fragments. This is the most-repeated and most-ignored piece of remote-work advice. It's true, and most people (me included, until recently) treat it as aspirational rather than enforceable.&lt;/p&gt;

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

&lt;p&gt;Two-hour blocks on the calendar, marked busy, treated as real meetings. Not "focus time." That's too vague to defend against your own future self. Specific: "Architecture review draft: do not book over."&lt;/p&gt;

&lt;p&gt;Notifications off during those blocks. Slack quit, email closed, phone in another room. The phone-in-another-room thing sounds dramatic; it's the single change that made the biggest difference for me. Reaching for the phone to "check something" was costing me twenty minutes per occurrence and I wasn't tracking it.&lt;/p&gt;

&lt;p&gt;One context switch (a non-urgent message, a notification, a quick check of something) costs around twenty minutes of lost focus on hard work. Not because the switch is long, but because the climb back is. If you understand that and still switch, fine. Most of us don't, and the cost compounds across the day.&lt;/p&gt;

&lt;p&gt;The hardest part is letting things sit. Replies waiting, threads moving, a question someone just asked. All of it can wait until your block is done. That's the muscle.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you add travel to the mix
&lt;/h2&gt;

&lt;p&gt;I've been working remotely while traveling for stretches, and the honest version is that travel makes most of the above harder, not easier. The Instagram pitch (laptop on a beach, perfect productivity) is selectively true on a small number of days, and it costs you on most of the others.&lt;/p&gt;

&lt;p&gt;The things that get harder, in rough order of how much they hurt. Time zones first. You're either disconnected from your team for half the day, or answering Slack at midnight. Then internet. A hotspot stops being optional and becomes the actual connection in most places. Then environment. A different desk, different chair, different noise floor every week or two. The compounding effect of "decent enough" beats the effect of "novel" within about a month.&lt;/p&gt;

&lt;p&gt;What helps, if you do it anyway: pick places with infrastructure first and aesthetics second. Carry a backup hotspot on a different carrier. Don't try to start work the day you arrive somewhere new. The first 24 hours is logistics, not output. Protect at least four hours of overlap with your team, wherever you are.&lt;/p&gt;

&lt;p&gt;What you give up is real: deeper rest, a stable rhythm, a setup that gets a little better every month. Travel is fun. It's also a tax on output. If the location is the point, you'll love it. If the work is the point, you'll find that traveling less makes the work easier, which is worth knowing before you sign a six-month lease somewhere far away.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I've stopped believing
&lt;/h2&gt;

&lt;p&gt;A few things I dropped along the way.&lt;/p&gt;

&lt;p&gt;That gear matters much beyond the basics. It doesn't.&lt;/p&gt;

&lt;p&gt;That every hour of the day needs to be productive. Most of mine aren't, even on good days, and trying to force them just adds anxiety to the unproductive ones.&lt;/p&gt;

&lt;p&gt;That working from "cool" places makes you happier. It makes the photos better. The day-to-day feels about the same.&lt;/p&gt;

&lt;p&gt;That async means everything has to be in writing. Some conversations need to be conversations. Trying to write your way through every disagreement, every nuanced design call, every piece of feedback is its own kind of overhead.&lt;/p&gt;

&lt;p&gt;That discipline is the answer. Most days, removing one source of friction beats adding one new habit. The version of remote work that lasted, for me, has fewer rules than the version I started with, and the ones that remain are mostly about what I don't do.&lt;/p&gt;

</description>
      <category>career</category>
      <category>devjournal</category>
      <category>discuss</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Clean architecture in React Native isn't about layers</title>
      <dc:creator>Amanda Gama</dc:creator>
      <pubDate>Fri, 01 May 2026 03:46:40 +0000</pubDate>
      <link>https://forem.com/aoligama/clean-architecture-in-react-native-isnt-about-layers-agl</link>
      <guid>https://forem.com/aoligama/clean-architecture-in-react-native-isnt-about-layers-agl</guid>
      <description>&lt;p&gt;Every React Native codebase I've worked on hits the same wall around month four. A screen that started at 80 lines is now 400. Half of it is &lt;code&gt;useEffect&lt;/code&gt; chains coordinating API calls. A push notification mid-flow leaves the app in a state nobody can reproduce.&lt;/p&gt;

&lt;p&gt;Clean architecture in React Native isn't about folders or layers. It's about whether you can still reason about your app when async, navigation, and native modules collide.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape that fails
&lt;/h2&gt;

&lt;p&gt;The default React Native architecture is "everything lives where it's first needed." API calls land in the handler that triggers them. State sits in the screen that displays it. Native modules get called from the button that activates them. It works for the first month.&lt;/p&gt;

&lt;p&gt;What it doesn't survive:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Async outliving its caller.&lt;/strong&gt; A user kicks off a request, taps a notification, lands on a different screen. The original promise resolves into a setter that no longer makes sense.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native modules in the UI.&lt;/strong&gt; A screen calls &lt;code&gt;NativeModules.Audio.start()&lt;/code&gt; directly. iOS 17 changes the audio session semantics. Three screens break, not one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth races.&lt;/strong&gt; A token refresh fires while three other requests are in flight. Two retry, one logs the user out, one leaks the old token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drift.&lt;/strong&gt; The same "send a message" logic lives in two screens. One gets a validation rule added. The other doesn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern that produces all of this:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleSend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="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;res&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;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/messages&lt;/span&gt;&lt;span class="dl"&gt;'&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="nf"&gt;setMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&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;Nothing wrong with this code on its own. The problem is the second time it gets written, slightly differently, in another screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three layers, one rule
&lt;/h2&gt;

&lt;p&gt;Skip the diagram. The minimum viable framing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Presentation.&lt;/strong&gt; Screens, components, hooks. Renders. Orchestrates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domain.&lt;/strong&gt; Use cases. Pure logic. No &lt;code&gt;react&lt;/code&gt;, no &lt;code&gt;fetch&lt;/code&gt;, no &lt;code&gt;NativeModules&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data.&lt;/strong&gt; API clients, storage, native bridges. Knows about the outside world.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One rule: &lt;strong&gt;the UI talks to the domain, never to data directly.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's the post. The rest is what enforcing that rule does to the bugs above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use cases are the boundary
&lt;/h2&gt;

&lt;p&gt;The shift in code is small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: handler decides how things work&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleSend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="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;res&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;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/messages&lt;/span&gt;&lt;span class="dl"&gt;'&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="nf"&gt;setMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// After: handler delegates&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleSend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;sendMessage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The use case is where the logic actually lives:&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SendMessage&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MessageRepository&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="nf"&gt;execute&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="nx"&gt;SendMessageInput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// validation, business rules, orchestration&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn't ceremony. The point is that &lt;code&gt;SendMessage&lt;/code&gt; is the only place anyone outside the domain learns how a message gets sent. Two screens calling it can't drift apart, because there's only one of it.&lt;/p&gt;

&lt;p&gt;The repository is the other half:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;MessageRepository&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SendMessageInput&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Message&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The use case depends on the interface. The implementation lives in the data layer. The UI imports neither. It imports the use case, calls &lt;code&gt;execute&lt;/code&gt;, and stops thinking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where React Native makes you pay for shortcuts
&lt;/h2&gt;

&lt;p&gt;This is the part most "clean architecture" posts are silent on. Web apps have UI and API. React Native has UI, API, navigation lifecycle, native modules, background/foreground transitions, and OS-level interruptions. The cost of mixing layers compounds with each one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Async outliving the screen.&lt;/strong&gt; A request starts on screen A and resolves after the user is on screen C. If the resolution reaches for component-local setters, navigation refs, or context that no longer exists, you get a bug that only reproduces when someone moves fast. A use case gives you one place to attach cancellation, idempotency, or "is this caller still listening?" guards. The screen doesn't need to know.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Native modules don't belong in handlers.&lt;/strong&gt; &lt;code&gt;NativeModules.Audio.start()&lt;/code&gt; in a button handler ties the UI to platform behavior. Platform behavior is the part most likely to diverge between iOS and Android, between OS versions, between simulator and device. Wrap the module in a repository, expose a use case (&lt;code&gt;StartRecording&lt;/code&gt;), and the UI is platform-agnostic. The platform-specific logic has one home, and you know where to look when iOS changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auth and rehydration races.&lt;/strong&gt; Token refresh overlapping with three in-flight requests is the canonical React Native bug. If your auth logic is split across an axios interceptor, a context provider, and a screen, the race is unfixable. There's no single thing to serialize. A &lt;code&gt;RefreshSession&lt;/code&gt; use case that owns the queue makes it tractable. Boring, but tractable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests stop pretending
&lt;/h2&gt;

&lt;p&gt;The biggest practical payoff isn't reuse. It's that tests stop needing the framework.&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sends a message via the repo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="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;repo&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;FakeMessageRepo&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;useCase&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;SendMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;repo&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;useCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="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;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No render tree. No &lt;code&gt;react-test-renderer&lt;/code&gt;. No mocked &lt;code&gt;NativeModules&lt;/code&gt;. No Detox. The use case runs in pure Node and exits in milliseconds.&lt;/p&gt;

&lt;p&gt;Most of the value of architecture is what becomes testable, not what becomes "clean."&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap
&lt;/h2&gt;

&lt;p&gt;A few ways this goes wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Optional architecture isn't architecture.&lt;/strong&gt; "I'll just call the API directly this once" is how you end up with three places that do the same thing badly. Either the boundary is enforced or it isn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three layers for a two-screen app is waste.&lt;/strong&gt; If your app is a login and a list, you don't need a use case layer. Apply this when the complexity earns it, usually somewhere between the third real feature and the second engineer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Folders aren't boundaries.&lt;/strong&gt; You can have a &lt;code&gt;domain/&lt;/code&gt; directory and still call &lt;code&gt;fetch&lt;/code&gt; from a screen. The directory structure is documentation. ESLint rules and code review are enforcement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The other cost is upfront friction. A new feature now touches three files instead of one. For a few weeks that feels worse, not better. It pays off the first time a bug reproduces only on Android, only after a notification, only when offline. You find the cause in one place instead of grepping six.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually get
&lt;/h2&gt;

&lt;p&gt;Clean architecture in React Native isn't a goal, and it isn't about being clean. Something will go wrong at month twelve, in a way you didn't predict. It's the bill you pay so the code you're staring at is still one you can reason about.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>cleancode</category>
    </item>
    <item>
      <title>Going native for voice recording</title>
      <dc:creator>Amanda Gama</dc:creator>
      <pubDate>Thu, 30 Apr 2026 16:21:53 +0000</pubDate>
      <link>https://forem.com/aoligama/going-native-for-voice-recording-5gm2</link>
      <guid>https://forem.com/aoligama/going-native-for-voice-recording-5gm2</guid>
      <description>&lt;p&gt;When you build a recording feature in React Native, the obvious move is to reach for one of the popular audio libraries. I did. Then I tried another. Then a third.&lt;/p&gt;

&lt;p&gt;Every one of them fell over on the same two scenarios: the OS backgrounding the app, and a memory warning while a long recording was in flight. Files would land on disk corrupt, half-finalized, or just gone. Internal state would desync from reality. There was no recovery hook. Once the lifecycle went sideways, you got back a &lt;code&gt;false&lt;/code&gt; from a promise and that was the end of it.&lt;/p&gt;

&lt;p&gt;A 90-second voice memo silently disappearing is a hard failure. I needed control of the audio session and the interruption surface. So I wrote my own native module.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audio session config
&lt;/h2&gt;

&lt;p&gt;The session setup is small but every flag earns its place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight objective_c"&gt;&lt;code&gt;&lt;span class="k"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BOOL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;configureAudioSession&lt;/span&gt;&lt;span class="p"&gt;:(&lt;/span&gt;&lt;span class="n"&gt;NSError&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;AVAudioSession&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;AVAudioSession&lt;/span&gt; &lt;span class="nf"&gt;sharedInstance&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="n"&gt;BOOL&lt;/span&gt; &lt;span class="n"&gt;success&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="nf"&gt;setCategory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;AVAudioSessionCategoryPlayAndRecord&lt;/span&gt;
                            &lt;span class="nl"&gt;withOptions:&lt;/span&gt;&lt;span class="n"&gt;AVAudioSessionCategoryOptionDefaultToSpeaker&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;
                                        &lt;span class="n"&gt;AVAudioSessionCategoryOptionAllowBluetooth&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;
                                        &lt;span class="n"&gt;AVAudioSessionCategoryOptionMixWithOthers&lt;/span&gt;
                                  &lt;span class="nl"&gt;error:&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;NO&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="n"&gt;success&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="nf"&gt;setMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;AVAudioSessionModeSpokenAudio&lt;/span&gt; &lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;NO&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="n"&gt;session&lt;/span&gt; &lt;span class="nf"&gt;setActive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nb"&gt;YES&lt;/span&gt; &lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PlayAndRecord&lt;/code&gt; is the only category that lets me record and play back from the same module. &lt;code&gt;DefaultToSpeaker&lt;/code&gt; keeps audio off the earpiece. Recordings get reviewed on speaker, not held to the ear. &lt;code&gt;AllowBluetooth&lt;/code&gt; so AirPods users don't have to take them out. &lt;code&gt;MixWithOthers&lt;/code&gt; so I don't kill the user's podcast or music when the recorder simply opens.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AVAudioSessionModeSpokenAudio&lt;/code&gt; pays for itself: iOS tunes signal processing for voice when it's set, and you can hear the difference on playback.&lt;/p&gt;

&lt;p&gt;The catch: &lt;code&gt;setActive:&lt;/code&gt; can fail on first call if another audio client is mid-handoff. A short sleep and one retry recovers from that race almost every time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight objective_c"&gt;&lt;code&gt;&lt;span class="k"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BOOL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;configureAudioSessionWithRetry&lt;/span&gt;&lt;span class="p"&gt;:(&lt;/span&gt;&lt;span class="n"&gt;NSError&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;error&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="n"&gt;self&lt;/span&gt; &lt;span class="nf"&gt;configureAudioSession&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;YES&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;NSThread&lt;/span&gt; &lt;span class="nf"&gt;sleepForTimeInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;6&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="n"&gt;self&lt;/span&gt; &lt;span class="nf"&gt;configureAudioSession&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;error&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;h2&gt;
  
  
  Surviving interruptions and memory warnings
&lt;/h2&gt;

&lt;p&gt;This is the part the libraries got wrong.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AVAudioSessionInterruptionNotification&lt;/code&gt; fires when a phone call comes in, when Siri activates, when another app grabs the audio session. The default behavior is "your recorder stops, good luck." I pause cleanly, persist state, and emit an event back to JS so the UI can show "recording paused (call interruption)":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight objective_c"&gt;&lt;code&gt;&lt;span class="k"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;handleInterruption&lt;/span&gt;&lt;span class="p"&gt;:(&lt;/span&gt;&lt;span class="n"&gt;NSNotification&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;notification&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;NSDictionary&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;userInfo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;AVAudioSessionInterruptionType&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;AVAudioSessionInterruptionTypeKey&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="nf"&gt;unsignedIntegerValue&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="n"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;AVAudioSessionInterruptionTypeBegan&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="n"&gt;audioRecorder&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;audioRecorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isRecording&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="n"&gt;audioRecorder&lt;/span&gt; &lt;span class="nf"&gt;pause&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt; &lt;span class="nf"&gt;stopProgressTimer&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt; &lt;span class="nf"&gt;persistRecordingState&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt; &lt;span class="nf"&gt;emitStateChange&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;@"paused"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt; &lt;span class="nf"&gt;emitInterruption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;@"phone_call"&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;When the interruption ends, I re-activate the session (with the same retry logic) and signal JS whether the system wants the recording resumed.&lt;/p&gt;

&lt;p&gt;Memory warnings are the more interesting case. iOS will tear the process down without ceremony if pressure stays high. But the warning itself is a chance to land cleanly. I stop the recorder, which causes AVFoundation to finalize the M4A header so the partial file is playable, then emit a distinct state so the UI can offer recovery:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight objective_c"&gt;&lt;code&gt;&lt;span class="k"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;handleMemoryWarning&lt;/span&gt;&lt;span class="p"&gt;:(&lt;/span&gt;&lt;span class="n"&gt;NSNotification&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;notification&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="n"&gt;audioRecorder&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;audioRecorder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isRecording&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;isPaused&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="n"&gt;self&lt;/span&gt; &lt;span class="nf"&gt;persistRecordingState&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;audioRecorder&lt;/span&gt; &lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt; &lt;span class="nf"&gt;stopProgressTimer&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt; &lt;span class="nf"&gt;sendEventWithName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;@"onRecordingStateChange"&lt;/span&gt; &lt;span class="nf"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:@{&lt;/span&gt;
            &lt;span class="s"&gt;@"state"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;@"stopped_memory_warning"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;@"filePath"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;currentFilePath&lt;/span&gt; &lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="s"&gt;@""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;@"reason"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;@"Recording stopped due to low memory."&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 libraries' failure mode here was silence. The OS killed them and the user lost the take. Stopping early with a finalized file on disk is a much better trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Level metering math
&lt;/h2&gt;

&lt;p&gt;For a waveform UI, AVAudioRecorder gives you &lt;code&gt;averagePowerForChannel:&lt;/code&gt; and &lt;code&gt;peakPowerForChannel:&lt;/code&gt;. They return dBFS (full-scale referenced), so 0 dB is clipping and the floor is around -160 dB.&lt;/p&gt;

&lt;p&gt;The math everyone gets wrong on the first try is the normalization. You can't just divide by 160. Real voice rarely registers below -50 dB; using the full range gives you a bar that lives in the bottom 10% of its travel and barely moves. Pick a practical floor (-50 works) and clamp:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight objective_c"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;audioRecorder&lt;/span&gt; &lt;span class="nf"&gt;updateMeters&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;averagePower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;audioRecorder&lt;/span&gt; &lt;span class="nf"&gt;averagePowerForChannel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;peakPower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;audioRecorder&lt;/span&gt; &lt;span class="nf"&gt;peakPowerForChannel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;normalizedLevel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;averagePower&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;normalizedLevel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MIN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalizedLevel&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;normalizedPeak&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;peakPower&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;normalizedPeak&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MIN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalizedPeak&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I sample at 50 ms: &lt;code&gt;NSTimer&lt;/code&gt; at 0.05 s on the main run loop. Faster looks jittery, slower looks laggy. 50 ms is also roughly the cadence the RN bridge can deliver events at without batching, so there's no point emitting more.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boring numbers that matter
&lt;/h2&gt;

&lt;p&gt;The recorder settings are a five-line dictionary, but each value is a deliberate choice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight objective_c"&gt;&lt;code&gt;&lt;span class="k"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NSDictionary&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;audioRecorderSettings&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="nl"&gt;AVFormatIDKey:&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kAudioFormatMPEG4AAC&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nl"&gt;AVSampleRateKey:&lt;/span&gt; &lt;span class="mf"&gt;@44100.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;AVNumberOfChannelsKey:&lt;/span&gt; &lt;span class="mi"&gt;@1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;AVEncoderAudioQualityKey:&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AVAudioQualityMedium&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nl"&gt;AVEncoderBitRateKey:&lt;/span&gt; &lt;span class="mi"&gt;@64000&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;AAC in an M4A container plays everywhere: iOS, Android, browsers. PCM/WAV is an order of magnitude bigger. Opus still has gaps on older Safari.&lt;/p&gt;

&lt;p&gt;44.1 kHz over 48: a hair smaller on disk, and downstream voice pipelines will resample anyway. Mono halves file size again and matches reality. No one is recording a stereo voice memo.&lt;/p&gt;

&lt;p&gt;64 kbps AAC at &lt;code&gt;AVAudioQualityMedium&lt;/code&gt; is transparent for speech. Apple's own defaults sit at 128 to 256 kbps, which is overkill for voice and produces files four times bigger than they need to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got back
&lt;/h2&gt;

&lt;p&gt;About 150 lines of Objective-C replaced three failed library integrations. The recorder now survives a phone call, a memory warning, and a backgrounded app, and emits structured events JS can react to instead of swallowing the failure.&lt;/p&gt;

&lt;p&gt;I own this surface now, though. The libraries handled enough cases that you could ship without thinking about audio sessions at all; I chose to think about them, and that bill comes due whenever iOS changes interruption semantics. I accept it because the alternative was silently losing recordings.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>javascript</category>
      <category>mobile</category>
      <category>reactnative</category>
    </item>
  </channel>
</rss>
