<?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>The issue tracker was built for a team I don't have</title>
      <dc:creator>Simple Memo</dc:creator>
      <pubDate>Wed, 08 Jul 2026 01:40:28 +0000</pubDate>
      <link>https://dev.to/simple_memo/the-issue-tracker-was-built-for-a-team-i-dont-have-4pli</link>
      <guid>https://dev.to/simple_memo/the-issue-tracker-was-built-for-a-team-i-dont-have-4pli</guid>
      <description>&lt;p&gt;This is going to be a rant. I'll earn it before the end.&lt;/p&gt;

&lt;p&gt;Here is what set me off. Last Tuesday I spent forty minutes grooming a backlog that exactly one person will ever read. That person is me. I moved cards between columns, I set a priority field on each one, I dragged four of them into something my tool insisted on calling a sprint, and at no point in those forty minutes did I produce a line of code, a fixed bug, or a piece of information I did not already have in my head when I sat down.&lt;/p&gt;

&lt;p&gt;I have run my product alone for over a year. For most of that year I kept reaching, by reflex, for the same category of software every team I ever worked on used to coordinate itself: the issue tracker. Jira, Linear, GitHub Issues, a Trello board with a tasteful column layout — the brand kept changing, the reflex did not. I want to talk about why that reflex is wrong for a solo dev, because it took me an embarrassingly long time to name what was bothering me.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I was actually doing for forty minutes
&lt;/h2&gt;

&lt;p&gt;Break down the ceremony of an issue tracker and almost every ritual in it exists to answer a question that a &lt;em&gt;second person&lt;/em&gt; would ask.&lt;/p&gt;

&lt;p&gt;Story points exist so a team can forecast capacity across several people and argue about it in planning. Status columns exist so that when I hand a ticket to you, you can watch it move from "mine" to "yours" without either of us sending a message. A "definition of done" exists so that two people who disagree about what finished means can settle it in writing before it becomes a fight. Assignee fields, sprint boundaries, WIP limits, triage labels: every one of them is a coordination primitive. They are answers to the problem of getting work through more than one pair of hands.&lt;/p&gt;

&lt;p&gt;I have one pair of hands. When I set a story-point value, I am estimating my own capacity to myself, then reading my own estimate back later and feeling vaguely accused by it. When I move a card from "In Progress" to "Review," there is no reviewer. The card is moving from me to me. I had built an entire apparatus for handing work off, and then I stood on both sides of every handoff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is worse than wasted time
&lt;/h2&gt;

&lt;p&gt;If the tracker only cost me forty minutes a week I would shrug and pay it. The real cost is that the tool quietly reshapes the work to fit its own shape.&lt;/p&gt;

&lt;p&gt;A board wants to be tended. An empty column feels like failure; a full "Done" column feels like progress even in a week where I shipped nothing that mattered. I caught myself making tickets for work I had already finished, purely so I could drag them to Done and feel the small hit of it. That is status theater, and I was performing it for an audience of nobody.&lt;/p&gt;

&lt;p&gt;Then there is the guilt. A stale board is a quiet daily accusation. Forty-one open tickets, six of them older than a quarter, all of them technically "mine" and none of them moving: the board turns an ordinary backlog into a monument to my own unreliability. On a team that pressure is sometimes useful, because a colleague can see the same board and the shared visibility creates real accountability. Alone, it is just a machine for manufacturing low-grade shame, and I was paying a monthly subscription to run it.&lt;/p&gt;

&lt;p&gt;The mismatch underneath all of this is simple. An issue tracker is optimized for state that is &lt;em&gt;shared&lt;/em&gt; and &lt;em&gt;asynchronous&lt;/em&gt;: many people, touching the same work, at different times, needing a durable record of who holds what. My work is neither. It is one person, holding the whole thing in one head, in more or less continuous time. I was using async-coordination software to coordinate with no one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I reach for instead
&lt;/h2&gt;

&lt;p&gt;I keep a single plain-text file. Every line starts with a date and a verb. &lt;code&gt;2026-07-06 fix the timezone bug in the CSV export.&lt;/code&gt; When the line is done I do not drag it anywhere; I put an &lt;code&gt;x&lt;/code&gt; in front of it and move on. There are no columns, because there is nobody to hand a column to. There is no status beyond "still here" or "struck through."&lt;/p&gt;

&lt;p&gt;Time-bound obligations go in the calendar, because a calendar is honestly better than any tracker at the one job it has: telling me a thing must happen on a specific day. And the only real &lt;em&gt;queue&lt;/em&gt; I keep, the only place where work actually arrives from other people, is my email inbox. I wrote a whole separate piece about &lt;a href="https://dev.to/simple_memo/my-inbox-has-been-my-only-task-tracker-for-12-months-3h52"&gt;treating the inbox as the single task queue&lt;/a&gt;, so I won't relitigate it here, except to note the principle is the same one running through the text file: the thing that assigns me work is allowed to be the thing that tracks it, and I refuse to keep a second copy by hand.&lt;/p&gt;

