<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Simple Memo</title>
    <description>The latest articles on DEV Community by Simple Memo (@simple_memo).</description>
    <link>https://dev.to/simple_memo</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3919840%2Ff3e34759-885a-4e5e-9959-57c82a1a9c45.png</url>
      <title>DEV Community: Simple Memo</title>
      <link>https://dev.to/simple_memo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/simple_memo"/>
    <language>en</language>
    <item>
      <title>A thought you can't capture in a second is already gone</title>
      <dc:creator>Simple Memo</dc:creator>
      <pubDate>Fri, 19 Jun 2026 13:15:10 +0000</pubDate>
      <link>https://dev.to/simple_memo/a-thought-you-cant-capture-in-a-second-is-already-gone-20n9</link>
      <guid>https://dev.to/simple_memo/a-thought-you-cant-capture-in-a-second-is-already-gone-20n9</guid>
      <description>&lt;p&gt;A thought you cannot capture in about a second is, for practical purposes, already gone. Not slower to retrieve. Not filed somewhere inconvenient. Gone, with no copy anywhere, and usually you do not even notice the loss, which is the part that should bother you.&lt;/p&gt;

&lt;p&gt;I spent years treating my note-taking like a storage problem. I compared apps by how they organized things: folders versus tags, backlinks versus search, local files versus a synced database. Every few months I migrated to whatever promised a tidier shelf. None of it changed the thing that was actually costing me ideas, because the thing that was costing me ideas happened before storage was ever involved. It happened in the gap between having a thought and getting it out of my head, and that gap is measured in seconds, not in retrieval quality.&lt;/p&gt;

&lt;p&gt;There is an old experiment that I think about more than is probably healthy. In 1959, Lloyd and Margaret Peterson gave people three-letter strings to remember, then immediately made them count backward by threes so they could not rehearse. After three seconds of that distraction, people still recalled the letters about eighty percent of the time. After eighteen seconds, recall collapsed to around ten percent. Eighteen seconds, and the letters were gone, because attention had been pointed somewhere else for less time than it takes to find the right app and tap into the right note.&lt;/p&gt;

&lt;p&gt;That is the real shape of the problem. Human working memory is not a hard drive that holds your thought patiently until you get around to saving it. It is a leaky bucket, and the leak is fast. So the requirement on the moment of capture is harsher than almost any other requirement in a personal system: it has to be fast enough that catching the thought beats losing it, every single time, including on the days you are tired, walking, holding something in your other hand, or being talked at by a stranger.&lt;/p&gt;

&lt;p&gt;Here is where most systems quietly fail, and they fail for a reason that looks like a virtue. They make capture happen inside structure. To write the thought down, you first open the app, then you are looking at a hierarchy, and the hierarchy asks questions. Which notebook. Which folder. Which tag. Does this belong with the other thing or is it new. Each question is a small decision, a few hundred milliseconds of deliberation, and a few hundred milliseconds is enough. While you are deciding where the thought goes, the thought is already draining out of the bucket. You end up with an immaculately organized system that is missing exactly the ideas that mattered most, because good ideas tend to arrive at the worst possible moments for filing.&lt;/p&gt;

&lt;p&gt;So I started measuring my own setup with a single number, and it is the only note-taking metric I trust now: how long, in seconds, from the instant I have a thought to the instant it is recorded somewhere safe and I can stop holding it in my head. Not how searchable it is later. Not how nicely it is filed. Just the latency of the catch. When I started timing that honestly, my elaborate system was costing me three to five seconds per capture, sometimes more if I hesitated over where something belonged, and I could feel the hesitation as a kind of low background tax on thinking. I had been optimizing the warehouse while the loading dock leaked.&lt;/p&gt;

&lt;p&gt;Cutting that number down turned out to be almost entirely a matter of removing decisions, not adding features. The capture surface has to be dumb on purpose. No folder prompt. No tag picker. No "which notebook." A line goes in, it lands somewhere I trust, and I keep walking. Everything that feels like organizing has to be ripped out of the moment of capture and pushed to later, because organizing is a decision and decisions are exactly what the fast layer cannot afford. The discipline is counterintuitive: to keep more of my thinking, I had to let the capture step get noticeably stupider.&lt;/p&gt;

&lt;p&gt;I want to be fair to the other side, because there is a real objection here. Capture that records everything and organizes nothing just relocates the problem. A pile of undifferentiated lines is not a second brain; it is a junk drawer, and a junk drawer you cannot search is arguably worse than not capturing at all. That is true, and I am not arguing against organization. I am arguing against doing it at the wrong time. Curation, structure, linking, deleting the two a.m. thought that did not survive contact with morning, all of that is real work and all of it deserves a place. Its place is later, at a desk, where I have minutes and not a second and a half. The mistake is collapsing two jobs with two completely different latency budgets into one screen and one moment.&lt;/p&gt;

&lt;p&gt;The reason this matters for shipping, and not just for note-taking, is that the same friction shows up everywhere I work alone. The idea for the fix to a bug, the realization about why a feature is not landing, the one sentence that finally explains the product, none of these arrive on a schedule and most of them arrive when my hands are full. As a solo developer I do not have a meeting where someone writes my ideas on a whiteboard, and I do not have a colleague who remembers the thing I said in the hallway. If I do not catch it in the first second, there is no backstop. The friction in capture becomes, very directly, friction in what I manage to build, because the things I never recorded are things I never act on.&lt;/p&gt;

&lt;p&gt;I have stopped being precious about the storage end as a result. Plain lines, timestamped, in a format I will still be able to read in ten years, is enough for the bottom of the system, and I would rather have a boring durable store and a fast front door than a beautiful database with a slow one. If I have to choose between a system that captures in one second and organizes clumsily, and a system that organizes brilliantly and captures in five, I take the first one without thinking, because the first one keeps the raw material and the second one loses it. You can always build a better reader on top of lines you actually kept. You can never retroactively capture the thought you let drain away while deciding where to put it.&lt;/p&gt;

&lt;p&gt;None of this is a productivity hack, and I am suspicious of the genre. It is closer to an accounting correction. For years I was counting the wrong cost. I measured my note system by how it stored and retrieved, when the dominant cost was sitting upstream of both, in the half-second of friction I had never thought to measure. Once I started measuring that number and protecting it, the rest of the system got simpler, not more complex, because most of what I had been adding was structure that the fast layer was paying for and the slow layer should have owned.&lt;/p&gt;

&lt;p&gt;So the whole claim, restated as plainly as I can: a thought you cannot capture in about a second is already gone, the cost of that loss is total and silent, and the only way to keep more of your own thinking is to make the moment of capture too dumb to ask you any questions. Optimize the second. The shelves can wait.&lt;/p&gt;

&lt;p&gt;What I cannot see is your version of this. When a thought hits you in the middle of something else, what is your real capture latency, one second or ten or never, and do you even notice the ones that get away?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://apps.apple.com/us/app/captio-style-simple-memo/id6758438948" rel="noopener noreferrer"&gt;Simple Memo&lt;/a&gt; on my own: a one-tap iOS app that gets a thought out of my head and into my email in about a second, before I can lose it. I write here every few days about the parts of building solo I had to get wrong first.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>watercooler</category>
      <category>writing</category>
      <category>psychology</category>
    </item>
    <item>
      <title>iOS 26's SpeechAnalyzer on a live mic: the 5 things the docs don't tell you</title>
      <dc:creator>Simple Memo</dc:creator>
      <pubDate>Tue, 16 Jun 2026 11:23:29 +0000</pubDate>
      <link>https://dev.to/simple_memo/ios-26s-speechanalyzer-on-a-live-mic-the-5-things-the-docs-dont-tell-you-2ng5</link>
      <guid>https://dev.to/simple_memo/ios-26s-speechanalyzer-on-a-live-mic-the-5-things-the-docs-dont-tell-you-2ng5</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;This is a condensed version. The full write-up — with the complete &lt;code&gt;SpeechSession&lt;/code&gt;, the &lt;code&gt;AudioBufferConverter&lt;/code&gt;, and the SFSpeechRecognizer → SpeechAnalyzer migration table — lives on &lt;a href="https://simplememofast.com/en/blog/ios26-speechanalyzer-live-mic" rel="noopener noreferrer"&gt;the original post&lt;/a&gt;, and the runnable sample is on &lt;a href="https://github.com/simplememofast/ios26-speechanalyzer-live-mic" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; (MIT).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;iOS 26 replaces &lt;code&gt;SFSpeechRecognizer&lt;/code&gt; with &lt;code&gt;SpeechAnalyzer&lt;/code&gt; + composable modules. The new model is nicer — an orchestrator you attach modules to, optimized for longer on-device audio, no "enable dictation in Settings" requirement. But if you follow the WWDC sample to wire it to a &lt;strong&gt;live microphone&lt;/strong&gt;, you can end up with code that compiles and produces no text. Here are the five things that cost real time.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mic ─► AVAudioEngine.installTap ─► AVAudioConverter ─► AnalyzerInput
                                                          │
                                    SpeechAnalyzer([ SpeechTranscriber ])
                                                          │
                            for try await result in transcriber.results
                                result.text (AttributedString) / result.isFinal
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SpeechAnalyzer&lt;/code&gt; coordinates; you attach a &lt;code&gt;SpeechTranscriber&lt;/code&gt;. Audio goes in as &lt;code&gt;AnalyzerInput&lt;/code&gt;; results come out of an &lt;code&gt;AsyncSequence&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;locale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;SpeechTranscriber&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;supportedLocale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;equivalentTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current&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="k"&gt;throw&lt;/span&gt; &lt;span class="kt"&gt;Failure&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;localeNotSupported&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;transcriber&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;SpeechTranscriber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;transcriptionOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
    &lt;span class="nv"&gt;reportingOptions&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="n"&gt;volatileResults&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;   &lt;span class="c1"&gt;// partial text WHILE speaking&lt;/span&gt;
    &lt;span class="nv"&gt;attributeOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;                     &lt;span class="c1"&gt;// add .audioTimeRange for per-word timing&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;analyzer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;SpeechAnalyzer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;modules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;transcriber&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;analyzerFormat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;SpeechAnalyzer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bestAvailableAudioFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;compatibleWith&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;transcriber&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  1. You must convert the audio buffer (the #1 trap)
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;AVAudioEngine&lt;/code&gt;'s input node format (often 48 kHz, hardware-dependent) usually does &lt;strong&gt;not&lt;/strong&gt; match &lt;code&gt;SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith:)&lt;/code&gt;. Feed a mismatched buffer and you get a clean compile and &lt;strong&gt;zero transcription&lt;/strong&gt; — no error. Run every buffer through &lt;code&gt;AVAudioConverter&lt;/code&gt; first.&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;converter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;AudioBufferConverter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// capture locals; never touch self in the tap&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;audioEngine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inputNode&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;micFormat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;outputFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forBus&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;input&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;installTap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;onBus&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="nv"&gt;bufferSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;micFormat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;converted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;converter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;analyzerFormat&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;AnalyzerInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;converted&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;audioEngine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;audioEngine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. The model downloads on first use — handle offline
&lt;/h2&gt;

