<?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: Captio-Style Simple Memo</title>
    <description>The latest articles on DEV Community by Captio-Style 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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3919840%2Ff3e34759-885a-4e5e-9959-57c82a1a9c45.png</url>
      <title>DEV Community: Captio-Style 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>Todo debt: 32 field notes from a solo dev's notebook</title>
      <dc:creator>Captio-Style 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>Captio-Style 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>Captio-Style Simple Memo</dc:creator>
      <pubDate>Fri, 22 May 2026 13:11:44 +0000</pubDate>
      <link>https://dev.to/simple_memo/i-version-every-prompt-i-send-to-claude-heres-why-3f9l</link>
      <guid>https://dev.to/simple_memo/i-version-every-prompt-i-send-to-claude-heres-why-3f9l</guid>
      <description>&lt;p&gt;&lt;strong&gt;Q1: Why did I start logging every prompt I send to Claude and Cursor?&lt;/strong&gt;&lt;/p&gt;

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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