&lt;p&gt;The one rule under all of it is that capturing a task has to be cheaper than the thought that produced it. If writing a task down takes eleven seconds of choosing a project and a label and a priority, I will not write half of them down, and the unwritten half is where the real bugs hide. So capture is a dated line and nothing else. It is the same instinct that made me build Simple Memo, my own capture app, around a single action instead of a menu: the instant you force someone to &lt;em&gt;decide&lt;/em&gt; where a thought goes, you have already lost some of the thoughts. Sorting can happen later, on a quiet Sunday, when sorting is cheap. The capture moment has to stay too dumb to ask questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part where I argue against myself
&lt;/h2&gt;

&lt;p&gt;I said I would earn this rant, so here is the half of it I think is wrong.&lt;/p&gt;

&lt;p&gt;There are cases where an issue tracker earns its place even for one person, and they share a tell: a second human shows up somewhere in the loop. The moment my project has outside contributors, a public bug tracker stops being ceremony and becomes the actual shared surface those people need. I cannot ask a stranger on the internet to append a dated line to my private text file. Anything with a compliance or audit requirement needs the durable, timestamped, who-changed-what record that a tracker gives you for free and a flat file does not. And there is one more that has nothing to do with other people at all: scale over &lt;em&gt;time&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That last one is the trade-off I am genuinely unsure about. My text file is fast to write and miserable to query. Ask me what I was working on in March and I am reduced to grepping the file and reading around the matches. A real tracker would let me filter by label and date in a second. For a project I intend to run for a decade, I may be trading away a queryable history that future-me will badly want, in exchange for a capture speed that present-me enjoys today. I have chosen present-me every time so far. I am not at all certain that is the right person to keep choosing, and I notice I grow less certain the older the file gets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you tell me I'm wrong
&lt;/h2&gt;

&lt;p&gt;Some of you run a personal tracker and love it, and some of you are on teams where the board is the only reason the work ships at all. I am not coming for your board. I am coming for the reflex: the assumption that because real software teams track work in Jira, a party of one should too.&lt;/p&gt;

&lt;p&gt;So here is the question I actually want answered, because I think it exposes the whole thing. What is the one field in your tracker that you fill in on every single ticket and have never once read back? Story points is my bet. Tell me I'm wrong, and tell me which field genuinely earns its keep when nobody but you will ever look at it.&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; alone — an iOS app that turns a line of text into a sent email in about a second, then gets out of the way. I post here a couple of times a week, usually when one of my strong opinions survives a fight with my own logs.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>agile</category>
      <category>devjournal</category>
      <category>career</category>
    </item>
    <item>
      <title>Taking apart the write path into another app's iCloud folder</title>
      <dc:creator>Simple Memo</dc:creator>
      <pubDate>Fri, 03 Jul 2026 13:23:09 +0000</pubDate>
      <link>https://dev.to/simple_memo/taking-apart-the-write-path-into-another-apps-icloud-folder-2pdp</link>
      <guid>https://dev.to/simple_memo/taking-apart-the-write-path-into-another-apps-icloud-folder-2pdp</guid>
      <description>&lt;p&gt;I am going to take apart, layer by layer, roughly 90 lines of Swift that let my app write a single line into a file owned by a different app, inside that app's own iCloud Drive container, with no plugin and no server in between. Four layers, one API most iOS developers never touch on purpose, and one bug that cost me a weekend because I trusted &lt;code&gt;FileHandle&lt;/code&gt; more than I trusted Apple's warning label on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's in this teardown:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why an iOS app cannot just guess another app's iCloud Drive path, and what &lt;code&gt;UIDocumentPickerViewController&lt;/code&gt; actually grants you&lt;/li&gt;
&lt;li&gt;Why the permission it grants goes stale, and the bookmark trick that survives a relaunch&lt;/li&gt;
&lt;li&gt;Why a plain append can silently corrupt a file mid-sync, and what &lt;code&gt;NSFileCoordinator&lt;/code&gt; changes about that&lt;/li&gt;
&lt;li&gt;The one case none of the above solves, and how I detect it instead of pretending it can't happen&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The thing I'm taking apart
&lt;/h2&gt;

&lt;p&gt;Since the end of May, a small iOS note-to-email app I build has had an optional feature: send a memo, and a timestamped line also lands in a markdown vault stored in iCloud Drive, formatted the way a daily note expects. No server round trip, no companion plugin installed inside the vault. One app writes into a folder it does not own, on a filesystem two different processes are actively syncing at the same time.&lt;/p&gt;

&lt;p&gt;The app is called Simple Memo, and I wrote about the two-layer capture-then-curate shape of its note system &lt;a href="https://dev.to/simple_memo/i-gave-my-notes-a-cache-hierarchy-phone-l1-vault-l2-73m"&gt;in an earlier post&lt;/a&gt;. This one is about the pipe underneath it, not the shape: the part that actually moves bytes across the sandbox boundary. That premise hides four separate problems, and I built the feature in the wrong order the first time. I wrote the "append a string to a file" part first, decided it was basically done, and only found the other three problems by making them happen on my own phone. Here they are, in the order iOS actually enforces them, not the order I discovered them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why can't I just hardcode the vault path?
&lt;/h2&gt;