&lt;p&gt;Transcription is on-device, but the language model is a &lt;strong&gt;system-shared asset&lt;/strong&gt; that may not be installed yet (it doesn't count against your app bundle). A first run with no network can't download it, so handle that state explicitly instead of failing silently.&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;installed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;SpeechTranscriber&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;installedLocales&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bcp47&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="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;installed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bcp47&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="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;AssetInventory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assetInstallationRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;supporting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;transcriber&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;downloadAndInstall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// has .progress for a UI&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;
  
  
  3. Volatile vs. finalized results
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;reportingOptions: [.volatileResults]&lt;/code&gt; gives fast partials while the user is still speaking; &lt;code&gt;result.isFinal&lt;/code&gt; marks committed text. Show volatile dimmed, replace it on a final, persist only finals. &lt;code&gt;result.text&lt;/code&gt; is an &lt;code&gt;AttributedString&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;transcriber&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;piece&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;characters&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;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isFinal&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;finalizedText&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;piece&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;volatileText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&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="n"&gt;volatileText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;piece&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;
  
  
  4. There is no Custom Vocabulary
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;SFSpeechRecognizer&lt;/code&gt; had &lt;code&gt;contextualStrings&lt;/code&gt; to bias toward known terms. &lt;code&gt;SpeechAnalyzer&lt;/code&gt;, as of iOS 26.0, exposes no equivalent. If your domain is full of proper nouns or jargon, budget for that gap now.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. watchOS: SpeechAnalyzer isn't there — but voice input still is
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;SpeechAnalyzer&lt;/code&gt; ships on iOS, iPadOS, macOS, visionOS and tvOS 26 — &lt;strong&gt;not watchOS&lt;/strong&gt;. That doesn't mean "no voice on the Watch": you fall back to the system dictation UI, which hands back finished text (you lose volatile results, time ranges, and your own tap).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// watchOS — the system handles dictation and returns text:&lt;/span&gt;
&lt;span class="kt"&gt;TextFieldLink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;prompt&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;"Speak or type"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;systemName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"mic.fill"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nv"&gt;onSubmit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&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;
  
  
  A note on latency (with the conditions attached)
&lt;/h2&gt;

&lt;p&gt;The most-cited SpeechAnalyzer latency figure is a WWDC25-era developer-forum report of &lt;strong&gt;~14s+ to the first result&lt;/strong&gt; on an iPhone 16 &lt;strong&gt;Pro&lt;/strong&gt; (iOS 26.0 beta, Xcode beta 5). On shipping &lt;strong&gt;iOS 26.5&lt;/strong&gt;, on an &lt;strong&gt;iPhone 16e&lt;/strong&gt; — the non-Pro A18, the &lt;em&gt;least&lt;/em&gt; powerful A18 device — time to the first volatile result is &lt;strong&gt;~0.3–0.5s&lt;/strong&gt; on a warm start (model installed, locale allocated). First-ever launch is different (it downloads the model once), so budget for that path separately and show progress.&lt;/p&gt;

&lt;p&gt;This is a first-party measurement (time-to-first-volatile-result), &lt;strong&gt;not&lt;/strong&gt; a controlled head-to-head — different device, shipping OS vs beta. Measure on your own device and publish device + OS + metric alongside the number. The likely takeaway: the beta-era latency was a preheat/config/beta issue, not a hardware limit — on-device transcription runs primarily on the Neural Engine, the same 16-core unit across the whole A18 family.&lt;/p&gt;

&lt;h2&gt;
  
  
  Swift 6 concurrency footnote
&lt;/h2&gt;

&lt;p&gt;The tap closure runs on a real-time audio thread. Under complete strict concurrency, capture only locals (the continuation, the target format, a fresh converter) and never touch a &lt;code&gt;@MainActor&lt;/code&gt; object inside the tap — then it compiles without &lt;code&gt;@unchecked Sendable&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;Full code and the migration table: &lt;strong&gt;&lt;a href="https://github.com/simplememofast/ios26-speechanalyzer-live-mic" rel="noopener noreferrer"&gt;github.com/simplememofast/ios26-speechanalyzer-live-mic&lt;/a&gt;&lt;/strong&gt; (MIT). Corrections from real device builds welcome via PR.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>swiftui</category>
      <category>speech</category>
    </item>
    <item>
      <title>iOS 26 SpeechAnalyzer: what I learned wiring it to a mic</title>
      <dc:creator>Simple Memo</dc:creator>
      <pubDate>Tue, 09 Jun 2026 13:25:07 +0000</pubDate>
      <link>https://dev.to/simple_memo/ios-26-speechanalyzer-what-i-learned-wiring-it-to-a-mic-f7p</link>
      <guid>https://dev.to/simple_memo/ios-26-speechanalyzer-what-i-learned-wiring-it-to-a-mic-f7p</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;AVFoundation&lt;/span&gt;
&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;Speech&lt;/span&gt;

&lt;span class="kd"&gt;@Observable&lt;/span&gt;
&lt;span class="kd"&gt;@MainActor&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;LiveTranscriber&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Two strings on purpose. One is rewritten constantly, one is permanent.&lt;/span&gt;
    &lt;span class="kd"&gt;private(set)&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;volatile&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;AttributedString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// the gray, live guess&lt;/span&gt;
    &lt;span class="kd"&gt;private(set)&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;committed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;AttributedString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// finalized text, never rewritten&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;analyzer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SpeechAnalyzer&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;transcriber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SpeechTranscriber&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;inputBuilder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;AsyncStream&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;AnalyzerInput&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;.&lt;/span&gt;&lt;span class="kt"&gt;Continuation&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;analyzerFormat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;AVAudioFormat&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;resultsTask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Task&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;Void&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Never&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;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;AVAudioEngine&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Locale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;transcriber&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;SpeechTranscriber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nv"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;transcriptionOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
            &lt;span class="nv"&gt;reportingOptions&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="n"&gt;volatileResults&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;   &lt;span class="c1"&gt;// opt in to live partials&lt;/span&gt;
            &lt;span class="nv"&gt;attributeOptions&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="n"&gt;audioTimeRange&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;    &lt;span class="c1"&gt;// each run carries its audio span&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transcriber&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;transcriber&lt;/span&gt;
        &lt;span class="n"&gt;analyzer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;SpeechAnalyzer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;modules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;transcriber&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;analyzerFormat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;SpeechAnalyzer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bestAvailableAudioFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;compatibleWith&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;transcriber&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ensureModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;for&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;transcriber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// download once, if missing&lt;/span&gt;

        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;continuation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;AsyncStream&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;AnalyzerInput&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;.&lt;/span&gt;&lt;span class="nf"&gt;makeStream&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;inputBuilder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;continuation&lt;/span&gt;

        &lt;span class="n"&gt;resultsTask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;transcriber&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;results&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;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isFinal&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="n"&gt;committed&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;
                        &lt;span class="n"&gt;volatile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;AttributedString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;       &lt;span class="c1"&gt;// clear the guess&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="n"&gt;volatile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;              &lt;span class="c1"&gt;// replace, don't append&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* surface to the UI; a thrown result ends the stream */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;analyzer&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;inputSequence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="nf"&gt;startMic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// installs the tap, converts buffers, yields AnalyzerInput&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 is the entire spine of the live dictation I wired into the iOS app I build by myself. Forty-odd lines, no third-party packages, running fully on-device on iOS 26. It took me about a day to write and the better part of a week to stop getting wrong. This post is the week, not the day.&lt;/p&gt;

&lt;p&gt;The class is short because &lt;code&gt;SpeechAnalyzer&lt;/code&gt; carries the weight. But "short" hid four traps that the WWDC talk and the sample code skate past, and every one of them cost me real hours. I'll walk the spine first, then open up each trap with the code that actually fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the spine
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;SpeechTranscriber&lt;/code&gt; is the module that turns audio into words. I configure it with &lt;code&gt;reportingOptions: [.volatileResults]&lt;/code&gt;, which is the single line that makes the experience feel live. Leave it out and you only get finalized text, in chunks, after the recognizer has heard enough context to be confident. With it, you get a stream of fast, throwaway guesses that tighten as more audio arrives.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SpeechAnalyzer(modules:)&lt;/code&gt; is the session. You hand it an array of modules; here that is just the one transcriber, though a &lt;code&gt;SpeechDetector&lt;/code&gt; for voice-activity can ride alongside it. The analyzer does not produce results itself. Each module owns its own &lt;code&gt;results&lt;/code&gt; sequence, which is why my &lt;code&gt;for try await&lt;/code&gt; loop reads from &lt;code&gt;transcriber.results&lt;/code&gt; and not from the analyzer.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;bestAvailableAudioFormat(compatibleWith:)&lt;/code&gt; returns the PCM format the model wants to be fed. Hold onto that thought; it is the second trap.&lt;/p&gt;

&lt;p&gt;Then &lt;code&gt;AsyncStream&amp;lt;AnalyzerInput&amp;gt;.makeStream()&lt;/code&gt; gives me a stream and a continuation. I feed audio in through the continuation; the analyzer reads from the stream. &lt;code&gt;analyzer.start(inputSequence:)&lt;/code&gt; begins the session, and &lt;code&gt;startMic()&lt;/code&gt; opens the tap that pushes buffers in.&lt;/p&gt;

&lt;p&gt;That is the happy path. Here is where I actually spent the week.&lt;/p&gt;

&lt;h2&gt;
  
  
  Volatile results are a UI problem, not a recognition one
&lt;/h2&gt;

&lt;p&gt;The first time I ran it, the transcript stuttered and doubled. "the the quick the quick brown the quick brown fox." I had reached for the obvious move and appended every result to one string.&lt;/p&gt;

&lt;p&gt;The fix is the two-string split at the top of the class. A volatile result is a guess about the same span of audio the recognizer is still chewing on. It is meant to &lt;em&gt;replace&lt;/em&gt; the previous guess, not extend it. A final result is the recognizer committing: this span is settled, it will never be revised. So volatile text gets assigned, final text gets appended, and the moment a final arrives I clear the volatile buffer so the same words don't show up twice.&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;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isFinal&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;committed&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;          &lt;span class="c1"&gt;// permanent, append&lt;/span&gt;
    &lt;span class="n"&gt;volatile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;AttributedString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;     &lt;span class="c1"&gt;// the guess is now redundant&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="n"&gt;volatile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;            &lt;span class="c1"&gt;// transient, overwrite&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the UI I render &lt;code&gt;committed&lt;/code&gt; in the normal text color and &lt;code&gt;volatile&lt;/code&gt; in gray, and I insert the live text at the cursor so a memo grows in place while I talk. The gray is not decoration. It is a promise to the reader that those words might still change, and it is the difference between an interface that feels honest and one that feels broken when a word flips a half-second after it appeared. &lt;code&gt;result.text&lt;/code&gt; is an &lt;code&gt;AttributedString&lt;/code&gt; rather than a &lt;code&gt;String&lt;/code&gt; precisely so the framework can hang this kind of metadata off the runs, including the &lt;code&gt;audioTimeRange&lt;/code&gt; I asked for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The microphone's format is not the analyzer's format
&lt;/h2&gt;

&lt;p&gt;This is the trap that ate the most hours, because it fails silently. The tap delivers buffers in the input node's hardware format. The analyzer wants the format that &lt;code&gt;bestAvailableAudioFormat&lt;/code&gt; handed back. Feed it the wrong one and you don't get an error. You get nothing, or you get garbage, and you sit there wondering whether the model is broken.&lt;/p&gt;

&lt;p&gt;The microphone tap has to convert every buffer before it goes in:&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;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;startMic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inputNode&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;micFormat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;outputFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forBus&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="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;analyzerFormat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;converter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;AVAudioConverter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;micFormat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;analyzerFormat&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="k"&gt;throw&lt;/span&gt; &lt;span class="kt"&gt;TranscriberError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;noUsableFormat&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;installTap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;onBus&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="nv"&gt;bufferSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;micFormat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;converted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;converter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;analyzerFormat&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inputBuilder&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;AnalyzerInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;converted&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&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;AnalyzerInput(buffer:)&lt;/code&gt; is the envelope the stream carries. The tap closure runs on an audio thread, so I keep it cheap: convert, yield, done. Nothing else belongs in there. I learned that the hard way too, by doing string work in the closure and watching the audio glitch.&lt;/p&gt;

&lt;h2&gt;
  
  
  "On-device" still means "download once"
&lt;/h2&gt;

&lt;p&gt;On-device is the headline, and it is true: nothing I record leaves the phone, and because there is no metered speech API behind it, a user can talk all day and my server bill stays exactly zero, which for a solo dev with no backend is the entire reason this feature was even thinkable. But "on-device" is not the same as "already on the device." The language model has to be present, and the first time a given locale comes up it may not be.&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;func&lt;/span&gt; &lt;span class="nf"&gt;ensureModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="nv"&gt;transcriber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SpeechTranscriber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Locale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;wanted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bcp47&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;supported&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;SpeechTranscriber&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;supportedLocales&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;supported&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bcp47&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;wanted&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="k"&gt;throw&lt;/span&gt; &lt;span class="kt"&gt;TranscriberError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;localeUnsupported&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;installed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;SpeechTranscriber&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;installedLocales&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;installed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bcp47&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;wanted&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="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;AssetInventory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assetInstallationRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;supporting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;transcriber&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;downloadAndInstall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// this is the "Preparing…" state the user sees&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 checks, not one. &lt;code&gt;supportedLocales&lt;/code&gt; answers "can this device ever transcribe this language" — at the time of writing the list runs to forty-some locales, from &lt;code&gt;en_US&lt;/code&gt; to &lt;code&gt;ja_JP&lt;/code&gt; to &lt;code&gt;yue_CN&lt;/code&gt;. &lt;code&gt;installedLocales&lt;/code&gt; answers "is the model on disk right now." Only when a locale is supported but not installed do I ask &lt;code&gt;AssetInventory&lt;/code&gt; to fetch it, and that download is what surfaces as a "Preparing…" label in the app.&lt;/p&gt;

&lt;p&gt;Here is the part I genuinely like as an app author, not just an engineer: those models are shared system assets, not part of my bundle. My download size on the App Store did not move a kilobyte when I shipped this. The model lives in system storage, gets shared across every app that uses it, and updates itself out from under me when Apple improves it. I am used to features that cost binary size or cost cents-per-call. This one costs neither, and that combination is rare enough that I went back and re-read the docs twice to make sure I wasn't missing the catch.&lt;/p&gt;

&lt;p&gt;The catch, such as it is, is availability. On a device or OS that can't run it, &lt;code&gt;SpeechTranscriber.isAvailable&lt;/code&gt; is false, and the right move is to hide the microphone entirely rather than show a button that does nothing. Typing still works; the voice affordance simply isn't offered. Degrading by hiding beats degrading by erroring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two transcribers, and I'm not certain I chose right
&lt;/h2&gt;

&lt;p&gt;There are actually two transcription modules in the framework. I shipped with &lt;code&gt;SpeechTranscriber&lt;/code&gt;, which is tuned for clean, lower-overhead recognition. There is also &lt;code&gt;DictationTranscriber&lt;/code&gt;, which adds punctuation and leans into conversational structure — the kind of thing you'd want for composing a long message out loud.&lt;/p&gt;

&lt;p&gt;Read that back and you can see my doubt. A memo app is arguably closer to "composing a message" than to "command recognition," which is the textbook case for &lt;code&gt;DictationTranscriber&lt;/code&gt;. I went with &lt;code&gt;SpeechTranscriber&lt;/code&gt; because my memos are short, often fragments, and I'd rather under-punctuate a three-word note than have the model guess sentence boundaries that aren't there. But I hold that choice loosely. It is the first thing I'll A/B if people tell me the transcripts read like a transcript instead of like a note.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd change after shipping it
&lt;/h2&gt;

&lt;p&gt;The bug that survived longest into production was a lifecycle one, and it is worth flagging because it is counterintuitive. Finishing the input stream does &lt;strong&gt;not&lt;/strong&gt; finish the session. Calling &lt;code&gt;continuation.finish()&lt;/code&gt; just tells the analyzer no more audio is coming; the analyzer stays alive, holding resources, waiting. To actually wind down you have to call a finish method on the analyzer itself:&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;func&lt;/span&gt; &lt;span class="nf"&gt;stop&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="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;inputBuilder&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;analyzer&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;finalizeAndFinishThroughEndOfInput&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;// flush, then close for real&lt;/span&gt;
    &lt;span class="n"&gt;resultsTask&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancel&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;finalizeAndFinishThroughEndOfInput()&lt;/code&gt; flushes whatever audio is still in flight into final results before it closes, so you don't lose the last word someone spoke. I had the &lt;code&gt;engine.stop()&lt;/code&gt; and the stream &lt;code&gt;finish()&lt;/code&gt; from day one and assumed that was teardown. It wasn't, and the leak only showed up after a few dozen start/stop cycles in a long session.&lt;/p&gt;

&lt;p&gt;The other thing I'd revisit is backpressure. An &lt;code&gt;AsyncStream&lt;/code&gt; will buffer if the analyzer falls behind the microphone, and under sustained fast speech that buffer grows. I haven't been bitten by it yet, but I've made a note to bound the stream and drop the oldest buffers rather than the newest if I ever am, because in dictation the freshest audio is the audio you most need.&lt;/p&gt;

&lt;h2&gt;
  
  
  The converter I hand-waved
&lt;/h2&gt;

&lt;p&gt;I skipped the body of &lt;code&gt;convert(_:with:to:)&lt;/code&gt; above so the mic section would read cleanly. Here it is, since it is the piece most likely to trip you up. It is a one-shot pull through &lt;code&gt;AVAudioConverter&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;AVAudioPCMBuffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                     &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="nv"&gt;converter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;AVAudioConverter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                     &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="nv"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;AVAudioFormat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;AVAudioPCMBuffer&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sampleRate&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sampleRate&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;capacity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;AVAudioFrameCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;frameLength&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;AVAudioPCMBuffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;pcmFormat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;frameCapacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;capacity&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="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&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;supplied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;conversionError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;NSError&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
    &lt;span class="n"&gt;converter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;out&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="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;conversionError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;supplied&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pointee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;noDataNow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;supplied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pointee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;haveData&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;buffer&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;conversionError&lt;/span&gt; &lt;span class="o"&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;out&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fiddly bit is the input block. &lt;code&gt;AVAudioConverter&lt;/code&gt; pulls input rather than taking it, so you hand it the buffer once with &lt;code&gt;.haveData&lt;/code&gt;, then answer &lt;code&gt;.noDataNow&lt;/code&gt; on the next pull so it doesn't spin asking for more. The &lt;code&gt;+ 1024&lt;/code&gt; on the capacity is slack for the resample; size it too tight and the conversion truncates. None of this is exotic, but it is exactly the kind of plumbing the headline API hides, and it is why "wire it to a mic" turned out to be the hard half of the sentence.&lt;/p&gt;

&lt;p&gt;If you've shipped &lt;code&gt;SpeechAnalyzer&lt;/code&gt; against live audio, I want to compare notes on one thing specifically: did you stay on &lt;code&gt;SpeechTranscriber&lt;/code&gt;, or did &lt;code&gt;DictationTranscriber&lt;/code&gt; read better for free-form notes? That's the one decision I still can't defend with data.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm a solo iOS developer building Simple Memo. I write here every few days about the unglamorous parts of shipping alone, usually when an Apple API surprises me. The voice input this code grew into is documented &lt;a href="https://simplememofast.com/voice-input/" rel="noopener noreferrer"&gt;on its own page&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>swift</category>
      <category>ios</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>I gave my notes a cache hierarchy: phone L1, vault L2</title>
      <dc:creator>Simple Memo</dc:creator>
      <pubDate>Fri, 05 Jun 2026 13:21:04 +0000</pubDate>
      <link>https://dev.to/simple_memo/i-gave-my-notes-a-cache-hierarchy-phone-l1-vault-l2-73m</link>
      <guid>https://dev.to/simple_memo/i-gave-my-notes-a-cache-hierarchy-phone-l1-vault-l2-73m</guid>
      <description>&lt;p&gt;I am not going to tell you which note app I run. The app is the least load-bearing part of this, and if I name it you will argue about the app instead of the decision. So I will do the more useful thing and show you how I decided, one axis at a time, including the option I threw out and the reason I threw it out.&lt;/p&gt;

&lt;p&gt;The decision was not "which app." It was a layout question: should capturing a thought and keeping a thought happen in the same place, or in two places tuned for two different jobs? For years I assumed one place, because one place looks tidy on a whiteboard. I now run two, and the thing that moved me was a diagram I half-remembered from an undergraduate architecture course, sketched on a napkin at a coffee shop in February.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the problem
&lt;/h2&gt;

&lt;p&gt;Here is the diagram. A CPU does not have one kind of memory. It has a hierarchy. Registers at the top, then L1 cache, then L2, then L3, then main memory, then disk. Each level down is larger and cheaper per byte and slower to reach. L1 is on the order of tens of kilobytes and answers in about a nanosecond. Main memory is gigabytes and answers in around a hundred. No single technology is fast and large and cheap at once, so the machine refuses to choose. It builds levels and moves data between them.&lt;/p&gt;

&lt;p&gt;I stared at that napkin and realized my notes, the thing people now call a second brain, had the identical problem, and I had been pretending they did not. The place where I capture a thought needs to be instant, always within reach, and forgiving. The place where I keep a thought for two years needs to be large, searchable, durable, and portable. Those are not the same set of requirements. They are barely compatible. I had been shopping for one memory technology that was fast and large and cheap, and that product does not exist, for silicon or for notes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The options on the table
&lt;/h2&gt;

&lt;p&gt;I wrote down three honestly, not one plus two strawmen.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One unified store.&lt;/strong&gt; A single vault I both type into on my phone and organize on my laptop. One folder of files, captured and curated in the same surface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One database app.&lt;/strong&gt; A hosted, Notion-style workspace where capture, structure, and storage all live in the same database, reachable from every device.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two layers.&lt;/strong&gt; A small, fast capture surface on the phone, feeding a larger, slower curation-and-storage surface on the laptop, with a defined path between them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The axes I scored them on
&lt;/h2&gt;

&lt;p&gt;I did not score on features. I scored on the same properties you would use to judge a level of memory.&lt;/p&gt;

&lt;p&gt;Write latency at capture: how long from "I have a thought" to "the thought is recorded and I can let go of it." For me this is the dominant cost, and I will explain why in a moment.&lt;/p&gt;

&lt;p&gt;Cost of a miss: what happens when the system is not ready in time. In a CPU, a cache miss costs you latency; you go fetch the line from a lower level. For a person capturing a thought, a miss costs you the thought.&lt;/p&gt;

&lt;p&gt;Retrieval latency: how long from "I need that note" to "I am reading it." This matters, but it matters at my desk, where I have minutes, not on a sidewalk where I have a second and a half.&lt;/p&gt;

&lt;p&gt;Durability and portability: will this note survive a vendor shutdown, an export, a decade. Plain markdown files on disk score high here. A proprietary database scores low, no matter how good the app feels today.&lt;/p&gt;

&lt;p&gt;Merge cost: what it takes to reconcile the same note touched in two places. The more places a note can be edited, the higher this climbs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why capture latency won
&lt;/h2&gt;

&lt;p&gt;The honest reason one axis dominated all the others: for a working memory system, the cost of a capture miss is not a slowdown. It is a total loss with no backing store.&lt;/p&gt;

&lt;p&gt;This is exactly where the cache analogy bends, and the bend is the most important part, so I want to be precise about it. In real silicon, a cache miss is recoverable. The data still exists one level down; you just pay more nanoseconds to go get it. A cache is an optimization layered on top of a guarantee. Human attention has no such guarantee. A thought I fail to record in the first second or two is not slower to retrieve later. It is gone, and usually I do not even know it is gone, which is worse. There is no L2 for the idea I had in the shower and lost.&lt;/p&gt;

&lt;p&gt;So the requirement on my top level is harsher than the requirement on a CPU's L1. L1 only has to be fast. My capture layer has to be fast enough that catching the thought beats losing it every single time: on a bad day, half asleep, one-handed, with a stranger talking at me. That pushed write-latency-at-capture and cost-of-a-miss to the top of the weighting and shoved everything else down. Retrieval can be slow. Storage can live elsewhere. Capture cannot.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision
&lt;/h2&gt;

&lt;p&gt;Two layers. The phone is L1. It does one job: take a typed line and get it somewhere safe in about one tap, with no decision attached. No folder prompt, no tag picker, no "which notebook." A line goes in and I keep walking.&lt;/p&gt;

&lt;p&gt;The laptop is L2. That is where the curating happens: where a captured line becomes a paragraph, gets filed, gets linked to a few older notes, or gets deleted because it was a 2 a.m. thought that did not survive contact with morning. My L2 is an Obsidian vault, plain markdown files synced through iCloud Drive, because durability and portability were the axes the storage level had to win, and plain text on disk wins them by default.&lt;/p&gt;

&lt;p&gt;The path between the levels is the part people skip, and it is the part that makes a hierarchy a hierarchy instead of two disconnected apps. At the end of the day, the lines I captured on the phone are already in the vault as timestamped bullets under that day's note: &lt;code&gt;- 14:22 the cache metaphor only works if eviction is automatic&lt;/code&gt;. I did not build a ritual where I sit down and copy things over, because a flush that depends on my discipline is a flush that will not happen, and I do not trust mine. The eviction is automatic. The line lands in the markdown file the same minute it lands in my inbox.&lt;/p&gt;

&lt;p&gt;If you want to be pedantic about the analogy, what I described is closer to a write-through cache than a write-back one: the line is mirrored down to L2 the instant it hits L1, not held in the fast layer and flushed later. I chose that on purpose. Write-back would leave a window where a captured line exists only on the phone, and that window is exactly when a dropped device or a force-quit becomes a lost thought. The expensive work, deciding what the line means and where it belongs, is the part I defer. The mirroring is immediate; the curation is a later compaction pass over data that is already safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that holds it together
&lt;/h2&gt;

&lt;p&gt;One rule, and it is the whole system: a level never does the other level's job. The phone never curates. The vault never captures.&lt;/p&gt;

&lt;p&gt;I learned this one the expensive way. For about three weeks last spring I tried to curate on the phone, because it felt efficient to tidy a note while standing in line for coffee. Within days my capture latency had crept from roughly a second to several, and I could feel the hesitation coming back. The reason was mechanical, not moral. The moment the capture screen also offers organizing affordances, capture stops being a reflex and becomes a small decision, and a small decision at the top of the hierarchy is the one thing the top of the hierarchy cannot afford. I tore it out and made the rule absolute. The phone got dumber on purpose, and capture got fast again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I rejected, and why
&lt;/h2&gt;

&lt;p&gt;The unified store lost on exactly this rule. If the place I capture into is also the place I keep and organize, then every capture happens inside a structure, and structure asks questions. Which folder. Which existing note. Which tag. Each question is a few hundred milliseconds of deliberation at the precise moment I can least afford it. A single store forces a schema decision at write time. The whole point of a hierarchy is to let the fast level stay schema-free and push all the structure down to a level that has time for it.&lt;/p&gt;

&lt;p&gt;The database app lost on durability and merge cost. A hosted database is a fine retrieval layer and a poor foundation, because the storage level is the one place I am least willing to rent. If the bottom of my hierarchy can be switched off by someone else's pricing decision, it is not storage, it is a long-term loan. Markdown files I can still read with &lt;code&gt;cat&lt;/code&gt; in 2035 are the opposite of a loan. I will trade a nicer query interface for that every time, because I can always build a better reader on top of durable files, and I can never retrofit durability onto a format I do not control.&lt;/p&gt;

&lt;h2&gt;
  
  
  The review I scheduled for myself
&lt;/h2&gt;

&lt;p&gt;I do not trust a decision I cannot re-examine, so I dropped a recurring note in the Obsidian vault to audit this one in 90 days. The questions I will put to future me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is capture still sub-second on a bad day, or has friction crept back in?&lt;/li&gt;
&lt;li&gt;Is eviction actually automatic, or have I quietly started hand-copying lines again?&lt;/li&gt;
&lt;li&gt;Did any line die between the layers this quarter? If so, where, and was it the path or me?&lt;/li&gt;
&lt;li&gt;Am I curating on the phone again without noticing? (Architecture drift is silent.)&lt;/li&gt;
&lt;li&gt;Is the bottom level still plain files I fully control, or did something convenient creep underneath it?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If every answer holds, the layout stays. If two or more have rotted, I redraw the napkin.&lt;/p&gt;

&lt;p&gt;That is the entire method: stop hunting for one memory that is fast and large and durable, accept that it does not exist, and build the levels instead. The cache hierarchy is not a productivity hack I am selling you. It is just the shape you arrive at when you take capture latency and durability seriously at the same time and refuse to let either one lose.&lt;/p&gt;

&lt;p&gt;If you think the cache metaphor breaks somewhere I did not catch, that is exactly the comment I want.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://apps.apple.com/us/app/captio-style-simple-memo/id6758438948" rel="noopener noreferrer"&gt;Simple Memo&lt;/a&gt; by myself — a one-tap iOS app that drops whatever I just typed into my email. On a curating day the same line is also sitting in a markdown vault in Obsidian. I write something up here every few days about the parts of working solo that I am still getting wrong.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>obsidian</category>
      <category>productivity</category>
      <category>pkm</category>
      <category>discuss</category>
    </item>
    <item>
      <title>I called my three quietest launches failures. I was wrong.</title>
      <dc:creator>Simple Memo</dc:creator>
      <pubDate>Tue, 02 Jun 2026 13:15:16 +0000</pubDate>
      <link>https://dev.to/simple_memo/i-called-my-three-quietest-launches-failures-i-was-wrong-2dfi</link>
      <guid>https://dev.to/simple_memo/i-called-my-three-quietest-launches-failures-i-was-wrong-2dfi</guid>
      <description>&lt;p&gt;Six months ago I wrote a line in my year-end review file that I now think was half wrong: "Three launches this year, three flops. Stop launching quietly." I believed both halves of that sentence when I typed it. I was right about one half and badly wrong about the other, and untangling which was which took me until last month.&lt;/p&gt;

&lt;p&gt;I am a solo developer. I have no growth team, no launch playbook, and no budget for a coordinated splash. When I ship, I ship alone, and the loudest part of any launch is usually the silence that follows. For a long time I read that silence as a verdict. This is the post I wish someone had handed me before I wrote that year-end line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I changed my mind about, in four lines:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Quiet launch" and "failed launch" are not the same event, and I had glued them together.&lt;/li&gt;
&lt;li&gt;The metric I judged launches by, signups on the day, was measuring the wrong window.&lt;/li&gt;
&lt;li&gt;Two of my three "flops" were still doing work months after I had written them off.&lt;/li&gt;
&lt;li&gt;The one thing I got right: chasing a spike for its own sake really is a bad use of my week.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The note I wrote to myself
&lt;/h2&gt;

&lt;p&gt;I keep a launch log. One dated line per launch, in the same notebook where I track everything else, the kind of line that flows into my markdown vault on the days I sit down to curate. Going back through that log is the only reason I can write this with real numbers instead of remembered feelings. Here are the lines, lightly cleaned up.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;2024-09-14 Product Hunt. 19 upvotes. Ranked ~30th for the day. Nothing.&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;2025-01-22 Show HN. 4 points. Off the front of /newest in 35 minutes. Nothing.&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;2025-04-03 Reddit r/iosapps update post. 6 upvotes, 2 comments, ~25 new users. Nothing.&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three times I wrote the word "Nothing." That word is the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Launch one: the Product Hunt day I treated as a verdict
&lt;/h2&gt;

&lt;p&gt;I had spent two weeks getting ready for Product Hunt. I built a gallery, wrote a maker comment, and lined up the one friend who would reliably click. On September 14, 2024, I posted at 7am Pacific and refreshed the page roughly every ninety seconds until lunch. By the end of the day I had 19 upvotes and a ranking somewhere around thirtieth. I closed the laptop and wrote "Nothing" in my log.&lt;/p&gt;

&lt;p&gt;Here is what I could not see that day. Three of those 19 upvotes came from people who later emailed me. One of them is still using the app eighteen months later, and he sent me the single most useful bug report I have ever received, about a keyboard race condition I could never have reproduced on my own device. The Product Hunt &lt;em&gt;day&lt;/em&gt; was a flop. The Product Hunt &lt;em&gt;launch&lt;/em&gt;, measured over a year, brought me my most engaged early user. I had been grading a marathon by the time at the first mile marker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Launch two: the Hacker News post that fell off in 35 minutes
&lt;/h2&gt;

&lt;p&gt;On January 22, 2025, I posted a Show HN. It got four points and dropped off the new page in about half an hour. By the brutal arithmetic of Hacker News, that is close to invisible. I wrote "Nothing" again and started drafting a small apology to myself about how I clearly could not write a title that lands.&lt;/p&gt;

&lt;p&gt;What I missed is that the post was indexed. For the next several months, a slow trickle of people searching for "iOS note to email" found that thread and the comments under it. One commenter had asked a sharp question about why I did not just use Shortcuts, and my answer to him became, almost word for word, a paragraph on my landing page that converts better than anything I wrote on purpose. The launch did not spike. It seeded. I did not own the word "seeded" yet, so I filed the whole thing under "failed."&lt;/p&gt;

&lt;p&gt;This is the launch I was most wrong about, because I came closest to acting on the wrong lesson. After Show HN I seriously considered paying someone to "do launches properly." I am glad I did not. The post was already working. I simply could not see work that refused to arrive as a graph spike on day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Launch three: the Reddit post that actually was quiet
&lt;/h2&gt;

&lt;p&gt;On April 3, 2025, I posted an update to r/iosapps. Six upvotes, two comments, about 25 new users I could attribute. Of the three launches, this is the one where quiet really did mean small. Months later I can find no thread of consequence leading back to it: no emails, no retained users I can trace, no sentence I quietly stole for the site.&lt;/p&gt;

&lt;p&gt;I am including it on purpose, because the honest version of this post is not "every quiet launch is really a hidden win." That is the comforting story that keeps you doing something that is not working. Launch three was a modest, forgettable event, and pretending otherwise would make the other two reversals worthless. The skill is not telling yourself every launch mattered. It is being able to tell which ones did, and that takes longer than a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was I actually wrong about?
&lt;/h2&gt;

&lt;p&gt;The error was not optimism or pessimism. It was the measurement window. I judged every launch by signups-on-the-day, because that number is available, emotional, and arrives while I am still paying attention. The numbers that actually mattered (a retained user, an indexed thread, a sentence that explained the product better than I could) all showed up weeks or months later, after I had stopped looking and already stamped the launch "Nothing."&lt;/p&gt;

&lt;p&gt;A spike is a measurement you can take in one afternoon. A seed is a measurement that requires you to come back in March. I had built my entire sense of whether launching was "worth it" on the one I could take quickly, which is exactly the wrong one for a solo dev with no paid acquisition and a product people adopt slowly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got right, and am keeping
&lt;/h2&gt;

&lt;p&gt;Here is the half of my year-end sentence I still stand behind. Chasing a spike for its own sake is a bad use of my week. I have watched indie developers, myself included, spend a fortnight orchestrating a Product Hunt run for a dopamine number that has evaporated by Thursday. For me, that prep time competes directly with shipping, and shipping is the only marketing I have ever found that compounds. So "stop launching for the spike" was correct. "Stop launching quietly" was the part I had wrong, and in December those two instructions felt identical. They are nearly opposite.&lt;/p&gt;

&lt;p&gt;What I do these days is smaller and slower. I still launch, because launches create indexed surface area and the occasional excellent user. But I write the log entry with a blank space next to it, and I refuse to fill in the verdict for ninety days. The launch is not "Nothing." It is "pending." I check back in a quarter, and only then do I decide what it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I might be wrong next
&lt;/h2&gt;

&lt;p&gt;I will name the part I am least sure about, because a confession that only covers old mistakes is too comfortable. My current belief is that ninety days is the right window. It might be too short. The Product Hunt user took eighteen months to surface that bug report; the Show HN thread is still trickling in. It is possible the correct unit for a solo launch is not a quarter but a year, and that I am about to repeat my original error one zoom level up: declaring launches "pending-failed" at ninety days when their real work lands at five hundred. Ask me in 2027. I will have a new line in the log, and probably a new thing I was wrong about.&lt;/p&gt;

&lt;p&gt;If you have ever shipped into silence, I would like to know whether your quiet launches seeded or just sank, and how long you made yourself wait before you decided.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm a solo developer building &lt;a href="https://apps.apple.com/us/app/captio-style-simple-memo/id6758438948" rel="noopener noreferrer"&gt;Captio-style Simple Memo&lt;/a&gt;, an iOS app that turns a typed line into an email before you can switch apps. On the days I sit down to curate, those same lines collect in a markdown vault. I post here when I've changed my mind about something and worked out why.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>indiehackers</category>
      <category>startup</category>
      <category>buildinpublic</category>
      <category>devjournal</category>
    </item>
    <item>
      <title>Todo debt: 32 field notes from a solo dev's notebook</title>
      <dc:creator>Simple Memo</dc:creator>
      <pubDate>Fri, 29 May 2026 13:21:30 +0000</pubDate>
      <link>https://dev.to/simple_memo/todo-debt-32-field-notes-from-a-solo-devs-notebook-2a75</link>
      <guid>https://dev.to/simple_memo/todo-debt-32-field-notes-from-a-solo-devs-notebook-2a75</guid>
      <description>&lt;ol&gt;
&lt;li&gt;&lt;p&gt;The capture friction matters more than the schema. Most of my "todo system overhauls" turned out to be schema redesigns, when the real bug was that adding a new task took eleven seconds instead of one.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Todo debt compounds the same way tech debt does, but I cannot see it in a profiler. Nobody writes a postmortem about a thing they did not do. There is no flame graph for items 311 through 480.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A todo from fourteen months ago is no longer a todo. It is a small piece of evidence that I once had different priorities. I have learned to treat it as data instead of as a guilty obligation.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The first 50 items in any list are mine. After 50, the list starts to belong to a former version of me, and the act of "processing" it is really an act of negotiating with a stranger.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;I tried tagging todos by energy level — &lt;code&gt;low_energy&lt;/code&gt;, &lt;code&gt;deep_work&lt;/code&gt;, &lt;code&gt;meeting_brain&lt;/code&gt;. I picked the wrong tag about seven times out of ten, and the act of picking ate the energy I was trying to budget.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A friend told me her trick: every Friday she archives any todo older than 90 days, unread. She has not regretted one archive in three years. I have copied this and the regret rate is the same for me.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The cost of a todo is not the time it takes to do it. The cost is the number of times I have to read it before I either do it or delete it. The "read tax" is the thing nobody charges for.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;I once tracked re-reads with a counter. The top item in my list was read 41 times before I finally killed it. It said &lt;code&gt;figure out RevenueCat&lt;/code&gt;. I never figured out RevenueCat, and the app shipped anyway.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;There is a class of todo that exists only to make me feel like I am still planning to do the thing. Naming this class — I call them "alibi todos" — was the first thing that helped me kill any of them.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Most "Inbox Zero" advice does not survive contact with a backlog of 600+ items. The advice assumes you started clean. I never have. The bigger lie is that I will reach the starting line by next Sunday.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Things I will never todo-app again: birthday gifts, replying to friends, reading specific books. They go on a calendar, or they go in a person, or they go nowhere. A todo app turns out to be the wrong substrate for any of them.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The half-life of an "urgent" tag in my system is about eleven days. After eleven days, the tag means nothing. I have stopped using it and the number of actually-urgent items I miss has not measurably changed.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;I rewrote my todo system four times before noticing that the cost of each rewrite, in lost todos, exceeded everything the new system was supposed to save. Migrations are the most expensive form of procrastination I have ever found.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A todo that lives across two devices and one syncing service is a todo that will eventually die alone in a conflict resolution dialog. The number of items I have lost to "newer version exists" prompts is, conservatively, in the dozens.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The most useful field I ever added to my todo schema was &lt;code&gt;created_at&lt;/code&gt;. Not &lt;code&gt;due_date&lt;/code&gt;. &lt;code&gt;created_at&lt;/code&gt;, so I could see how long I had been lying to myself about an item. Most of my schemas before that quietly hid this fact.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Tech debt eventually crashes the build. Todo debt does not crash anything. That is the whole problem with it. There is no red light. There is only the slow, invisible compounding of attention you owe to your past self.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;When I cleared 280 items in one session I felt nothing. When I cleared three items I had been postponing for months I felt lighter for a week. The relief is not linear in count; it is linear in shame.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A todo without a verb is a fragment of an idea. Most of mine are nouns: &lt;code&gt;RevenueCat&lt;/code&gt;. &lt;code&gt;Kani 2 update&lt;/code&gt;. &lt;code&gt;bench taxes&lt;/code&gt;. These never get done because they were never decisions in the first place. They are categories pretending to be tasks.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The phrase &lt;code&gt;quick win&lt;/code&gt; in a todo is a lie I tell myself to feel productive. I checked once: the median time-to-complete of items I had labeled &lt;code&gt;quick win&lt;/code&gt; was 27 days. The label predicts the opposite of what it claims.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;I once moved my entire backlog into a single &lt;code&gt;.txt&lt;/code&gt; file and grep-searched it for verbs. About 60% of items contained no verb at all. The 40% that did contain a verb completed at roughly four times the rate of the 60% that did not.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The single trick that did the most for my real backlog was forwarding the task to my own email with a date in the subject line. Mail clients surface time better than todo apps do. A todo without a date next to it is invisible after seven days.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A reasonable answer to "should this be a todo" is "no, this should be a calendar event with a hard end". The hard end is what makes me say "good enough" and stop. Open-ended todos invite open-ended polish.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The number of open todos at the start of any month predicts the number of features I will not ship that month. It does not predict the number I will ship. The two metrics are uncoupled, which surprised me when I first plotted them.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Every time I added a child-task feature to my homegrown todo app, the average nesting depth of my tasks grew by half a level. Features train me as much as I configure them. A flat list is partly flat because the tool refuses to nest.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;I keep two lists. One is for things I am doing this week. The other is for things I once told myself I should care about. The second list is where ideas go to be quietly disagreed with later.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The shortest-lived todo I have ever logged was open for four seconds. I typed it, captured it, and immediately remembered the answer was no. I keep a counter of these because they are the only honest thing in the system.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The longest-lived todo in my system right now is 893 days old. It reads: &lt;code&gt;simpler&lt;/code&gt;. I have never had the heart to delete it and I have never been able to act on it. It exists as a kind of weather.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The number of distinct tools I have used to track todos in the last decade is twenty-one. The number of those tools that survived more than one calendar quarter in my workflow is two. The other nineteen each took a weekend to set up.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;I keep a small &lt;code&gt;done.txt&lt;/code&gt;. Every line is a thing I finished. The file is open in a tab I never close. The most reliable productivity intervention I have is rereading the last ten lines when I am about to call the day a loss.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Project todos are different from house todos are different from life todos, and putting them in the same list is the same category error as putting &lt;code&gt;unit tests&lt;/code&gt; and &lt;code&gt;dentist&lt;/code&gt; next to each other. I learned this slowly and at the cost of several dental appointments.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The verbs that actually pull items out of my backlog are these, in order of frequency: &lt;code&gt;send&lt;/code&gt;, &lt;code&gt;reply&lt;/code&gt;, &lt;code&gt;delete&lt;/code&gt;, &lt;code&gt;decide&lt;/code&gt;, &lt;code&gt;cancel&lt;/code&gt;. &lt;code&gt;decide&lt;/code&gt; is doing the most work and is in the smallest number of todos.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The honest end state for most todo lists is not "completed". It is "no longer relevant". I have stopped treating that ending as a failure. It is just how a list of things you considered doing eventually ends.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;I write at &lt;a class="mentioned-user" href="https://dev.to/simple_memo"&gt;@simple_memo&lt;/a&gt;. I ship Captio-style Simple Memo, an iOS note-to-email app I built for myself first.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>devjournal</category>
      <category>beginners</category>
      <category>career</category>
    </item>
    <item>
      <title>An offline-first Outbox in Swift: 7 steps, no third-party libs</title>
      <dc:creator>Simple Memo</dc:creator>
      <pubDate>Tue, 26 May 2026 13:27:59 +0000</pubDate>
      <link>https://dev.to/simple_memo/an-offline-first-outbox-in-swift-7-steps-no-third-party-libs-4b4d</link>
      <guid>https://dev.to/simple_memo/an-offline-first-outbox-in-swift-7-steps-no-third-party-libs-4b4d</guid>
      <description>&lt;p&gt;Reproducing this in your own iOS project: seven steps.&lt;/p&gt;

&lt;p&gt;I have been running this Outbox in my note-to-email app for ten months. It survives a four-floor subway descent, a phone reboot mid-send, and the occasional iCloud Drive hang. It is roughly 240 lines of Swift, with zero third-party packages.&lt;/p&gt;

&lt;p&gt;Below is the recipe. I will write the code straight, the way it sits in my repo, and call out the failure modes I hit at each step. If you are building anything that needs to "send-and-forget" (analytics, message drafts, telemetry, optimistic UI mutations), most of this will transplant directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an Outbox actually is
&lt;/h2&gt;

&lt;p&gt;An Outbox is a durable queue that sits between your UI and the network. The UI hands it an Operation. The Outbox guarantees that Operation will be executed at least once, eventually, even if the user puts the phone in airplane mode, force-quits the app, and reopens it on a different cellular network six hours later.&lt;/p&gt;

&lt;p&gt;A short list of requirements drove every decision I made:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The user's tap must feel instant. Network latency is the Outbox's problem, not the UI's.&lt;/li&gt;
&lt;li&gt;A killed app, a rebooted phone, or a low-memory eviction must not lose work.&lt;/li&gt;
&lt;li&gt;I do not want to ship 4 MB of dependencies for a feature this conceptually small.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything below follows from those three.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Define a single envelope
&lt;/h2&gt;

&lt;p&gt;Everything that enters the Outbox is wrapped in one envelope. Plain &lt;code&gt;Codable&lt;/code&gt; struct, all the bookkeeping a retry loop needs, no payload coupling.&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;OutboxOperation&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;Identifiable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UUID&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;          &lt;span class="c1"&gt;// "sendEmail", "uploadAnalytics", etc.&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Data&lt;/span&gt;         &lt;span class="c1"&gt;// opaque to the Outbox&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;attemptCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;     &lt;span class="c1"&gt;// mutates on retry&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;nextEligibleAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;  &lt;span class="c1"&gt;// backoff target&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;lastError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;    &lt;span class="c1"&gt;// diagnostic only&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I keep &lt;code&gt;payload&lt;/code&gt; as opaque &lt;code&gt;Data&lt;/code&gt;. The Outbox never deserialises it. Each registered &lt;code&gt;OperationHandler&lt;/code&gt; knows how to decode its own payload type. This decoupling means I can add a new operation kind without touching the queue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure I hit.&lt;/strong&gt; My first version used a Swift &lt;code&gt;enum&lt;/code&gt; with associated values for &lt;code&gt;kind&lt;/code&gt;. It read beautifully and broke the first time I added a case in an app update. Every envelope written by the previous build failed to decode on launch, and I lost three hours of users' queued sends. A &lt;code&gt;String&lt;/code&gt; kind plus opaque &lt;code&gt;Data&lt;/code&gt; payload is uglier and forwards-compatible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Persist each operation as its own file
&lt;/h2&gt;

&lt;p&gt;I store the queue as a directory of JSON files inside the app's Application Support folder.&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;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;OutboxStore&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;encoder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;JSONEncoder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;decoder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;JSONDecoder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;NSLock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;base&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;FileManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&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="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;applicationSupportDirectory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;userDomainMask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;appropriateFor&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;create&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="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendingPathComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Outbox"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;isDirectory&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="k"&gt;try&lt;/span&gt; &lt;span class="kt"&gt;FileManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createDirectory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nv"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;withIntermediateDirectories&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="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;op&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;OutboxOperation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendingPathComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uuidString&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;.json"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;encoder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;options&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="n"&gt;atomic&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;OutboxOperation&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;urls&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;FileManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contentsOfDirectory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nv"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;includingPropertiesForKeys&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;compactMap&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;decoder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;OutboxOperation&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="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;contentsOf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$0&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="n"&gt;sorted&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;createdAt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;createdAt&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kt"&gt;FileManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nv"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendingPathComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uuidString&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="s"&gt;.json"&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="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;op&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;OutboxOperation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// same file path; atomic overwrite&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;Why one file per operation, not one big queue file? Two reasons. The first is crash safety. An atomic write of a 1 KB envelope finishes in microseconds. An atomic rewrite of a 10 MB queue file is a much wider window for the OS to kill you mid-write. The second is the iOS memory eviction model. With one file per op, only the envelopes the drain loop is currently reading sit in memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure I hit.&lt;/strong&gt; I forgot the &lt;code&gt;.atomic&lt;/code&gt; write option in the first version. A backgrounded app got killed mid-write to one envelope, which left a half-written JSON on disk. On next launch, &lt;code&gt;JSONDecoder&lt;/code&gt; threw on that one file. &lt;code&gt;try?&lt;/code&gt; swallowed the error and silently dropped the operation. The user lost a memo. The fix was both &lt;code&gt;.atomic&lt;/code&gt; and never &lt;code&gt;try?&lt;/code&gt; a decode without surfacing the failure to a diagnostic log.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Watch the network with NWPathMonitor
&lt;/h2&gt;

&lt;p&gt;Apple gives you this for free. There is no reason to install a reachability library in 2026.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;import&lt;/span&gt; &lt;span class="kt"&gt;Network&lt;/span&gt;

&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;NetworkWatcher&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;monitor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;NWPathMonitor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;DispatchQueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"outbox.network"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private(set)&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;isOnline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="kt"&gt;Bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Void&lt;/span&gt;&lt;span class="p"&gt;)?&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;monitor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pathUpdateHandler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;online&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;satisfied&lt;/span&gt;
            &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isOnline&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;online&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isOnline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;online&lt;/span&gt;
            &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;?(&lt;/span&gt;&lt;span class="n"&gt;online&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;monitor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;queue&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 opinions worth defending here. First, I treat "connected to a network" as "online" and never as "can reach my server". The reachability of a specific endpoint is a question I let the drain loop answer the hard way, by trying. Second, I do not debounce. If the user flips from Wi-Fi to cellular twice in a second, the drain loop will fire twice and the deduplication in Step 6 makes that safe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure I hit.&lt;/strong&gt; My early version queried &lt;code&gt;path.isExpensive&lt;/code&gt; and refused to drain on cellular. I thought I was being polite to the user's data plan. Then I noticed that the only feature in my app using the Outbox is the user's own action of sending their own note. They very much want it to go even on LTE. Letting the user's explicit intent override a cost heuristic was the right call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Drain with bounded concurrency
&lt;/h2&gt;

&lt;p&gt;The drain loop wakes on three triggers: a new enqueue, the network coming back, and a scheduled retry timer firing. It pulls eligible operations and runs them through their handlers.&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;actor&lt;/span&gt; &lt;span class="kt"&gt;OutboxDrainer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;OutboxStore&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;handlers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;OperationHandler&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;maxInFlight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&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;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;OutboxStore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;handlers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;OperationHandler&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;
        &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handlers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;handlers&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;drain&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="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;ops&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;eligible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ops&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nextEligibleAt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;withThrowingTaskGroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Void&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;group&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;slots&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;op&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;eligible&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;slots&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;maxInFlight&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                    &lt;span class="n"&gt;slots&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;addTask&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
                    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="n"&gt;slots&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;group&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="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;op&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;OutboxOperation&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="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;handlers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&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;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;op&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;error&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;I cap concurrency at three. Higher numbers used to win me 200–300 ms on initial drains of large queues, then I noticed those wins evaporated under any real radio condition. Three is enough to mask the latency of one slow request without saturating the cellular link.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure I hit.&lt;/strong&gt; My first drainer was not an actor. It was a class wrapping an &lt;code&gt;OperationQueue&lt;/code&gt;. Two concurrent triggers (network-up plus a new enqueue arriving in the same 50 ms window) would each schedule a drain, and the same operation would execute twice. Making the drainer an actor serialises drain calls automatically. The actor reentrancy debates aside, this is one of the cleanest wins Swift Concurrency gave me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Exponential backoff with a hard ceiling
&lt;/h2&gt;

&lt;p&gt;The retry policy is a single function. It mutates the envelope, persists it, and lets the next drain pick it up.&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;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;op&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;OutboxOperation&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="kt"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throws&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;updated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;op&lt;/span&gt;
    &lt;span class="n"&gt;updated&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attemptCount&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;600.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;updated&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attemptCount&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;jitter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;in&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;..&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;updated&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nextEligibleAt&lt;/span&gt; &lt;span class="o"&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="nf"&gt;addingTimeInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;jitter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;updated&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lastError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;describing&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;try&lt;/span&gt; &lt;span class="n"&gt;store&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="n"&gt;updated&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;Doubling with a 10-minute ceiling means a request that has been failing for an hour will only be retried every ten minutes after that point. I deliberately do not give up. There is no "drop after N failures" policy because in my app every queued operation is something the user explicitly typed and pressed send on. The right time to give up is when the user clears the queue manually from a settings screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure I hit.&lt;/strong&gt; My first backoff used a fixed 30-second retry. The first time my server had a 90-minute outage, every device in the wild was hammering it once every thirty seconds, and the post-recovery thundering herd took down my single Postgres instance for another twenty minutes. Exponential with jitter solved both problems with the four-line function above. Jitter costs nothing and saves you the day a thousand phones come back online at the same airport gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Make every handler idempotent
&lt;/h2&gt;

&lt;p&gt;A queue that promises at-least-once delivery is a queue that will, sooner or later, deliver something twice. Build for that on day one.&lt;/p&gt;

&lt;p&gt;Every operation already has a client-generated &lt;code&gt;UUID&lt;/code&gt;. The handler passes that UUID to the server in an &lt;code&gt;Idempotency-Key&lt;/code&gt; header. The server stores a row keyed by the UUID and the user, and the second call returns the first response from a small cache.&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;SendEmailHandler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;OperationHandler&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;api&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;APIClient&lt;/span&gt;
    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;req&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;JSONDecoder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;SendEmailPayload&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="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"/v1/send"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nv"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Idempotency-Key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uuidString&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;I keep the UUID inside the payload as well, not just on the envelope. That way the handler can be tested without an envelope, and the wire format is self-contained.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure I hit.&lt;/strong&gt; I forgot to make my analytics endpoint idempotent and reused the same Outbox for it. After a server hiccup, I had a user whose "opened settings" event was counted four times. Funnels read like the app had become viral overnight. Lesson: idempotency is not a server-only concern, but the server has to enforce it. The client only proposes; the server disposes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Hand the UI an optimistic state
&lt;/h2&gt;

&lt;p&gt;The UI does not wait for the network. It writes to a local store and shows the user the "sent" state immediately. The Outbox is a background fact.&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;@MainActor&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;ComposeViewModel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ObservableObject&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;@Published&lt;/span&gt; &lt;span class="kd"&gt;private(set)&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;sentLocally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;LocalEmail&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;let&lt;/span&gt; &lt;span class="nv"&gt;outbox&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Outbox&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Draft&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="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;local&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;LocalEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;sentAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;sentLocally&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;payload&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;JSONEncoder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="kt"&gt;SendEmailPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;draft&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;draft&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;outbox&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"sendEmail"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;payload&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;I do not surface a "queued" indicator. The user pressed send. Their mental model is "it sent." Showing "queued, will retry" in the UI is a tax I refuse to charge the user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure I hit.&lt;/strong&gt; My first version did show a spinner per queued message. Users hated it. The spinner was honest and useless: "queued, retrying" is information the user can do nothing with. Removing the spinner did not change a single delivery outcome and improved the perceived speed of the app noticeably. The Outbox should be invisible until it fails for so long that a user-visible warning is warranted, which in my app is an hour and which I have triggered exactly once in ten months.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common failures sidebar
&lt;/h2&gt;

&lt;p&gt;A short list of things I have seen go wrong with variants of this design, gathered from my own commits and from two friends who shipped their own versions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Filesystem case sensitivity.&lt;/strong&gt; Naming files with the raw &lt;code&gt;UUID().uuidString&lt;/code&gt; is fine on iOS but bites you the moment you copy your queue directory onto a macOS volume formatted case-insensitively for testing. Lowercase the filename if you ever read these files outside the app sandbox.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Codable&lt;/code&gt; evolution.&lt;/strong&gt; Adding a new field to &lt;code&gt;OutboxOperation&lt;/code&gt; without &lt;code&gt;Optional&lt;/code&gt; will break decode for every envelope written by a previous app version. New fields are always optional, with a default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background time.&lt;/strong&gt; The drain loop after a network-up event has roughly thirty seconds of background time before iOS suspends the app. Long uploads need &lt;code&gt;URLSessionConfiguration.background&lt;/code&gt;, which is a different story I am leaving out here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clock drift.&lt;/strong&gt; &lt;code&gt;nextEligibleAt&lt;/code&gt; is wall-clock. A user setting their phone's clock forward six hours will trigger an immediate drain of every queued op. In practice this has never happened to me. In paranoid mode I would use &lt;code&gt;ContinuousClock&lt;/code&gt; for the comparison.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk full.&lt;/strong&gt; &lt;code&gt;try data.write(.atomic)&lt;/code&gt; throws on a full disk. Handle the throw; do not silently lose the user's input. I show a one-time alert and keep the in-memory copy.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I would do differently if I started today
&lt;/h2&gt;

&lt;p&gt;I have now shipped this design across two apps. A few small changes I would make on a clean slate:&lt;/p&gt;

&lt;p&gt;First, I would use SwiftData for the store from day one. When I wrote the original, SwiftData was not stable enough for me to trust on the critical path. It is now, and it gives you a real query language for diagnostics, which the file-per-op approach does not.&lt;/p&gt;

&lt;p&gt;Second, I would expose a read-only &lt;code&gt;AsyncSequence&amp;lt;OutboxState&amp;gt;&lt;/code&gt; from the Outbox so the UI could subscribe to overall queue health without polling. Today I poll from a hidden settings screen, which works, but a SwiftUI integration would be cleaner.&lt;/p&gt;

&lt;p&gt;Last, I would write a fuzz test that randomly enqueues, drains, kills the app mid-drain, and replays the queue. Most of the bugs I shipped in this code would have been caught by one weekend of fuzzing.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to take this further
&lt;/h2&gt;

&lt;p&gt;Three things are worth your next afternoon, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add a hidden debug screen with a "retry now" button and a list of currently queued operations. You will use it more than you think when triaging real user reports.&lt;/li&gt;
&lt;li&gt;Wire &lt;code&gt;os_log&lt;/code&gt; with a &lt;code&gt;category&lt;/code&gt; of &lt;code&gt;"outbox"&lt;/code&gt; on every state transition. The signpost output in Instruments is shockingly informative once a queue starts misbehaving in the field.&lt;/li&gt;
&lt;li&gt;Read the actor reentrancy section of the Swift Concurrency proposal one more time. The Outbox is the place in my codebase where I most often regret not having read it more carefully.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Captio-style Simple Memo is an iOS app I maintain on weekends. It turns whatever I type into an email and sends it before I can second-guess. I write here when a piece of code surprises me. &lt;a href="https://apps.apple.com/us/app/captio-style-simple-memo/id6758438948" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>swift</category>
      <category>mobile</category>
      <category>iosdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I version every prompt I send to Claude. Here's why.</title>
      <dc:creator>Simple Memo</dc:creator>
      <pubDate>Fri, 22 May 2026 13:11:44 +0000</pubDate>
      <link>https://dev.to/simple_memo/i-version-every-prompt-i-send-to-claude-heres-why-3f9l</link>
      <guid>https://dev.to/simple_memo/i-version-every-prompt-i-send-to-claude-heres-why-3f9l</guid>
      <description>&lt;p&gt;&lt;strong&gt;Q1: Why did I start logging every prompt I send to Claude and Cursor?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A. Because I lost a Sunday afternoon in September 2025 trying to reconstruct a prompt I had nailed down three weeks earlier. The model's built-in history was useless — I could not search by the &lt;em&gt;shape&lt;/em&gt; of the prompt I half-remembered. After rewriting it from scratch and getting a worse answer, I decided to treat my prompts the way I treat my commits: append-only, plain text, lived in the editor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q2: What does each entry look like?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A. One block per non-trivial prompt. ISO timestamp, model, one-line task, the prompt verbatim, a few lines of outcome. No tags. No app. No CLI. A real entry from August 14, 2025:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## 2025-08-14T16:32+09:00  claude-sonnet-4-5  ios/captio&lt;/span&gt;
TASK: Make the share extension's preview card render in &amp;lt;50ms when the
host app passes a 4KB plain-text string.

PROMPT:
&lt;span class="gt"&gt;&amp;gt; I have an iOS share extension built in Swift. When the user shares&lt;/span&gt;
&lt;span class="gt"&gt;&amp;gt; plain text, I want to render a preview card showing the first 200&lt;/span&gt;
&lt;span class="gt"&gt;&amp;gt; chars and the character count. The preview is currently taking ~180ms&lt;/span&gt;
&lt;span class="gt"&gt;&amp;gt; and I think it is the AttributedString conversion. Show me a version&lt;/span&gt;
&lt;span class="gt"&gt;&amp;gt; that uses NSAttributedString and a CATextLayer instead, and explain&lt;/span&gt;
&lt;span class="gt"&gt;&amp;gt; the tradeoff in one paragraph.&lt;/span&gt;

OUTCOME:
&lt;span class="p"&gt;-&lt;/span&gt; Worked first try. Dropped to 38ms median on iPhone 13.
&lt;span class="p"&gt;-&lt;/span&gt; Tradeoff was correctly named: lost dynamic type support.
&lt;span class="p"&gt;-&lt;/span&gt; Reused the pattern two weeks later for the keyboard ext.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timestamp is the only required field. Everything else is optional. Half my entries have no &lt;code&gt;OUTCOME:&lt;/code&gt; block because the prompt failed and I bailed. That's fine. A log that punishes you for being honest is a log you stop writing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q3: How is this different from what ChatGPT, Claude, and Cursor already give me?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A. Three histories, three search bars, three different relevance algorithms. None of them index by the thing I actually remember about old prompts — the &lt;em&gt;shape&lt;/em&gt;, not the literal first sentence. I remember "the one where I asked it to write a property wrapper that throttled writes." Built-in searches are bad at shape. &lt;code&gt;grep&lt;/code&gt; over my own plaintext log is good at shape, because I named the shape in the &lt;code&gt;TASK:&lt;/code&gt; line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q4: When does the log start paying back?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A. Around month three. Months one and two are pure capture overhead — there is nothing to recall yet, so the work feels one-directional. The flywheel starts when I notice that I am looking things up in &lt;code&gt;prompts.log&lt;/code&gt; more often than I am opening a model's sidebar. I tally-marked the "wait, what worked last time?" moments on a sticky note for two months: down from roughly five per day to under one. That is two of my daily 90-second context breaks reclaimed, which compounds.&lt;/p&gt;

&lt;p&gt;The second-order payback is harder to measure but more interesting. Reading my own log end-to-end on a Saturday morning in March (ninety minutes total) surfaced three pattern clusters I had not noticed while writing them. One: roughly two-thirds of my failed prompts were failures of context, not capability. The model could have solved the problem; I had not given it the surrounding code or constraints. Two: my highest-hit-rate prompts cluster around naming the exact file and stating the constraint as a hard number. Three: I rephrase the same five questions every month across different projects. That observation gave me a small &lt;code&gt;templates/&lt;/code&gt; folder of reusable prompt scaffolds, and dropped my average turns-to-correct-answer on those repeating shapes from about 3.4 to 1.6 over the next two months.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q5: When is it overkill?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A. If you write fewer than maybe 20 non-trivial prompts a week, the capture cost outweighs the recall benefit. If your team already shares prompts in a repo or doc, that shared store is more valuable than a private log. If your work is mostly one-shot ("write me a regex," "fix this typo"), the recall path doesn't matter — you'll never look the prompt up again. I am also not religious about the format. I piggyback on a journaling habit I already had. If you don't have a "open a text file and write something" muscle, a fancier tool may be a better starting point for you than a flat file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q6: What's the unexpected win that I didn't plan for?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A. The log became a personal RAG. In April I was trying to get Claude to write a Swift property wrapper, and after two unsatisfying turns I pasted about 60 lines from &lt;code&gt;prompts.log&lt;/code&gt; (every previous time I had asked for a property wrapper, &lt;em&gt;including the failures&lt;/em&gt;) into the conversation as context, and asked it to write a new one in the same style. The third turn was the answer I wanted. Now I run an 11-line shell function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;prompthist &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;q&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-B1&lt;/span&gt; &lt;span class="nt"&gt;-A20&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; ~/notes/prompts.log &lt;span class="se"&gt;\&lt;/span&gt;
    | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 400 &lt;span class="se"&gt;\&lt;/span&gt;
    | pbcopy
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Copied matching prompt history to clipboard (&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;pbpaste | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; lines)."&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;prompthist "property wrapper"&lt;/code&gt; puts the entire context of every previous time I asked about property wrappers into my clipboard. The model reads my past failures and writes around them. This is &lt;code&gt;grep&lt;/code&gt;, not embeddings. For 14,000 lines and a sample size of one user, &lt;code&gt;grep&lt;/code&gt; is enough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q7: What did I get wrong about it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A. Three things. I assumed I would need tags. I don't. I tried JSON for "structure," and stopped within a month because schema decisions ate the writing energy. I assumed the log would teach me about the model; it actually taught me about myself. The highest-hit-rate prompts I write are uniformly under 80 words, second-person, name the exact file or function I care about, and state the constraint as a hard number. The clever, multi-example prompts I was proud of in 2024 had a worse track record than the boring ones. I had to read 200 of my own failures in one Saturday morning to see that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q8: What would I change if I started over today?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A. Two things. I would co-locate the log with my git repo from day one. Mine sits at &lt;code&gt;~/notes/prompts.log&lt;/code&gt; and is symlinked into every project, but for the first six months it lived only in &lt;code&gt;~/notes/&lt;/code&gt; and I kept forgetting to look at it inside a Cursor session. The fix was a Cmd+T jump-to-file shortcut to &lt;code&gt;prompts.log&lt;/code&gt; from any workspace. The second change: I would version it. The log itself is now in a private git repo with daily auto-commits. The commit history of my prompt history has answered "when did I last care about X?" twice already, and it cost me one afternoon to set up.&lt;/p&gt;

&lt;p&gt;I would not start with anything fancier than that. Vector embeddings, RAG pipelines, a homegrown CLI: I have looked at all three and the marginal benefit over &lt;code&gt;grep&lt;/code&gt; is, for one user and a five-figure number of lines, statistically indistinguishable from zero. The thing the log gives me is not retrieval; it is the &lt;em&gt;habit&lt;/em&gt; of capturing in the first place. Every tool I have evaluated lowered the retrieval cost at the expense of raising the capture cost. That trade is bad for me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q9: This one's for you.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you've kept a prompt log, or tried and abandoned one, I'd like to hear what made it stick for you, or what made you drop it. Especially if you switched away from a database or app back to a flat file, or went the other direction. Two sentences is plenty.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://apps.apple.com/us/app/captio-style-simple-memo/id6758438948" rel="noopener noreferrer"&gt;Captio-style Simple Memo&lt;/a&gt; — a one-screen iOS app that emails my note to my inbox in under half a second. I have shipped it alone since 2024. I post here whenever a habit changes how I work.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>prompts</category>
      <category>discuss</category>
    </item>
    <item>
      <title>I shipped an iOS app with zero third-party dependencies</title>
      <dc:creator>Simple Memo</dc:creator>
      <pubDate>Tue, 12 May 2026 13:48:09 +0000</pubDate>
      <link>https://dev.to/simple_memo/i-shipped-an-ios-app-with-zero-third-party-dependencies-2jpd</link>
      <guid>https://dev.to/simple_memo/i-shipped-an-ios-app-with-zero-third-party-dependencies-2jpd</guid>
      <description>&lt;p&gt;At 11:47 PM on a Sunday, I deleted the last &lt;code&gt;import Alamofire&lt;/code&gt; from my Xcode project. I replaced eight network calls with seventeen lines of &lt;code&gt;URLSession&lt;/code&gt; code. The unit tests passed. The app cold-started about 80 milliseconds faster. I went to bed.&lt;/p&gt;

&lt;p&gt;That was the moment I committed to a rule I have not broken since: the iOS app I'm shipping carries zero third-party dependencies. Twelve months later, it is the single most contentious thing about how I work as a solo dev.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Twelve months of zero third-party dependencies on a small iOS app got my IPA to about 2.1 MB and my cold start to 280 ms median, in exchange for roughly two weekends of work I would have skipped with off-the-shelf libraries.&lt;/li&gt;
&lt;li&gt;The biggest win is not performance. It is that every line of Swift in the project is debuggable by me, with no source-map archaeology and no vendored module to apologize for at 1 AM.&lt;/li&gt;
&lt;li&gt;The rule is not absolute. I would add a third-party library tomorrow if it solved a problem I could not reasonably solve myself in one weekend, with tests, and with a maintainer who actually answers issues.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;I am a solo developer. I ship an iOS app called &lt;a href="https://simplememofast.com/captio-alternative/" rel="noopener noreferrer"&gt;Captio-style Simple Memo&lt;/a&gt;. It does one thing: you type a note, you tap a button, the note is in your email inbox in roughly 0.3 seconds. No accounts. No sync. No cloud database. The whole product is a thin UI on top of &lt;code&gt;MFMailComposeViewController&lt;/code&gt;, a small encryption layer for the local draft cache, and a handful of system frameworks.&lt;/p&gt;

&lt;p&gt;The first prototype, in late 2024, used three third-party Swift packages: SnapKit for layout, KeychainAccess for secure storage, and a small async HTTP wrapper around &lt;code&gt;URLSession&lt;/code&gt; I had cargo-culted from my last job. None of them were strictly necessary. All three were habits.&lt;/p&gt;

&lt;p&gt;By month two, two things had happened. The IPA had drifted past 6 MB. Cold-start times measured on my old iPhone 12 mini moved from 220 ms to 410 ms. Neither number is ruinous on paper. But for an app whose entire identity is "you tap, the email goes," half a second of warmup is the product. So I started cutting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried first (and why it failed)
&lt;/h2&gt;

&lt;p&gt;The obvious play was triage. Keep the libraries that earned their weight, drop the rest. I made a spreadsheet. Each row: a third-party dependency. Each column: bytes added, build-time added, time-to-replace estimated in hours, unique value over Apple's SDK.&lt;/p&gt;

&lt;p&gt;It looked rational. It also failed. The triage spreadsheet got me to drop SnapKit, which I should have dropped on day one. It got me to keep KeychainAccess, because the time-to-replace estimate was four hours and I told myself four hours was too much. Three months later I needed to support an experimental share target that KeychainAccess did not handle cleanly in my setup. I burned six hours wrestling with the library before I gave up and wrote 60 lines of &lt;code&gt;SecItemAdd&lt;/code&gt; / &lt;code&gt;SecItemCopyMatching&lt;/code&gt; directly. The wrapper had saved me four hours up front and cost me six on the day I needed it most.&lt;/p&gt;

&lt;p&gt;That was the moment the rule stopped being aesthetic. I was not removing dependencies because zero-dependency code is morally superior. I was removing them because every dependency I keep is a future morning I do not control.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing that actually worked
&lt;/h2&gt;

&lt;p&gt;I rewrote the rule. Instead of "fewest dependencies possible," I wrote this in my project README:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A third-party dependency is allowed only if it would take me longer than one weekend to write a correct, well-tested replacement, AND the dependency is actively maintained, AND it has fewer than three transitive dependencies of its own, AND it ships with tests I can read.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The change is subtle but important. The old rule was a bias. The new rule is a gate. It forces me to do a tiny piece of estimation up front. It also forces me to confront an uncomfortable fact: most of the third-party libraries I had reached for in the past were not in the "longer than one weekend" bucket. They were in the "I never bothered to learn the system API" bucket.&lt;/p&gt;

&lt;p&gt;Here is what fell out the other side of that gate, in twelve months of shipping:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Networking: 38 lines of &lt;code&gt;URLSession&lt;/code&gt; plus a tiny &lt;code&gt;Result&lt;/code&gt;-based wrapper. Replaced Alamofire and one custom in-house wrapper.&lt;/li&gt;
&lt;li&gt;Keychain access: 62 lines wrapping &lt;code&gt;SecItem*&lt;/code&gt;. Replaced KeychainAccess.&lt;/li&gt;
&lt;li&gt;Layout: native Auto Layout with a small set of &lt;code&gt;NSLayoutConstraint&lt;/code&gt; helper extensions, about 40 lines. Replaced SnapKit.&lt;/li&gt;
&lt;li&gt;JSON: &lt;code&gt;Codable&lt;/code&gt;. There was never a real reason to keep SwiftyJSON.&lt;/li&gt;
&lt;li&gt;Logging: &lt;code&gt;OSLog&lt;/code&gt;. There was never a reason to keep CocoaLumberjack.&lt;/li&gt;
&lt;li&gt;Crash reporting: I do not have third-party crash reporting. I have &lt;code&gt;MetricKit&lt;/code&gt;, a handful of TestFlight users who actually report issues, and a hard rule that any crash on launch is a release blocker.&lt;/li&gt;
&lt;li&gt;Encryption for the local draft cache: AES-GCM via &lt;code&gt;CryptoKit&lt;/code&gt;, plus about 90 lines of file-format glue I wrote myself. Replaced a small encryption helper library.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total third-party packages in the shipping app today: zero. Total lines of code I wrote that exist solely to replace those libraries: roughly 300. Total time spent writing those 300 lines, including tests and rewrites: about 18 hours across twelve months. Two weekends, conservatively.&lt;/p&gt;

&lt;p&gt;The numbers I care about, measured on a fresh install on an iPhone 12 mini:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;IPA size: 2.1 MB&lt;/li&gt;
&lt;li&gt;Cold start (icon tap to first usable frame): 280 ms median over 50 launches&lt;/li&gt;
&lt;li&gt;Time-to-email-sent from first tap: median 312 ms&lt;/li&gt;
&lt;li&gt;Build time, clean: 11 seconds&lt;/li&gt;
&lt;li&gt;SwiftPM resolve time: 0 seconds, because there is nothing to resolve&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I am not pretending these numbers are amazing. For an app that does one thing, they are roughly the floor. The point is that the floor was reachable, by one person, in evenings, without a heroic engineering budget, and without any meaningful sacrifice in code quality on the parts I actually care about. Most of that was simply the absence of work I would have otherwise been doing: no integration glue, no version-pin debates with myself, and no "this library moved to v3, here are the breaking changes" weekends I had not budgeted for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does this matter for a small app?
&lt;/h2&gt;

&lt;p&gt;There is a quieter reason I kept the rule. Every Swift Package Manager dependency I add is a small bet on someone else's calendar. If the maintainer of a 3,000-star library posts a deprecation notice next month, that is now my problem. As a solo dev I do not have the bandwidth to absorb other people's roadmap decisions on top of Apple's. Apple already gives me one platform whose roadmap I do not control. Adding a second is a tax I cannot afford to pay quietly.&lt;/p&gt;

&lt;p&gt;There is also a debugging argument that I underrated until I lived it. When an iOS 17.x point release changed the behavior of &lt;code&gt;MFMailComposeViewController&lt;/code&gt; on dismissal in a way that left my view controller stack in a strange state, I needed about 40 minutes with the debugger to figure out what had changed. If that flow had been mediated by a third-party "mailer" library, my best case would have been waiting for the maintainer to ship a fix, and my worst case would have been forking, patching, vendoring, and learning a codebase I had spent months pretending I did not need to read.&lt;/p&gt;

&lt;p&gt;Zero dependencies, in this framing, is a debuggability decision. Every stack frame is mine. Every breakpoint lands in code I wrote and remember. The first time I hit a real production weirdness and the trace was entirely my own code, I noticed how much faster my brain moved. There was no library to be intimidated by.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently next time (counter-take)
&lt;/h2&gt;

&lt;p&gt;I want to be honest about where this approach is wrong, because I have read enough "zero dependencies" essays to know they tend to be smug.&lt;/p&gt;

&lt;p&gt;Three places I was wrong:&lt;/p&gt;

&lt;p&gt;First, I was wrong about Sentry-style crash reporting. For a year I leaned on "if it crashes on launch, my few beta testers will tell me." That is fine at a few hundred users. It is not fine if I ever cross a few thousand. I will add a thin crash-reporting integration before any real marketing push, and I will accept that the "zero" in my rule becomes "one" the moment the product has scale.&lt;/p&gt;

&lt;p&gt;Second, I was wrong to extend the rule to my server tooling. I run a tiny landing page on a small VPS. I spent a weekend writing my own static-site generator because of the rule. The site is fine. The weekend was wasted. The right answer was Hugo. The rule, I now think, applies cleanly to the shipping app and is mostly noise everywhere else.&lt;/p&gt;

&lt;p&gt;Third, I underestimated the cost of "I'll write it myself" in the analytics layer. I have no third-party analytics. I have a single anonymous keepalive ping per app launch and a self-hosted PostHog instance I never finished setting up. I should either commit to running PostHog properly or pick a privacy-friendly hosted analytics product. Pretending the problem does not exist is not a strategy.&lt;/p&gt;

&lt;p&gt;The honest version of the rule, after twelve months, is closer to: zero third-party dependencies inside the binary the user installs, while remaining boring and pragmatic about everything outside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways for other solo devs
&lt;/h2&gt;

&lt;p&gt;If you are considering this for your own project, here is what I would do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run the gate at the moment you reach for any library, not after you have already typed &lt;code&gt;import&lt;/code&gt;. The decision is cheap before it is in your code and expensive afterward.&lt;/li&gt;
&lt;li&gt;Time-box the replacement. If your "I could write this myself" estimate is over one weekend, the library probably earns its place.&lt;/li&gt;
&lt;li&gt;Write a one-paragraph block in your project README that names every dependency and the reason it survived the gate. Future-you needs the receipts.&lt;/li&gt;
&lt;li&gt;Measure binary size and cold start before and after each library decision. Numbers protect you from your own taste.&lt;/li&gt;
&lt;li&gt;Do not extend the rule to tools that never touch the user's device. Static-site generators, build scripts, and CI runners are not part of the product.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Open question for the comments
&lt;/h2&gt;

&lt;p&gt;I am curious what other solo devs do here. Have you tried a zero-dependency rule on a shipping app, and did it survive contact with a real feature? Or did you have the opposite experience, a single well-chosen library that paid for itself ten times over?&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What was the last third-party dependency you removed, and what did you replace it with?&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;I'm a solo dev building &lt;a href="https://apps.apple.com/us/app/captio-style-simple-memo/id6758438948" rel="noopener noreferrer"&gt;Captio-style Simple Memo&lt;/a&gt;, an iOS app that emails the note you just typed in about 0.3 seconds. I write here every few days about the messy parts of shipping things alone.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>ios</category>
      <category>swift</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>My inbox has been my only task tracker for 12 months</title>
      <dc:creator>Simple Memo</dc:creator>
      <pubDate>Fri, 08 May 2026 11:19:05 +0000</pubDate>
      <link>https://dev.to/simple_memo/my-inbox-has-been-my-only-task-tracker-for-12-months-3h52</link>
      <guid>https://dev.to/simple_memo/my-inbox-has-been-my-only-task-tracker-for-12-months-3h52</guid>
      <description>&lt;p&gt;A year ago I deleted Things, archived my Notion task DB, closed every kanban board, and made one rule for myself: every obligation lives in my email inbox or it doesn't exist.&lt;/p&gt;

&lt;p&gt;I'm a solo dev. Nobody assigns me tickets. The only thing I have to coordinate is me-now talking to me-tomorrow. So I wanted to find out what actually happens when "task tracking" stops being an app and becomes a single, boring inbox.&lt;/p&gt;

&lt;p&gt;Twelve months in, here's the honest report.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Treating my email inbox as the only task queue cut my daily app-switching from around 40 hops to under 10, measured with a tiny script for two weeks.&lt;/li&gt;
&lt;li&gt;The model only holds if you commit to two rules: end-of-day zero-inbox, and any obligation that isn't already an email gets emailed to yourself within 5 seconds of thinking it.&lt;/li&gt;
&lt;li&gt;It broke in three predictable places — multi-day backlog, multi-step projects, and recurring tasks — and the patches are smaller than building a "real" tracker on top.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The trigger was embarrassingly small. I was on a walk in March, thought of a one-line bug fix, and reached for my phone to add it to my Things inbox. By the time I had unlocked the phone, opened Things, tapped the plus button, waited for the input field, typed the thing, and chosen a list, ninety seconds had passed. The thought I started with was already softer.&lt;/p&gt;

&lt;p&gt;That night I counted: I had 312 open tasks across Things, Notion, GitHub Issues, two Linear projects from old contracts, a Reminders list for groceries, and Slack saved items I'd been ignoring since January. None of these systems talked to each other. Each one demanded its own ritual to enter and its own ritual to clear.&lt;/p&gt;

&lt;p&gt;So I tried something stupid on purpose. I deleted Things from my phone. I exported the rest into a single text file, archived that text file, and decided that for one quarter the only place a task could live was in my own email inbox. If I forgot it, fine. If it mattered, future-me would email past-me about it eventually.&lt;/p&gt;

&lt;p&gt;That quarter became a year.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried first (and why it failed)
&lt;/h2&gt;

&lt;p&gt;This wasn't my first run at unifying task tracking. Over five years I had cycled through OmniFocus, Things, Todoist, Notion databases, Apple Reminders, GitHub Issues even for personal stuff, plain markdown files in a &lt;code&gt;tasks/&lt;/code&gt; folder, and the back of my paper notebook. Each one felt clean for about three weeks.&lt;/p&gt;

&lt;p&gt;The pattern was always the same. I would set up a beautiful taxonomy. I would import old tasks. I would feel productive about the meta-work of organizing. Then within a month the system would split — some things in the app, some things in email, some things in Slack, some things in my head. The system that was supposed to be the single source of truth had become one of seven sources of truth.&lt;/p&gt;

&lt;p&gt;The deeper problem wasn't the apps. The deeper problem was that capture and review were separate rituals. Capture happened on my phone, in apps I had to open. Review happened on my laptop, in a different app I had to open. The two rituals never aligned, so the queue I captured into was never the queue I reviewed from.&lt;/p&gt;

&lt;p&gt;Email already solved that. Capture from anywhere, review from anywhere, single inbox, archive when done. I had been ignoring the most-used piece of software on my devices because it didn't have "task management" in the name.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing that actually worked
&lt;/h2&gt;

&lt;p&gt;The whole system fits in two sentences:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The inbox is the queue. Anything not in it does not exist as a task.&lt;/li&gt;
&lt;li&gt;End every workday with the inbox at zero — meaning every email is either replied to, archived, or scheduled-send back to myself for the day I actually want to deal with it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That second rule is doing all the heavy lifting. Scheduled-send turns email into a deferred queue. "Email me on Tuesday with the migration checklist" is one keystroke away on Gmail and Apple Mail. The thing I would have put in a "Someday" list now arrives in my morning inbox on the day it becomes relevant.&lt;/p&gt;

&lt;p&gt;For capture-from-anywhere I use the simplest possible rig: an iOS shortcut that opens a one-field compose window pre-filled with my own email address. One tap, type the thing, hit send. The note shows up in my inbox in under a second. The whole capture takes about three seconds, including unlocking the phone. That ninety-second walk-thought from March takes three seconds now.&lt;/p&gt;

&lt;p&gt;I measured the impact in two ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A small script counted how often I switched between apps on my Mac during the day. The two weeks before deleting Things averaged 38 task-related app switches per day. The two weeks after averaged 9. The cost of every "let me check my system" moment is gone, because the system is the same window I already have open.&lt;/li&gt;
&lt;li&gt;I kept a one-line journal note every Friday: "did I forget anything important this week?". In the year before the experiment that note had a "yes" 22 weeks out of 52. In the year of inbox-as-queue it had a "yes" 6 weeks out of 52, and three of those were obligations from people who texted me instead of emailing me — which is a different problem.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it broke (and the patches)
&lt;/h2&gt;

&lt;p&gt;The honest part. Three failure modes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backlog days.&lt;/strong&gt; Some days I cannot get to inbox zero. A trip, a sick day, a launch crunch. The inbox piles up and the next morning is psychologically heavy. The patch was banal: I gave myself permission to open the inbox, select all, archive everything older than 7 days, and trust that whatever mattered would email me again. In a year that "mass archive" move has nuked something genuinely important exactly twice. Both times the sender followed up. The cost of the rare miss is much lower than the cost of carrying a permanent backlog.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-step projects.&lt;/strong&gt; "Ship the new export feature" is not a task, it's a project. Email is bad at projects because each email is flat. I tried to make threads work as projects and gave up. The patch is that I keep one plain markdown file per active project, named &lt;code&gt;2026-export-rewrite.md&lt;/code&gt;, in iCloud Drive. The inbox holds a single email titled "Project: export rewrite (link in body)" with a link to that file. The email is the entry point. The file is the workspace. When the project ends, I archive the email and the file together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recurring tasks.&lt;/strong&gt; "Pay the AWS bill on the 1st" doesn't fit. Email is event-based, not schedule-based. I tried scheduled-send to myself every month, and it works for low-volume things, but for ten or twelve recurring obligations it becomes its own admin job. The patch is to keep recurring tasks in Apple Calendar with alarms, and accept that calendar is a separate system. Two systems is still a lot fewer than seven.&lt;/p&gt;

&lt;p&gt;What I'd do differently if I started today: I'd accept the calendar split from day one instead of pretending email could swallow scheduled events. That fight cost me a month.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways for other solo devs
&lt;/h2&gt;

&lt;p&gt;If you're shipping alone and tempted to try this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stop searching for the perfect tracker. The cost of switching trackers is higher than the cost of any tracker's missing feature.&lt;/li&gt;
&lt;li&gt;Make capture cheaper than thought. If your capture ritual takes longer than three seconds you will lose tasks no matter how nice the app is.&lt;/li&gt;
&lt;li&gt;Trust scheduled-send. It turns the inbox from a flat queue into a time-aware one without adding a second tool.&lt;/li&gt;
&lt;li&gt;Let recurring obligations live in the calendar. Don't try to unify everything.&lt;/li&gt;
&lt;li&gt;Re-archive aggressively. A backlog you can't face is worse than a backlog you delete.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Open question for the comments
&lt;/h2&gt;

&lt;p&gt;I'd genuinely like to hear how other solo devs and small teams handle this. Has anyone here tried treating their inbox as the single queue and stuck with it? Where did it break for you, and what was the patch?&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you went the opposite direction — committed harder to a structured tracker and got rewarded — I want to hear that too. I'm not religious about this.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;I'm a solo dev. The capture rig described above is essentially the iOS app I built — &lt;a href="https://apps.apple.com/us/app/captio-style-simple-memo/id6758438948" rel="noopener noreferrer"&gt;Captio-style Simple Memo&lt;/a&gt; — which sends a note to your own email in about 0.3 seconds. I write here every few days about the messy parts of shipping things alone.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>indiehackers</category>
      <category>watercooler</category>
      <category>discuss</category>
    </item>
  </channel>
</rss>