&lt;p&gt;The instinct is to construct a path: &lt;code&gt;~/Library/Mobile Documents/iCloud~md~&amp;lt;vault app&amp;gt;/Documents/&amp;lt;vault name&amp;gt;/Daily/2026-07-03.md&lt;/code&gt;, or something close to it, and open a &lt;code&gt;FileHandle&lt;/code&gt; on it. On macOS this sometimes works, because the sandbox is looser and Full Disk Access is a checkbox away. On iOS it does not work, on purpose. Every app's iCloud container is a separate sandbox; one app cannot enumerate or open another app's container by path, no matter how well you know the naming convention, because the naming convention is not a contract — it is an implementation detail Apple can and does change between OS versions.&lt;/p&gt;

&lt;p&gt;The only sanctioned door in is &lt;code&gt;UIDocumentPickerViewController&lt;/code&gt;. The user picks a folder (their vault folder, in my case) through the system file picker, and iOS hands your process a security-scoped &lt;code&gt;URL&lt;/code&gt; — a token, effectively, not just a path string. That URL is only valid while you call &lt;code&gt;startAccessingSecurityScopedResource()&lt;/code&gt; on it, and only for as long as the system feels like honoring it in that session. This is the first real design constraint: the user has to grant access once, explicitly, through UI you do not control. There is no way to skip that dialog and no way to pre-select the folder for them. I tried, because a one-tap flow was the whole point of the app; Apple's sandbox model says no, and the "why" is the same reason your banking app can't read your email app's cache: cross-app storage access is opt-in per user gesture, not per developer intent.&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;requestVaultAccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nv"&gt;viewController&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UIViewController&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;picker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UIDocumentPickerViewController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forOpeningContentTypes&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;folder&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;picker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delegate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;
    &lt;span class="n"&gt;viewController&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;present&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;picker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;animated&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;documentPicker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UIDocumentPickerViewController&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                     &lt;span class="n"&gt;didPickDocumentsAt&lt;/span&gt; &lt;span class="nv"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;vaultURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first&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;guard&lt;/span&gt; &lt;span class="n"&gt;vaultURL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startAccessingSecurityScopedResource&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="c1"&gt;// grant refused or already revoked — surface this, don't silently no-op&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;defer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;vaultURL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stopAccessingSecurityScopedResource&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;persistBookmark&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;vaultURL&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 last line, &lt;code&gt;persistBookmark&lt;/code&gt;, is where layer two starts, and it is where I made my first wrong assumption.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does the permission go stale between launches?
&lt;/h2&gt;

&lt;p&gt;A security-scoped &lt;code&gt;URL&lt;/code&gt; does not survive process death. Quit the app, relaunch it, and the &lt;code&gt;URL&lt;/code&gt; you stored in memory (or, if you were careless, in &lt;code&gt;UserDefaults&lt;/code&gt; as a plain path string) is worthless — you'll get a permission error on the very first write, every single time, and the failure mode looks exactly like a bug in your write code instead of what it actually is: a missing re-authorization step.&lt;/p&gt;

&lt;p&gt;The fix is a security-scoped bookmark, which is a small opaque &lt;code&gt;Data&lt;/code&gt; blob you create once and can resolve back into a working &lt;code&gt;URL&lt;/code&gt; on every future launch, without asking the user again:&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;persistBookmark&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;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URL&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;bookmark&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;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bookmarkData&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="nv"&gt;includingResourceValuesForKeys&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;relativeTo&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="kt"&gt;UserDefaults&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;standard&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bookmark&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;forKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"vaultBookmark"&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;resolveVaultURL&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;URL&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&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="kt"&gt;UserDefaults&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;standard&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"vaultBookmark"&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;isStale&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;guard&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="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;resolvingBookmarkData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;data&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="nv"&gt;relativeTo&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;bookmarkDataIsStale&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;isStale&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;if&lt;/span&gt; &lt;span class="n"&gt;isStale&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// the folder moved, got renamed, or iCloud re-issued its identity — bookmark is dead, re-prompt&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;return&lt;/span&gt; &lt;span class="n"&gt;url&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;isStale&lt;/code&gt; is not a hint you can ignore. I ignored it for about two weeks of internal testing, on the theory that a stale bookmark would still resolve to something close enough. It does not. A stale bookmark that you use anyway will sometimes resolve to a URL that looks correct and fails on the write, and sometimes resolve to nothing your app can read at all, and the difference depends on exactly how the underlying folder moved — which is not something I control, because the user can rename their vault, or iCloud can re-shuffle the container during a sync conflict, entirely outside my app's runtime. Once &lt;code&gt;isStale&lt;/code&gt; comes back &lt;code&gt;true&lt;/code&gt;, the only honest move is to drop the bookmark and ask the user to re-grant access through the picker. I now show one line of UI for that ("Reconnect your vault folder") instead of failing the write silently, which is what the app did for those first two weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a plain append corrupt the file mid-sync?
&lt;/h2&gt;

&lt;p&gt;This is the layer that cost me the weekend, and it is the one most tutorials about &lt;code&gt;FileHandle&lt;/code&gt; do not mention at all, because most tutorials are not writing into a file that a &lt;em&gt;different process&lt;/em&gt; (the iCloud daemon) might be reading, uploading, or rewriting at the exact same moment.&lt;/p&gt;

&lt;p&gt;A naive append looks reasonable:&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;handle&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;FileHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forWritingTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;fileURL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seekToEndOfFile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;handle&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="n"&gt;lineData&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;handle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works close to 100% of the time on a phone sitting idle. It becomes unreliable the moment the file is actively syncing — for example, right after the user edited the same daily note on their Mac and iCloud is mid-upload of a newer version when your write lands. I reproduced this by editing the same daily note on my Mac and firing a memo from my phone within the same few seconds, on a loop, forty-some times. Three of those runs produced a daily note with a line inserted in the middle of another line's bytes: not a merge conflict dev.to readers would recognize from git, just corrupted UTF-8 you'd only notice by opening the file and seeing garbled text.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;FileHandle&lt;/code&gt; has no concept of "someone else might be touching this file right now." &lt;code&gt;NSFileCoordinator&lt;/code&gt; does — it is the actual mechanism iCloud Drive apps are supposed to use for exactly this, and it existed the entire time; I just did not reach for it on the first pass because the "write a string to a file" tutorials online almost never mention that a coordinator exists.&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;appendLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;line&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="n"&gt;to&lt;/span&gt; &lt;span class="nv"&gt;fileURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URL&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;coordinator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;NSFileCoordinator&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;coordinationError&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="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;writeError&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="n"&gt;coordinator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;coordinate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;writingItemAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;fileURL&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="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;forMerging&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;coordinationError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;safeURL&lt;/span&gt; &lt;span class="k"&gt;in&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;if&lt;/span&gt; &lt;span class="o"&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;fileExists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;atPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;safeURL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&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="kt"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;safeURL&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;handle&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;FileHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forWritingTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;safeURL&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="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seekToEndOfFile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;handle&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="kt"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&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;utf8&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="n"&gt;writeError&lt;/span&gt; &lt;span class="o"&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="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;coordinationError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;coordinationError&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;writeError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;writeError&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 details in that block are the ones I would flag if I were reviewing someone else's PR. First, &lt;code&gt;.forMerging&lt;/code&gt;, not the default writing intent, which assumes you are replacing the whole file. &lt;code&gt;.forMerging&lt;/code&gt; tells the coordinator (and, by extension, any &lt;code&gt;NSFilePresenter&lt;/code&gt; the other app registered, including that app's own sync layer if it's watching) that this is an additive change, which changes how the coordinator schedules your access relative to a concurrent iCloud upload instead of just serializing "biggest write wins." Second, the coordinator hands you a &lt;code&gt;safeURL&lt;/code&gt; inside the closure — not the URL you passed in. Writing to the original &lt;code&gt;fileURL&lt;/code&gt; instead of the coordinator-provided one defeats the entire point; I found that mistake in my own first draft by re-reading the closure a third time, not by a test failing.&lt;/p&gt;

&lt;p&gt;I re-ran the same forty-loop stress test after switching to the coordinator. Zero corrupted files across three separate runs. That is not a controlled benchmark (it is a home-brew stress test on one Mac and one phone), but it is the difference between the bug reproducing reliably and not reproducing at all, and for a background feature nobody watches while it runs, "not reproducing" is the bar that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive path vs. the coordinated path
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Plain &lt;code&gt;FileHandle&lt;/code&gt; append&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;NSFileCoordinator&lt;/code&gt; + &lt;code&gt;.forMerging&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Survives a concurrent iCloud upload&lt;/td&gt;
&lt;td&gt;No — corrupted 3/40 runs in my test&lt;/td&gt;
&lt;td&gt;Yes — 0/40 in the same test&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works after an app relaunch&lt;/td&gt;
&lt;td&gt;Only with a manual re-grant&lt;/td&gt;
&lt;td&gt;Yes, via bookmark resolution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Detects a placeholder (not-yet-downloaded) file&lt;/td&gt;
&lt;td&gt;No — throws a generic "file not found"&lt;/td&gt;
&lt;td&gt;No by itself — needs an explicit download check (below)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra code vs. a plain append&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;About 15 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Requires the user to grant folder access once&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (same requirement either way)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The one thing this doesn't solve
&lt;/h2&gt;

&lt;p&gt;None of the above helps if the target file is an iCloud placeholder your phone hasn't downloaded yet — which happens the first time you point the app at a vault someone else set up on a different device, or after the OS evicts an old file to save local storage. &lt;code&gt;NSFileCoordinator&lt;/code&gt; will hand you a &lt;code&gt;safeURL&lt;/code&gt; that exists in the directory listing and still fails the moment you try to actually read or write its bytes, because the bytes are not on the device.&lt;/p&gt;

&lt;p&gt;You have to check for this explicitly, before you coordinate the write:&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;values&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;fileURL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resourceValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forKeys&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;ubiquitousItemDownloadingStatusKey&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;values&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ubiquitousItemDownloadingStatus&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;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;startDownloadingUbiquitousItem&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;fileURL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// and then either poll resourceValues again, or accept the write will&lt;/span&gt;
    &lt;span class="c1"&gt;// land on next app launch — I chose the second, on purpose&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I chose to let the write silently defer to the next launch rather than block the UI on a download of unknown length over an unknown connection. That is the one deliberate trade-off in this whole pipe: I could hold the user's memo in a queue and retry the download-then-write on a timer, which would feel more "real-time," or I could accept that a first-time cross-device vault sync is rare enough that a one-launch delay is an acceptable cost for not adding a retry queue to a feature that already has three other failure modes to track. I picked the second, and I'm not fully sure it's the right call — it's the kind of decision that looks obviously correct until someone hits it on a slow hotel Wi-Fi and can't tell why their note didn't show up.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few questions I keep getting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does this need the Files app permission, or a special entitlement?&lt;/strong&gt;&lt;br&gt;
No special entitlement. &lt;code&gt;UIDocumentPickerViewController&lt;/code&gt; for folder access is a standard framework API; the entitlement you do need is the ordinary iCloud Documents capability if you also want your &lt;em&gt;own&lt;/em&gt; app's container to sync, which is a separate concern from writing into someone else's.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this work if the other app isn't running?&lt;/strong&gt;&lt;br&gt;
Yes, the write happens against the filesystem, not against the other app's process. The receiving app does not need to be open on any device for the append to land; it will pick up the change the next time it's opened or the next time it polls for changes, same as any other iCloud-synced edit made from another device.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not just use a plugin inside the other app instead?&lt;/strong&gt;&lt;br&gt;
Because that requires the user to install and configure something inside a second app, which is a much heavier ask than granting a folder once through a system picker — and it ties your feature's reliability to another app's plugin API staying stable, which is a dependency I did not want to own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one thing that mattered after taking it apart
&lt;/h2&gt;

&lt;p&gt;Every layer here exists because of the same rule: iOS does not trust one app's judgment about another app's files, and it is right not to. The permission model, the bookmark, the coordinator — none of it is incidental complexity you can shortcut past with a clever enough &lt;code&gt;FileHandle&lt;/code&gt; call. The closest thing to a lesson I'd underline twice is that &lt;code&gt;.forMerging&lt;/code&gt; line: the entire corruption bug disappeared behind one enum case I would have found in the documentation on day one if I had gone looking for "how do other apps write into iCloud files safely" instead of "how do I append to a file in Swift." The second question has a five-line answer that is wrong for this exact situation, and the wrong answer looks completely fine until two devices touch the same file within the same second.&lt;/p&gt;

&lt;p&gt;If you've built a write path into a file iCloud is actively syncing, I'd like to know whether &lt;code&gt;NSFileCoordinator&lt;/code&gt; alone solved it for you, or whether you also had to add your own conflict resolution on top of it — that's the part I'm least confident I've fully covered.&lt;/p&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;. Simple Memo is the iOS app behind the pipe this post takes apart. On the days I have background note-forwarding turned on, &lt;a href="https://simplememofast.com/en/obsidian/" rel="noopener noreferrer"&gt;the same write path lands a line in my vault&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>swift</category>
      <category>iosdev</category>
      <category>mobile</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The prompt is the cheap part. The context is the product.</title>
      <dc:creator>Simple Memo</dc:creator>
      <pubDate>Tue, 30 Jun 2026 13:27:55 +0000</pubDate>
      <link>https://dev.to/simple_memo/the-prompt-is-the-cheap-part-the-context-is-the-product-dh0</link>
      <guid>https://dev.to/simple_memo/the-prompt-is-the-cheap-part-the-context-is-the-product-dh0</guid>
      <description>&lt;p&gt;Suppose the model forgot you completely between every message. No memory, no history, no idea who you are or what you were doing thirty seconds ago. Every time you hit send, it wakes up with total amnesia, reads whatever you put in front of it, answers, and dies.&lt;/p&gt;

&lt;p&gt;That is not a thought experiment. That is just how a large language model works. The weights are frozen. Between two API calls it remembers nothing. Everything that feels like continuity (the model "knowing" your codebase, your tone, the bug you were chasing) is text that something assembled and handed over again, one more time, as if for the first time.&lt;/p&gt;

&lt;p&gt;I find it useful to take that fact and push it all the way to its conclusion, because the conclusion quietly changes what I think I am building when I work with these tools.&lt;/p&gt;

&lt;p&gt;Here is the first thing that falls out of it. If the model is amnesiac, the prompt is the most disposable artifact in the whole system. I used to treat the prompt as the thing I was crafting: the careful wording, the role-play, the "you are an expert" preamble. But a prompt lives for exactly one call and then it is gone. It is a paper airplane. Whatever value I thought I was banking in my prompt-writing skill was mostly evaporating on send.&lt;/p&gt;

&lt;p&gt;The durable thing, the thing that actually decides whether the answer is any good, is the context I assembled around the prompt. Which files I pulled in. Which past decision I pasted back. Which error log, which schema, which three sentences of "here is why I do it this weird way." That assembly is the real work. It is also the part I kept treating as an afterthought while I fiddled with adjectives in the instruction line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does this reframe matter?
&lt;/h2&gt;

&lt;p&gt;Because it moves the engineering surface to a different place than most people are looking.&lt;/p&gt;

&lt;p&gt;The industry has been circling this for about a year. Sometime in mid-2025 the phrase "context engineering" started showing up as the successor to "prompt engineering," and by 2026 it is the default framing. Anthropic published a whole piece on context engineering for agents; the trade reports are saturated with it. The short version of the shift: prompt engineering is about how you ask, context engineering is about what the model can see when you ask. The first is a sentence. The second is a system.&lt;/p&gt;

&lt;p&gt;I think the reframe is correct, but I want to put it more bluntly than the vendor posts do. The prompt is the cheap part. It is cheap to write, cheap to copy, cheap to throw away. The context is the product. It is expensive to assemble, expensive to keep current, and it is the one part nobody can lift from you, because it is made entirely of your specifics: your code, your constraints, your year of small decisions.&lt;/p&gt;

&lt;p&gt;And the moment context becomes the product, you walk straight into the thing the big-window marketing skips over. More context is not free. Models in 2026 ship million-token windows, and a few advertise ten million. But a long context is not a filing cabinet you can stuff and forget. Every serious evaluation I have read lands on the same failure: facts buried in the middle of a long context get lost, with accuracy dropping somewhere around 10 to 25 percent compared to the same fact placed at the start or the end. The bigger the window, the more "middle" there is to lose things in.&lt;/p&gt;

&lt;p&gt;So context is not "paste everything." Context is curation under a relevance budget. That is an editorial job, not a storage job, which is exactly why it does not get easier as the windows grow. A bigger truck does not make you a better packer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this changed about my own notes
&lt;/h2&gt;

&lt;p&gt;Here is where I stopped theorizing and looked at my own desk. If context is the product, then for me, working alone, the raw material for that product is the pile of things I have written down. My notes are not a memory aid anymore. They are the storage layer that everything I hand a model gets drawn from. A half-sentence I captured in a meeting in March becomes, in June, the exact line I paste in to stop the model re-suggesting the approach I already rejected and forgot I rejected.&lt;/p&gt;

&lt;p&gt;Which means the bottleneck is not the model, and it is not the prompt. It is capture. A thought I failed to write down is context that will never exist, no matter how large the window gets. I build a note-to-email app, so I am biased here, but the bias grew out of the problem rather than the other way around: the cheapest lever on the quality of my context is the friction of getting things into it. A lot of mine now goes in by voice. I talk, it transcribes on the device, the line lands while my hands are still on the keyboard or the steering wheel. The method is not the point. The point is that lowering capture friction raises the ceiling on every future prompt, and almost nobody budgets for it.&lt;/p&gt;

&lt;p&gt;I can date the moment this stopped being abstract. One afternoon in April I spent two hours fighting the same suggestion from Cursor, which kept proposing I cache a value I had very deliberately decided not to cache, for a reason that lived only in my head. Each time, I re-typed some version of that reason into the chat, got a fine answer for that one turn, closed the tab, and lost the explanation again. I was doing prompt engineering, I was doing it competently, and it was worthless, because none of it survived the session. The fix was not a sharper prompt. The fix was writing one durable line, the decision and its reason, into the place my notes already live, so that the next time the question surfaced I pasted instead of re-argued. The two hours I burned were not a model failure. They were a context failure, and the gap was entirely on my side of the keyboard.&lt;/p&gt;

&lt;p&gt;The shape of a context line that earns its keep, for me, looks boring on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-03-14 decided: no background refresh on the watch app.
reason: watchOS kills long-running sends; relay through the phone instead.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is not a note to myself. It is a paste-ready unit of context. When the model cheerfully suggests background refresh on the watch three months later, I do not argue with it and I do not re-derive the reasoning. I paste the line. The decision and the reason for it travel together, into the model's amnesiac little world, as if I had just explained both for the first time. I keep &lt;a href="https://dev.to/simple_memo/i-version-every-prompt-i-send-to-claude-heres-why-3f9l"&gt;a versioned log of every prompt I send&lt;/a&gt; for the same reason; the prompt I wrote is disposable, but the context that made it work is worth filing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The half of this I do not believe
&lt;/h2&gt;

&lt;p&gt;Now the part where I argue with my own thesis, because I only half-run it.&lt;/p&gt;

&lt;p&gt;If context is genuinely the product, the logical move is to hoard. Capture everything, store everything, retrieve aggressively, feed the model a fat dossier every time. I do not do that, and I have come to think hoarding is a trap, for two reasons. The lost-in-the-middle problem means a bloated context actively hurts: the signal drowns in the volume. And a context store you never prune rots. Last year's decision, the one I reversed in March, is still sitting there ready to mislead a model that has no way to know it is stale. A second brain stuffed with expired context is worse than a small one, the same way a confident wrong code comment is worse than no comment at all.&lt;/p&gt;

&lt;p&gt;So the honest version of my position is narrower than the slogan on the title line. Context is the product, but the product is edited, not accumulated. The skill is not "save more." It is knowing which one paragraph, out of a year of notes, to put in front of the model right now, and being willing to throw the rest out of the frame. That is a judgment I cannot automate yet, and I am not fully convinced I want to.&lt;/p&gt;

&lt;p&gt;So I run half my own thought experiment. I take seriously that the model forgets me, and I pour the effort into the context instead of the prompt. I refuse to take seriously the idea that more context is better, because the evidence and my own rotting notes agree that it is not.&lt;/p&gt;

&lt;p&gt;If you work with these tools every day, I am curious about one specific thing, because it is exactly where I am least sure of myself. What is the one piece of context you catch yourself re-explaining to the model over and over, the decision or the constraint or the "no, it has to be done this way," that you have never managed to store somewhere it gets reliably pulled back in? I want the actual line, not the category. I suspect the collected answers would draw a pretty accurate map of where the tooling still pretends the model remembers you, when it plainly does not.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm a solo dev. I build &lt;a href="https://simplememofast.com" rel="noopener noreferrer"&gt;Simple Memo&lt;/a&gt;, an iOS app that turns a thought into an emailed note before I lose it. I write here every few days about working with LLMs and shipping things alone.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>agents</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Five things shipping a product alone took from me</title>
      <dc:creator>Simple Memo</dc:creator>
      <pubDate>Tue, 23 Jun 2026 13:24:31 +0000</pubDate>
      <link>https://dev.to/simple_memo/five-things-shipping-a-product-alone-took-from-me-3ce0</link>
      <guid>https://dev.to/simple_memo/five-things-shipping-a-product-alone-took-from-me-3ce0</guid>
      <description>&lt;p&gt;My notebook has a running page I never meant to start. It is a list of things shipping alone has taken from me. Not skills I had to learn, the list of those is boring and endless, but things I used to have and gave up the day I decided to run a product by myself. I added five entries to it over the past few months. They are not complaints. Most of them I would give up again. But I had assumed solo work was mostly an addition problem, a matter of learning more jobs, and the page is my evidence that it is at least as much a subtraction problem. Here are the five, copied out and cleaned up enough to read.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second reader
&lt;/h2&gt;

&lt;p&gt;The first thing to go was the person who reads my work before the world does. No reviewer on the pull request, no editor on the release notes, nobody who notices that the button says "Cancle." I used to think code review was about catching logic bugs. Working alone taught me that most of its value was catching the dumb, surface things a tired author cannot see in their own work, because the brain helpfully renders what you meant instead of what you typed.&lt;/p&gt;

&lt;p&gt;I have tried to buy the function back without the person. I let a diff sit overnight before I merge it, because the version of me that reads it in the morning is a different enough reader to catch a few things. I write the reason for a change in the commit body as if I were explaining it to someone, and the act of explaining surfaces the parts that do not hold up. I lean on tests harder than I would on a team, because a test is the one reviewer that never gets bored on a Friday.&lt;/p&gt;

&lt;p&gt;None of it fully replaces the second reader. I keep a small file of bugs that shipped because no one but me looked: a date-math error that was off by a day only in one time zone, a setting that silently did nothing for two releases. Every entry is a thing a five-minute glance would have caught. I have made peace with the fact that some of those will always get through, and that part of the cost of working alone is paid by the people who hit them first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The roadmap
&lt;/h2&gt;

&lt;p&gt;I gave up the plan I could show people. Not planning itself, but the kind with quarters on it, the kind where "search is coming in the fall" is a thing you can say and mean. A team can make that promise because the plan survives any one person's bad week. Alone, I am the only resource, and the plan does not survive my bad week, my flu, the two days I lose to a bug that turns out to be a typo. Every estimate I make is really an estimate of a single human's uninterrupted attention, which is the least reliable quantity I know.&lt;/p&gt;

&lt;p&gt;So I stopped keeping a roadmap and started keeping a very short queue. I plan the next thing, sometimes the next two, and I refuse to dignify anything past that with a date. It feels irresponsible some days, like flying without instruments, and on the days a user asks when a feature is coming I have nothing honest to offer but "I don't know yet."&lt;/p&gt;

&lt;p&gt;What I got back is that I mostly stopped lying, including to myself. A roadmap for one person is a forecast dressed up as a commitment, and pretending otherwise just means apologizing later. The short queue is uglier and truer. I lost the comfort of a plan and the small authority that comes with being able to announce one, and I am still not sure that trade was free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Being good at one thing
&lt;/h2&gt;

&lt;p&gt;I used to be good at one thing. Going solo, I traded depth for surface area, and now I am passable at maybe six jobs and excellent at none. I design screens a real designer would quietly fix. I write marketing copy that makes actual marketers wince. My backend works and would not impress anyone who does backends for a living. Every part of the product carries the faint fingerprint of an amateur, because every part of it was made by someone doing that job part-time.&lt;/p&gt;

&lt;p&gt;For a while I treated this as a problem to fix, as if I could read enough about typography between releases and come out a designer. I cannot. The honest move was to accept the ceiling and decide where to spend my few hours of real focus. I let most things be a competent five out of ten and pour the effort into the one or two places where the product actually has to be sharp, which for me is the speed of the core action, the thing the whole app is about.&lt;/p&gt;

&lt;p&gt;The strange gift is that nothing gets lost in a handoff, because there is no handoff. The person who designs the screen is the person who writes the code behind it and answers the support email when it confuses someone. The seams between jobs, where teams leak the most, simply are not there. I gave up being good at one thing and got a blurry, end-to-end view of the whole that I do not think a specialist ever quite sees.&lt;/p&gt;

&lt;h2&gt;
  
  
  Someone else on call
&lt;/h2&gt;

&lt;p&gt;There is no one else on call. When something breaks at one in the morning, the rotation is me, and after me it is nobody. The first time my send pipeline started failing silently (a server hiccup that left notes stuck instead of delivered), I found out because I happened to be the user who noticed, and I fixed it at an hour no employer could have asked anyone to work.&lt;/p&gt;

&lt;p&gt;The lesson was not "work more hours." It was that I had to engineer the absence of a team into the product itself. If no human is on call, the software has to fail safe on its own. So the send path became an offline-first Outbox: a note is written to durable local storage the instant you hit send, and a background worker keeps retrying delivery with backoff until it succeeds, whether that is now, after a reboot, or after the subway. I built it less for the user's convenience than for my own sleep. The resilience I could not provide as a person, I had to provide as code.&lt;/p&gt;

&lt;p&gt;I gave up the safety net of someone else handling it, and the cost is that these days I over-engineer for failure in a way a bigger team might call paranoid. Maybe it is. But when you are the entire on-call rotation, the cheapest incident is the one that quietly resolves itself while you sleep, and I will spend real effort to buy more of those.&lt;/p&gt;

&lt;h2&gt;
  
  
  The person who tells me I'm wrong
&lt;/h2&gt;

&lt;p&gt;The last entry is the one I underestimated most. I gave up the colleague who tells me I am wrong before the market does. Alone, every idea I have is reviewed by a board consisting entirely of me, and that board approves almost everything, because my bad ideas show up wearing the same confidence as my good ones. There is no one in the room to say "that is a feature for you, not for users," which is the single most useful sentence I have ever been handed and now have to remember to say to myself.&lt;/p&gt;

&lt;p&gt;I have cobbled together substitutes. I keep a decision journal where I am required to write the strongest case against what I am about to do, and writing it honestly kills a fair number of plans. I ship small and fast so reality gets to disagree with me early, while a mistake is still cheap. And I have started treating the comment section as a de-facto review board. When I &lt;a href="https://dev.to/simple_memo/a-thought-you-cant-capture-in-a-second-is-already-gone-20n9"&gt;wrote here recently&lt;/a&gt; that capture latency is the only note metric that matters, a reader who builds a writing tool replied with the same finding from his own product: he had a category prompt blocking the start of a draft, removed it, and watched people actually start writing. That time the board agreed with me. But agreement from a stranger who tested the idea independently is worth more than agreement from the board inside my own head, precisely because it could have gone the other way.&lt;/p&gt;

&lt;p&gt;That is the one I would warn a new solo dev about. You can replace the reviewer and the on-call engineer with discipline and code. Replacing the person who disagrees with you is harder, because the failure mode is invisible from the inside. You do not feel an echo chamber. You just feel correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is still on the page
&lt;/h2&gt;

&lt;p&gt;The list in my notebook is not finished, and I have a feeling it never will be. Subtraction keeps showing up where I expected addition. I will copy out the next batch once it is long enough to be worth reading.&lt;/p&gt;

&lt;p&gt;If you ship alone too, tell me the entry you would add: the specific thing you gave up that you did not expect to miss, and whether you bought it back somehow (with a habit, a tool, a piece of code) or just learned to live without it. I am collecting these now, and the ones that still sting are the useful ones.&lt;/p&gt;




&lt;p&gt;I'm a solo developer. &lt;a href="https://apps.apple.com/us/app/captio-style-simple-memo/id6758438948" rel="noopener noreferrer"&gt;Simple Memo&lt;/a&gt; is the iOS app I make by myself: you type a line and it lands in your email about a second later. I write here every few days, mostly about the parts of going it alone I had to learn the slow way.&lt;/p&gt;

</description>
      <category>indiehackers</category>
      <category>solodev</category>
      <category>sideprojects</category>
    </item>
    <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;

&lt;p&gt;&lt;strong&gt;Further reading:&lt;/strong&gt; I later expanded these notes into a complete reference with the full SpeechSession, the AudioBufferConverter, and an SFSpeechRecognizer to SpeechAnalyzer migration table: &lt;a href="https://simplememofast.com/en/blog/ios26-speechanalyzer-live-mic" rel="noopener noreferrer"&gt;the full SpeechAnalyzer guide&lt;/a&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>
  </channel>
</rss>
