<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Simple Memo</title>
    <description>The latest articles on DEV Community by Simple Memo (@simple_memo).</description>
    <link>https://dev.to/simple_memo</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3919840%2Ff3e34759-885a-4e5e-9959-57c82a1a9c45.png</url>
      <title>DEV Community: Simple Memo</title>
      <link>https://dev.to/simple_memo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/simple_memo"/>
    <language>en</language>
    <item>
      <title>I killed every meeting as a solo dev. Half of it backfired.</title>
      <dc:creator>Simple Memo</dc:creator>
      <pubDate>Tue, 28 Jul 2026 13:37:13 +0000</pubDate>
      <link>https://dev.to/simple_memo/i-killed-every-meeting-as-a-solo-dev-half-of-it-backfired-58ei</link>
      <guid>https://dev.to/simple_memo/i-killed-every-meeting-as-a-solo-dev-half-of-it-backfired-58ei</guid>
      <description>&lt;p&gt;The line I was proudest of, for about 14 months, was that I ran a product with zero meetings. No standups. No syncs. Not one calendar invite. I said it the way people announce they have quit caffeine: a little smug, waiting for the flinch of envy.&lt;/p&gt;

&lt;p&gt;Then I put one meeting back. A single recurring call, once a month. And the month after I added it, I closed a decision I had been circling for three weeks. The timing nagged at me enough that I went back through my own notes to work out what the meetings I had so proudly deleted were actually doing. The answer was not flattering.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Zero meetings as a solo dev was half right: the coordination meetings I deleted really were dead weight, because there was nobody on the other side to coordinate with.&lt;/li&gt;
&lt;li&gt;It was half wrong. Meetings also force decisions, put a witness on your commitments, and drop a hard stop on the workday. Those jobs did not vanish when the invites did. I just stopped paying for them on purpose and started paying interest instead.&lt;/li&gt;
&lt;li&gt;The fix was not reinstating meetings. It was rebuilding those specific jobs deliberately, with one standing call, a timeboxed weekly review, and a habit of deciding in public.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The boast, and where I picked it up
&lt;/h2&gt;

&lt;p&gt;I did not invent the anti-meeting position. I inherited it, fully formed, from people I respect.&lt;/p&gt;

&lt;p&gt;Paul Graham's 2009 essay "Maker's Schedule, Manager's Schedule" taught a generation of builders that a maker and a manager run on different clocks, and that a single midday meeting can blow a crater in an afternoon by splitting it into two pieces too small to build anything in. A year later, Jason Fried and David Heinemeier Hansson gave the movement its bumper sticker in &lt;em&gt;Rework&lt;/em&gt;, in a section literally titled "Meetings Are Toxic," where they pointed out that a one-hour meeting with five people is not one hour, it is five. I read both in my twenties and treated them as settled law.&lt;/p&gt;

&lt;p&gt;So when I went solo, dropping meetings felt less like a choice and more like finally getting to practice what I had preached. And here is the part I still stand behind: for a solo developer, most meetings genuinely are waste. A standup coordinates handoffs between people. A sprint planning session negotiates capacity across a team. A status meeting exists so that eight humans can build one shared picture of where things are. When the team is one person, all of that machinery is spinning against no load. It is the same reason I eventually stopped using an &lt;a href="https://dev.to/simple_memo/the-issue-tracker-was-built-for-a-team-i-dont-have-4pli"&gt;issue tracker built for a team I don't have&lt;/a&gt;: the tool was solving a coordination problem I no longer had.&lt;/p&gt;

&lt;p&gt;That much was right, and I want to be clear about it before I take the rest apart. Deleting coordination meetings cost me nothing, because there was nothing there to coordinate.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what were the meetings really doing?
&lt;/h2&gt;

&lt;p&gt;The mistake was assuming coordination was the &lt;em&gt;only&lt;/em&gt; thing meetings did. It was the most visible thing, so I concluded it was the whole thing. It wasn't.&lt;/p&gt;

&lt;p&gt;When I traced my worst weeks back to a cause, the same few functions kept showing up, and every one of them was a job a meeting used to do off to the side, while everyone thought the point of the meeting was the agenda.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What a meeting also did&lt;/th&gt;
&lt;th&gt;My solo substitute&lt;/th&gt;
&lt;th&gt;What it actually cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Forced a decision by a hard date&lt;/td&gt;
&lt;td&gt;"I'll decide once I've thought it through properly"&lt;/td&gt;
&lt;td&gt;Decisions drifted for weeks; a deadline had been doing work I never credited it for&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Put a witness on my commitments&lt;/td&gt;
&lt;td&gt;Kept the plan in my own head&lt;/td&gt;
&lt;td&gt;I quietly abandoned plans nobody had seen me make&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Surfaced disagreement in real time&lt;/td&gt;
&lt;td&gt;Argued both sides with myself on paper&lt;/td&gt;
&lt;td&gt;I shipped confident mistakes with no one to catch them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dropped a hard stop on the day&lt;/td&gt;
&lt;td&gt;Worked until I drifted off it&lt;/td&gt;
&lt;td&gt;The task sprawled to fill every hour I gave it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these are coordination. All of them are load-bearing. And unlike the coordination overhead, which I could delete for free, these four were structural. Removing the meeting removed the structure and left the need standing there with nothing to lean on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three weeks I lost to a decision nobody forced
&lt;/h2&gt;

&lt;p&gt;Here is the one that finally got my attention, because it was expensive and entirely my own fault.&lt;/p&gt;

&lt;p&gt;I had a schema change to make. Small, reversible, the sort of thing that in a previous job would have come up in a Tuesday sync, gotten fifteen minutes of mild argument, and been decided by Wednesday because the meeting simply ended and a choice had to exist by then. On my own, with no meeting to end, the decision had no deadline attached to it. So I did the responsible-sounding thing and kept thinking about it.&lt;/p&gt;

&lt;p&gt;I thought about it for three weeks. I wrote two documents weighing the options. I felt productive the whole time, because writing documents feels like work. But the schema did not change for 21 days, and the feature sitting on top of it did not ship, and the actual amount of new information I gathered in week three versus week one was close to zero. I had not been deliberating. I had been marinating.&lt;/p&gt;

&lt;p&gt;A meeting would have killed that decision in a day, not because meetings are smart but because they are &lt;em&gt;over&lt;/em&gt; at a specific time, and a decision that has to exist by the time a meeting ends tends to exist. I had thrown away the deadline along with the small talk. This is the thing the anti-meeting essays are quietest about: for someone like me, the meeting was never the interruption. The meeting was the forcing function, and the calendar was doing my executive function for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed without reopening my calendar
&lt;/h2&gt;

&lt;p&gt;I want to be honest that this is where my zero-meetings boast became literally false, and I think that is fine. The goal was never zero meetings. The goal was zero &lt;em&gt;waste&lt;/em&gt;. I had confused the two.&lt;/p&gt;

&lt;p&gt;I rebuilt those functions on purpose, and only those. The forcing function and the hard stop came back together, as clocks. A standing weekly review, 30 minutes with a timer running, where any decision I have been "thinking about" for more than a week has to resolve or get explicitly killed, plus a fixed end-of-day I hold to so the work stops sprawling into the evening. The witness came back as deciding in public. When I commit to something now, I write it somewhere a reader could see it, because a plan with a witness behaves differently from a plan I am keeping in my head, even when the witness is a handful of strangers who will never follow up.&lt;/p&gt;

&lt;p&gt;And the disagreement came back as the one real meeting, the monthly call, with another solo developer who is under no obligation to be nice to me. I had already written about losing &lt;a href="https://dev.to/simple_memo/five-things-shipping-a-product-alone-took-from-me-3ce0"&gt;the one person who tells you you're wrong&lt;/a&gt; when you ship alone. The meeting is where that person used to live. Rebuilding a thin version of them was worth exactly one calendar invite a month.&lt;/p&gt;

&lt;p&gt;Notice what I did not add back: a standup, a status meeting, anything recurring whose job is coordination. Those stayed dead, because those really were waste. I paid to restore the functions I had lost and refused to pay for the ones I hadn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Isn't this just meetings with extra steps?
&lt;/h2&gt;

&lt;p&gt;This is the fair objection, and I have argued it against myself more than once, so let me put it at full strength.&lt;/p&gt;

&lt;p&gt;You could say I have simply reinvented meetings, badly, and that my "timeboxed review" is a standup with a party of one, my "deciding in public" is a status update, and my monthly call is just a meeting I have decided to feel clever about. There is a version of this where I spent 14 months taking the machine apart only to rebuild most of it and claim I invented something.&lt;/p&gt;

&lt;p&gt;I think that objection is about 60% right, and the honest 40% is this. The reconstructed versions are strictly weaker than the real thing at exactly one job, disagreement, and that weakness is not fixable. When I argue both sides of a decision on paper, I am still the one holding the pen, and I cannot fully surprise myself. The monthly call helps, but it is one hour against a whole month of me agreeing with me. I do not actually know yet whether that call is doing real work or whether it is a comfort blanket I have talked myself into needing. Ask me in a year. Forcing, witnessing, and stopping all rebuild cleanly, because they are mechanical and do not need a second brain. Disagreement is the one that genuinely wants another human, and it is the one I am still faking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I think I'm wrong next
&lt;/h2&gt;

&lt;p&gt;If the pattern holds, the belief I am currently too proud of is that async always beats sync. I have spent this whole post defending a slower, written, meeting-light way of working, and I still believe it for most things. But I have started to notice a specific class of problem, usually a tangled decision with three or four interacting unknowns, where 20 minutes of live back-and-forth with one person gets me further than a week of careful async ever does. I suspect I am still underpricing synchronous bandwidth for exactly the decisions that are too knotty to write down cleanly, and that in a year I will be back here explaining which half of &lt;em&gt;this&lt;/em&gt; post was wrong.&lt;/p&gt;

&lt;p&gt;That is the actual lesson, if there is one. Not "meetings good" or "meetings bad." Just that when you delete a ritual, you should figure out every job it was silently doing first, because some of those jobs do not have the decency to announce themselves on the way out.&lt;/p&gt;

&lt;h2&gt;
  
  
  A couple of questions I keep getting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Doesn't a weekly self-review just become another thing you skip?&lt;/strong&gt; It did, until I put a hard external date on it by tying it to the same day I do my monthly numbers, which I will not skip because money is involved. Attaching the soft ritual to a hard one was the only version that survived.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not just get better discipline and decide faster without any of this?&lt;/strong&gt; Because "get better discipline" is the plan that has failed every single time I have tried it, and building a structure that makes the right thing happen on a schedule is the plan that works. I would rather engineer the forcing function than keep losing to my own good intentions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if you had employees?&lt;/strong&gt; Then most of what I deleted would come straight back, because it would be doing real coordination work again, and this entire post would be irrelevant. That is the point. The right number of meetings is a function of how many people have to stay in sync, and for a team of one that number is small, but it is not zero.&lt;/p&gt;

&lt;p&gt;Here is the question I would actually like answered: when you removed a meeting, a standup, a weekly sync, a recurring call, what job did it turn out to be doing that you only noticed once it was gone? I am collecting these, because I suspect forcing, witnessing, and stopping are not the only three.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm a solo developer building an iOS app called Simple Memo. It &lt;a href="https://simplememofast.com/" rel="noopener noreferrer"&gt;turns a thought into a sent email&lt;/a&gt; before I can talk myself out of it. I write here every few days about the parts of working alone that nobody warns you about.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>management</category>
      <category>sideprojects</category>
      <category>remote</category>
      <category>discuss</category>
    </item>
    <item>
      <title>A 15-second interruption cost me 40 minutes of re-entry</title>
      <dc:creator>Simple Memo</dc:creator>
      <pubDate>Fri, 24 Jul 2026 13:37:08 +0000</pubDate>
      <link>https://dev.to/simple_memo/a-15-second-interruption-cost-me-40-minutes-of-re-entry-4b5f</link>
      <guid>https://dev.to/simple_memo/a-15-second-interruption-cost-me-40-minutes-of-re-entry-4b5f</guid>
      <description>&lt;p&gt;The auth bug was ten minutes from fixed. I had the whole failure loaded in my head: the token refresh racing the retry, the three files it touched, the one line I was reaching for. Then a Stripe email slid across the top of the screen at 3:12 PM. I read it. Fourteen seconds, maybe. Then I looked back at Xcode and the structure was just gone. Rebuilding it took the better part of forty minutes.&lt;/p&gt;

&lt;p&gt;That ratio bothered me enough to start timing it. A fourteen-second glance had cost me forty minutes, and no tool I owned had a place to record that. My calendar knew about the meeting. My timer knew the hours. Nothing knew the tax.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What thirty days of timing actually showed me&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The interruption is the cheap part. Re-entry is where the time goes, and almost nobody measures it.&lt;/li&gt;
&lt;li&gt;My re-entry cost was bimodal. Trivial switches ran about two minutes; deep ones ran thirty to forty-five. There was almost nothing in between.&lt;/li&gt;
&lt;li&gt;The single biggest predictor of which one I got was stupidly simple: whether I had written down my next move before I left.&lt;/li&gt;
&lt;li&gt;Blocking interruptions barely moved the number. Making re-entry cheap moved it a lot.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The setup, and why a solo dev can't just "avoid distractions"
&lt;/h2&gt;

&lt;p&gt;Here is the part the productivity advice skips. As a solo developer I am the engineer, the support desk, the ops on-call, and the person who notices the payment failed. Most of my interruptions are not distractions I can silence. They are the job arriving out of order. "Turn off notifications" is advice written for someone who has colleagues to absorb the things they ignore. I don't have colleagues.&lt;/p&gt;

&lt;p&gt;So after the forty-minute day I stopped trying to block interruptions and started measuring what they cost. One line per interruption for thirty days: the time, what I was doing, what pulled me away, and when I was genuinely back in. That last field turned out to be the one that mattered. I defined "back in" as my first correct edit after returning, not the moment my eyes hit the screen. Those are very different timestamps.&lt;/p&gt;

&lt;p&gt;I want to be honest about the instrument before I quote it. This is one developer timing himself, and on a bad day the faculty doing the timing is the same one the bad day has already degraded. Treat my numbers as a shape, not a benchmark.&lt;/p&gt;

&lt;p&gt;The population number does exist, and it is worse than most people think. Gloria Mark's team at UC Irvine, shadowing office workers through their real days, found it took an average of &lt;strong&gt;23 minutes and 15 seconds&lt;/strong&gt; to return to a task after an interruption, and that people don't go straight back but pass through about two intervening tasks first. Her 2023 follow-up is bleaker: average attention on a screen has fallen to roughly 47 seconds, with an interruption about every three minutes. The 23-minute figure is real. What it hid, for me, was the shape underneath the average.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is re-entry the expensive part, not the interruption?
&lt;/h2&gt;

&lt;p&gt;Because the interruption doesn't charge you for its own length. It charges you for the teardown.&lt;/p&gt;

&lt;p&gt;When I'm deep in a bug I'm holding a small structure in working memory: which call races which, what I've already ruled out, the exact edge I'm standing on. That structure isn't saved anywhere. It lives in the most volatile storage I own. A fourteen-second read doesn't cost fourteen seconds; it evicts the structure, and then I have to rebuild it from source — reopen the files, re-derive what I'd ruled out, walk back to the edge. Mark's two-intervening-tasks finding is the same story told from the outside: you rarely drop straight back into the thing. You bounce off two smaller things on the way.&lt;/p&gt;

&lt;p&gt;This is the extension of something I found the hard way a couple of months ago, when I was &lt;a href="https://dev.to/simple_memo/i-stopped-counting-hours-and-started-counting-energy-4kam"&gt;logging energy instead of hours&lt;/a&gt;: the villain in that ledger was never interruption either, it was unresolved ambiguity running a background thread and eating the depth I was saving for the hard task. This is the same monster in a different coat. Interruption isn't expensive. Losing your place is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the timer showed: the same switch, two different bills
&lt;/h2&gt;

&lt;p&gt;Once I sorted thirty days of lines, the split was embarrassingly clean. The cost of a switch had almost nothing to do with the interruption and almost everything to do with the one thing I controlled: whether I had left myself a note.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;No next-action written&lt;/th&gt;
&lt;th&gt;Next-action written&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Shallow switch&lt;/strong&gt; (I knew where I was)&lt;/td&gt;
&lt;td&gt;~2 min&lt;/td&gt;
&lt;td&gt;~2 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Deep switch&lt;/strong&gt; (mid-problem, stack loaded)&lt;/td&gt;
&lt;td&gt;~34 min&lt;/td&gt;
&lt;td&gt;~6 min&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The top row barely reacts. When the work was shallow, re-entry was cheap no matter what, because there was no structure to lose. The bottom row is the whole story. A deep switch with nothing written down cost me a median of about &lt;strong&gt;34 minutes&lt;/strong&gt;. The same depth of switch, when I had spent one second writing down my next move, cost about &lt;strong&gt;6&lt;/strong&gt;. Same interruption, same me, a five-fold difference that came down entirely to whether I had left a breadcrumb.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that didn't work
&lt;/h2&gt;

&lt;p&gt;For a month before this I did what everyone recommends. Notification batching. Do Not Disturb blocks. "Focus hours" fenced off on the calendar. It moved the number so little I almost didn't bother logging it.&lt;/p&gt;

&lt;p&gt;The reason is that blocking assumes the interruption is optional, and mine mostly weren't. The build broke; I had to look. A customer couldn't log in; I had to answer. The pattern the advice is built for, a stream of pings you could ignore without consequence, is not my pattern. You cannot Do-Not-Disturb your way out of being the entire company. Blocking treats the switch itself as the enemy. For me the switch was usually the correct thing to do. The damage came afterward, in the re-entry, and blocking did nothing about that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that did: cheapen re-entry, don't prevent the switch
&lt;/h2&gt;

&lt;p&gt;So I gave up on preventing switches and put everything into making re-entry cheap. The whole method is one habit: before I leave a task, even for fourteen seconds, I write one line saying exactly what I was about to do next.&lt;/p&gt;

&lt;p&gt;Not "fix auth." That is a task label, and it is useless coming back — it names the mountain, not the step. The line has to be the specific next physical action: &lt;code&gt;refreshToken guard in AuthClient fires before the refresh resolves; change the guard near line 80, not the retry&lt;/code&gt;. That sentence reloads the structure. When I come back I don't rebuild from source; I read one line and step onto the exact edge I left.&lt;/p&gt;

&lt;p&gt;The catch is that this only works if writing the line is nearly free. If capture takes longer than the thought, I won't do it mid-switch, and I'll quietly promise myself I'll remember. So the breadcrumb rides the same one-second capture path as everything else I catch, because &lt;a href="https://dev.to/simple_memo/a-thought-you-cant-capture-in-a-second-is-already-gone-20n9"&gt;a thought I can't get out of my head in about a second is already gone&lt;/a&gt;. One line into my own email, there in about a second; on the evenings I sit down to curate, the same line is already under that day's heading in my vault. The point isn't where it lands. The point is that the exit costs a second and the re-entry it buys back costs half an hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I don't want to oversell
&lt;/h2&gt;

&lt;p&gt;This is n=1, self-reported, and the "back in" timestamp is a judgment I made about my own head. The breadcrumb has a real failure mode too: when the interruption &lt;em&gt;changes&lt;/em&gt; the task (a production incident that rewrites what the next action even is), there's no place to return to, and the note is worthless. And none of this is some new law of attention. It's one crude instrument telling one developer he had been counting the wrong number for years.&lt;/p&gt;

&lt;p&gt;Which is the actual takeaway, if there is one. Every tool I own counts interruptions. Not one of them counts re-entry, and re-entry is the number that was eating my days. Measure the thing that costs you, not the thing that happens to be easy to count.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Isn't a breadcrumb just a TODO?&lt;/strong&gt; No, and the difference is the whole point. A TODO records a task: "fix auth." A breadcrumb records a resume point: the specific race, the specific line, the thing I had already ruled out. A task label makes you rebuild the context; a resume point hands it back. You can feel which one you wrote by whether reading it reloads your head or just reminds you the mountain is still there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Doesn't stopping to write the line just add another interruption?&lt;/strong&gt; It lives inside the switch you were already taking, and it costs about a second against a re-entry that runs into the tens of minutes. I have never once regretted the second. I have lost the afternoon plenty of times by skipping it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if I can't tell a deep switch from a shallow one in the moment?&lt;/strong&gt; You don't have to. Write the line on every switch. On shallow ones it costs a second and you throw it away; on deep ones it saves you half an hour. It is cheap enough to buy as insurance on all of them, and you stop having to make the call.&lt;/p&gt;

&lt;p&gt;So here is what I actually want from you: what is your own re-entry time after a deep interruption, and have you ever measured it apart from just counting how often you got interrupted? The count is the number every app puts in front of you. I am increasingly sure it is the wrong one, and I would like to know whether your log says the same.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm a solo developer building &lt;a href="https://apps.apple.com/us/app/captio-style-simple-memo/id6758438948" rel="noopener noreferrer"&gt;Simple Memo&lt;/a&gt;, an iOS app that gets one line of text into my own email about a second after I think it — roughly the speed a breadcrumb has to be caught to survive a context switch. I write here every few days about the parts of building alone that caught me off guard.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>solodev</category>
      <category>deepwork</category>
      <category>discuss</category>
    </item>
    <item>
      <title>What I measured cutting my iOS cold start to 280 ms</title>
      <dc:creator>Simple Memo</dc:creator>
      <pubDate>Tue, 21 Jul 2026 13:35:19 +0000</pubDate>
      <link>https://dev.to/simple_memo/what-i-measured-cutting-my-ios-cold-start-to-280-ms-l20</link>
      <guid>https://dev.to/simple_memo/what-i-measured-cutting-my-ios-cold-start-to-280-ms-l20</guid>
      <description>&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage (my iPhone, tap → first usable frame)&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;How I measured it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pre-main (dyld)&lt;/td&gt;
&lt;td&gt;210 ms&lt;/td&gt;
&lt;td&gt;88 ms&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DYLD_PRINT_STATISTICS=1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;didFinishLaunching&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;120 ms&lt;/td&gt;
&lt;td&gt;21 ms&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;os_signpost&lt;/code&gt; intervals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Root view build + first CA commit&lt;/td&gt;
&lt;td&gt;100 ms&lt;/td&gt;
&lt;td&gt;71 ms&lt;/td&gt;
&lt;td&gt;Instruments, App Launch template&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;430 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;180 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That table is the better part of a year spent chipping at a single number: the time between my thumb landing on the icon and the first usable frame of an iOS app I build alone. On my own phone it fell from 430 ms to 180 ms. In the field, where it actually counts, the number is different and more stubborn, and getting honest about that gap is most of what this post is about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The short version:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The cost of a cold start on a small app is mostly dyld pre-main, and the biggest lever there is how many dynamic libraries you link — not how clever your own code is.&lt;/li&gt;
&lt;li&gt;The rest of the cuts came from refusing to do work on the launch path that most launches never use: hydrating the whole note store, checking iCloud, warming the audio stack.&lt;/li&gt;
&lt;li&gt;The 280 ms in the title is a field median, and it is the least honest number in the post, because the cold start users actually feel is the tail and the first launch after an update — and that one is still over a second.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why does dyld dominate a small app's cold start?
&lt;/h2&gt;

&lt;p&gt;Before your &lt;code&gt;main()&lt;/code&gt; runs, the dynamic linker has already done a day's work. It maps your dependent dynamic libraries, applies rebase and bind fixups so every pointer lands in the right place, registers your Objective-C classes and runs any &lt;code&gt;+load&lt;/code&gt; methods, then runs your C++ static initializers. All of that is "pre-main," and on a small app it is routinely the largest single slice of the cold start. Your own code has not executed yet.&lt;/p&gt;

&lt;p&gt;The number that surprised me when I first turned on &lt;code&gt;DYLD_PRINT_STATISTICS=1&lt;/code&gt; in the scheme's environment variables was how little of pre-main was &lt;em&gt;mine&lt;/em&gt;. The breakdown it prints (dylib loading, rebase/binding, ObjC setup, initializer time) was dominated by dylib loading. I was paying, on every cold start, for the privilege of having split my app into tidy dynamic frameworks.&lt;/p&gt;

&lt;p&gt;Here is the actual shape of it on a fresh install, the numbers &lt;code&gt;DYLD_PRINT_STATISTICS&lt;/code&gt; printed to the console:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Total pre-main time: 210.44 milliseconds (100.0%)
         dylib loading time: 128.19 ms (60.9%)
        rebase/binding time:  41.02 ms (19.5%)
            ObjC setup time:  22.31 ms (10.6%)
           initializer time:  18.90 ms  (9.0%)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sixty percent of my pre-main was the linker finding and mapping binaries. No amount of cleverness inside &lt;code&gt;didFinishLaunching&lt;/code&gt; can touch that slice; it is spent before my code draws breath. The only knob that moves dylib-loading time is how many separate dynamic binaries dyld has to open, and that is a decision you make in your target settings, not your source.&lt;/p&gt;

&lt;p&gt;Two honest caveats before the numbers, because pre-main is the easiest place to fool yourself. First, measure a &lt;strong&gt;release&lt;/strong&gt; build on a &lt;strong&gt;device&lt;/strong&gt;; a debug build on the simulator will lie to you in both directions. Second, modern dyld caches a launch closure after the first run, so a warm cold start is far cheaper than the one that matters most: the first launch after an install or an app update, when the closure is rebuilt. The scary pre-main number is the post-update one, and it is the one &lt;code&gt;DYLD_PRINT_STATISTICS&lt;/code&gt; on a fresh install shows you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three hypotheses I tested
&lt;/h2&gt;

&lt;p&gt;This kind of optimization (measure, guess, cut, measure again) only works if the guesses are written down before the cut. Here are the three I actually tested, in the order the profiler pointed me at them.&lt;/p&gt;

&lt;h3&gt;
  
  
  H1: pre-main is dominated by dynamic library count
&lt;/h3&gt;

&lt;p&gt;Tested by counting. I'd already pulled every third-party dependency out of this app, which &lt;a href="https://dev.to/simple_memo/i-shipped-an-ios-app-with-zero-third-party-dependencies-2jpd"&gt;I wrote about separately&lt;/a&gt;; that turned out to be the single biggest thing standing between me and a fast pre-main, and I hadn't even measured it at the time. But I still had &lt;em&gt;my own&lt;/em&gt; code carved into a couple of embedded dynamic frameworks for module hygiene. Each one is a separate binary dyld has to find, map, and fix up.&lt;/p&gt;

&lt;p&gt;The cut: I collapsed my own modules back into the main app target and let them link statically. Module boundaries are a compile-time nicety; the runtime does not care how clean your dependency graph looked in Xcode. Pre-main dropped from 210 ms to 88 ms on my device — most of it the disappearance of dylib-loading time. Apple has been saying "keep your dynamic framework count low" since the 2016–2017 startup-time talks, and for a small app the advice is close to a free 100 ms.&lt;/p&gt;

&lt;h3&gt;
  
  
  H2: I'm doing store work on the launch path that the first frame doesn't need
&lt;/h3&gt;

&lt;p&gt;Tested with &lt;code&gt;os_signpost&lt;/code&gt;. I wrapped each phase of &lt;code&gt;didFinishLaunching&lt;/code&gt; in a signed interval and read it back in Instruments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;OSLog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;subsystem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"app.launch"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pointsOfInterest&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="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;OSSignpostID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;log&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;os_signpost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;log&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"hydrate-store"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;signpostID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;id&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;loadAll&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;// &amp;lt;- the expensive line&lt;/span&gt;
&lt;span class="nf"&gt;os_signpost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;log&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"hydrate-store"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;signpostID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;store.loadAll()&lt;/code&gt; was a &lt;code&gt;JSONDecoder&lt;/code&gt; reading the entire note history to build the first screen. On my store it cost ~60 ms, and the latent bug was worse than the number: it was O(n) in note count, paid &lt;em&gt;on every launch&lt;/em&gt;. A user with a few thousand notes was subsidising my laziness on the launch path every single time they opened the app.&lt;/p&gt;

&lt;p&gt;The cut: render the first frame from nothing (an empty capture field, the one thing a capture app can always draw instantly) and hydrate the store on a background queue afterward, updating the view when it arrives. The persistence layer already drained &lt;a href="https://dev.to/simple_memo/an-offline-first-outbox-in-swift-7-steps-no-third-party-libs-4b4d"&gt;its outbox asynchronously&lt;/a&gt;, so I was borrowing a pattern I'd already built. In the same pass I moved the iCloud availability check, &lt;code&gt;url(forUbiquityContainerIdentifier:)&lt;/code&gt;, off the launch path and onto first actual use; that call can block for a surprising while the first time you make it. &lt;code&gt;didFinishLaunching&lt;/code&gt; fell from 120 ms to 21 ms.&lt;/p&gt;

&lt;h3&gt;
  
  
  H3: I'm warming a subsystem for a feature most launches never touch
&lt;/h3&gt;

&lt;p&gt;This is the one I'm least proud of. I had been eagerly building the audio and on-device dictation stack during launch so the mic would feel instant the moment someone tapped it. Signposts put it at ~35 ms on the launch path, and it also poked &lt;code&gt;AVAudioSession&lt;/code&gt; awake for no reason on launches where nobody dictated anything.&lt;/p&gt;

&lt;p&gt;The trade I'd made without noticing: I was taxing every cold start to make one occasional tap feel faster. I moved the whole stack to lazy initialization on first mic tap. Cold start got cheaper for everyone; the mic's first-tap latency rose by an amount I could hide behind the waveform animation that plays while it spins up. Warm the thing the user is about to use, not the thing they might use — and never on the path to first frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  The field is not your dev phone
&lt;/h2&gt;

&lt;p&gt;Here is where the tidy lab numbers meet reality. &lt;code&gt;MXAppLaunchMetric&lt;/code&gt; from MetricKit reports the real distribution from real devices, delivered once a day to an &lt;code&gt;MXMetricManagerSubscriber&lt;/code&gt;, aggregated and privacy-preserving. This is field data, not a hero run on the newest phone in the room. It looked nothing like my 180 ms:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;MetricKit &lt;code&gt;timeToFirstDraw&lt;/code&gt; (all users)&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;p50 (median)&lt;/td&gt;
&lt;td&gt;~610 ms&lt;/td&gt;
&lt;td&gt;~280 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p90&lt;/td&gt;
&lt;td&gt;~1.2 s&lt;/td&gt;
&lt;td&gt;~520 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worst ~1% (post-update, cold, old devices)&lt;/td&gt;
&lt;td&gt;rarely under 2 s&lt;/td&gt;
&lt;td&gt;still ~1.3 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My lab number was the ceiling, nothing like the typical case. The field median is roughly 1.5x my device; the p90 is closer to 3x. The gap is older CPUs and slower flash, thermal throttling, low-storage devices where the filesystem is slow, launches while the system is busy doing something else, and above all the first launch after an update, when dyld has no cached closure to lean on. The 280 ms I put in the title is honest as a median. It is also, on its own, a little misleading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which cold start actually matters to a user?
&lt;/h2&gt;

&lt;p&gt;Not the one I spent the most time on. That is the uncomfortable finding.&lt;/p&gt;

&lt;p&gt;The p50 sits below the threshold where a person consciously registers a delay; shaving my own phone from 430 ms to 180 ms was, for the median user, mostly vanity I happened to enjoy. What a user actually &lt;em&gt;feels&lt;/em&gt; is two things I under-invested in. The first is the tail: the p95 and p99, the bad-device, bad-moment launches that still run over a second, because one slow launch is remembered longer than fifty fast ones. The second is that most launches are not cold at all. A capture tool gets opened twenty times a day, and nineteen of those are warm resumes governed by &lt;code&gt;applicationResumeTime&lt;/code&gt;, not the cold path I obsessed over. I optimised the least frequent launch because it was the most satisfying to measure. The honest allocation would have put more of that year into the tail and into resume time.&lt;/p&gt;

&lt;p&gt;I'm keeping the cold-start work (100 ms of free pre-main is still 100 ms, and the store bug was a real scaling landmine), but I'd be lying if I sold you the median as the win. The median was the easy, feel-good part.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Does this even matter on modern dyld with launch closures?&lt;/strong&gt; For warm cold starts, less than it used to; the closure does most of the caching for you. It matters exactly on the launch you can't cache: the first one after an app update. If you only ever test warm launches, you are measuring the case your users feel least.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SwiftUI or UIKit — does the choice change the cold start?&lt;/strong&gt; At this scale, less than the dynamic-framework count does. The first-frame cost differed by a handful of milliseconds between a minimal UIKit root and a minimal SwiftUI one in my testing; the 100 ms swing came from dyld, not the UI framework. Optimise the linker before you re-litigate your UI stack.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Isn't 100 ms of cold start below the human perception threshold anyway?&lt;/strong&gt; For the median, roughly yes, and that is exactly my counter-take above. The reason to still care is the tail: the same cut that moves a 180 ms launch you can't feel also moves a 1.3 s launch you very much can.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number I still can't see
&lt;/h2&gt;

&lt;p&gt;The measurement I want and don't have is the post-update first-launch time-to-first-draw, in the field, isolated from ordinary cold starts. MetricKit buckets them together, so the one launch I believe my users actually judge me on is hidden inside an average with all the cheap warm-cache launches. Next, I'm going to tag it myself: stash the last-launched bundle version in &lt;code&gt;UserDefaults&lt;/code&gt;, compare it on launch, and when it changed, emit a signpost around first draw so I can pull that specific launch out of the noise. My bet, and it is only a bet until the data lands, is that it is the only cold start that has ever cost me a user.&lt;/p&gt;

&lt;p&gt;If you've shipped an iOS app: what is your first-launch-after-update time-to-first-draw, and did you ever manage to measure it apart from ordinary cold starts? That's the number I can't cleanly see, and I suspect it is the only cold start anyone actually feels.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm a solo developer. I build Simple Memo, an iOS app that turns one typed line into an email about a second later, and I keep it small enough that the cold-start ledger above is a normal Tuesday for me. It's &lt;a href="https://apps.apple.com/us/app/captio-style-simple-memo/id6758438948" rel="noopener noreferrer"&gt;on the App Store&lt;/a&gt; if you're curious what that constraint produces.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>swift</category>
      <category>ios</category>
      <category>performance</category>
    </item>
    <item>
      <title>I deleted every Zettelkasten ID and my notes got better</title>
      <dc:creator>Simple Memo</dc:creator>
      <pubDate>Fri, 17 Jul 2026 13:33:32 +0000</pubDate>
      <link>https://dev.to/simple_memo/i-deleted-every-zettelkasten-id-and-my-notes-got-better-fkd</link>
      <guid>https://dev.to/simple_memo/i-deleted-every-zettelkasten-id-and-my-notes-got-better-fkd</guid>
      <description>&lt;p&gt;For three years I hand-maintained a Luhmann-style ID on top of every note I kept: roughly 1,400 of them, little addresses like &lt;code&gt;3a2&lt;/code&gt; and &lt;code&gt;3a2b&lt;/code&gt; clipped to the front of each line. Last spring I deleted all of them in one afternoon. The disaster every Zettelkasten guide promises, notes collapsing into an unnavigable pile the moment you remove the structure, never arrived. If anything, the notes got better at the one job I actually need them for now: being read by a machine.&lt;/p&gt;

&lt;p&gt;I want to argue that the famous ID scheme at the heart of the Zettelkasten method solves two problems I no longer have, and quietly charges me for a third I never noticed. And I want to make that argument in the era where a large share of my note retrieval is not done by me at all, but by a language model reading my notes as context.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;The folgezettel ID was an answer to a physical constraint (paper cannot be searched) and to a human navigator walking a branch by hand. In a plain-text note folder I have neither problem.&lt;/li&gt;
&lt;li&gt;Full-text search and backlinks already reproduce most of what the ID graph did. Even inside the Zettelkasten community this is largely conceded for digital setups.&lt;/li&gt;
&lt;li&gt;The newer reason to drop the IDs: a model reads dated plain prose far better than a lattice of cryptic addresses, and I feed it my notes constantly now.&lt;/li&gt;
&lt;li&gt;What I lost is real. The "at a glance" shape of a thought's branch does not survive the change, so this is a trade, not a free lunch.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The orthodoxy, stated fairly
&lt;/h2&gt;

&lt;p&gt;Niklas Luhmann, the German sociologist, built a paper Zettelkasten of around 90,000 index cards starting in the early 1950s, and credited it as the engine behind a working life of dozens of books and hundreds of papers. The cards were digitized and put online in 2019, so this is not folklore; you can go and look at the actual slips.&lt;/p&gt;

&lt;p&gt;The part everyone borrows is the numbering. Luhmann used what German speakers call &lt;em&gt;folgezettel&lt;/em&gt;, "follow-up notes." The first card is &lt;code&gt;1&lt;/code&gt;. An unrelated new card is &lt;code&gt;2&lt;/code&gt;. But a card that continues, comments on, or branches from card &lt;code&gt;1&lt;/code&gt; becomes &lt;code&gt;1a&lt;/code&gt;; the next in that strand is &lt;code&gt;1b&lt;/code&gt;; a comment on &lt;code&gt;1a&lt;/code&gt; becomes &lt;code&gt;1a1&lt;/code&gt;. The address is not a filing location, it is a position in a tree of thought. A quietly brilliant second-order effect falls out of this: a topic worked for years grows long addresses, so the length of an ID tells you at a glance how deep that vein has been mined.&lt;/p&gt;

&lt;p&gt;Sönke Ahrens' 2017 book &lt;em&gt;How to Take Smart Notes&lt;/em&gt; carried this method out of German academia and handed it to a generation of knowledge workers, and the modern digital Zettelkasten scene (zettelkasten.de, the Obsidian and Logseq crowds) grew from there. The orthodoxy that travelled with it is stated often and stated confidently: the ID is what makes a Zettelkasten a system instead of a junk drawer. Drop the ID and you have a pile.&lt;/p&gt;

&lt;p&gt;I believed that the whole time. I was wrong about it, and the reason I was wrong is the useful part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did the IDs feel productive when they weren't?
&lt;/h2&gt;

&lt;p&gt;Because assigning an address felt like thinking, and it wasn't. It was filing.&lt;/p&gt;

&lt;p&gt;Every time I made a note I had to decide where in the tree it hung, which meant re-reading neighbors, comparing, placing. That work produced a warm sense of having done something rigorous. But almost none of it changed what I later understood; it changed only where a card sat. I had confused the ceremony of organizing with the act of thinking, and the ceremony carried a real cost I was paying at every single capture.&lt;/p&gt;

&lt;p&gt;Here is the tell. In all those years I can count on one hand the times I retrieved a note by walking its folgezettel branch. Every other retrieval, thousands of them, was a full-text search. I had built and maintained an elaborate coordinate system and then navigated by coordinate almost never.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the ID was actually for
&lt;/h2&gt;

&lt;p&gt;Two jobs, and both have quietly expired.&lt;/p&gt;

&lt;p&gt;The first job was giving paper a coordinate system. A physical card has no &lt;code&gt;Ctrl-F&lt;/code&gt;. If you cannot search the contents you must know where a thing lives, so you impose an address and a tree on top of it. Luhmann's move was partly a workaround for the fact that a wooden box of cards is opaque to search. My vault is a folder of Markdown files. It is nothing but searchable. The original problem is simply gone.&lt;/p&gt;

&lt;p&gt;The second job was letting a human walk a line of thought by hand, card to card. That one is subtler and more defensible, and I will come back to it, because it is the part I actually gave something up to lose.&lt;/p&gt;

&lt;p&gt;Then there is the job nobody designed the ID for, the one that decided it for me. I now spend a large part of my week handing chunks of my own notes to a language model as context: debugging with it, drafting with it, asking it to reconcile something I wrote in March against something I concluded in June. A model does not traverse my link graph. It reads text, straight through, in order. To that reader a line like &lt;code&gt;2026-03-14 Outbox retry needs jitter or the whole queue wakes on the same tick&lt;/code&gt; is entirely legible. A line that opens with &lt;code&gt;3a2b -&amp;gt;&lt;/code&gt; is noise it has to spend attention decoding. The ID I was maintaining for a human navigator is, to my most frequent reader, litter.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Folgezettel ID&lt;/th&gt;
&lt;th&gt;Dated plain line&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Optimized for&lt;/td&gt;
&lt;td&gt;A human walking a branch&lt;/td&gt;
&lt;td&gt;Search, and a model reading straight through&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per capture&lt;/td&gt;
&lt;td&gt;Compare neighbors, place in the tree&lt;/td&gt;
&lt;td&gt;Stamp the date, type the thought&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How I retrieve&lt;/td&gt;
&lt;td&gt;Navigate the graph by address&lt;/td&gt;
&lt;td&gt;Full-text search or a backlink&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Machine-readable&lt;/td&gt;
&lt;td&gt;Poorly; the address is opaque&lt;/td&gt;
&lt;td&gt;Natively; it is just dated prose&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintenance on insert&lt;/td&gt;
&lt;td&gt;Re-thread, sometimes re-number&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The evidence, including the part that hurts
&lt;/h2&gt;

&lt;p&gt;The community already half-admits the first half of this. Spend an hour on the Zettelkasten forums and you find the &lt;em&gt;folgezettel debate&lt;/em&gt; running for years: whether the branching ID means anything once you have gone digital. The recurring finding is that in a tool like Obsidian you do not need unique IDs to capture the folgezettel structure at all, because backlinks reproduce it. Open a note and its continuations are simply listed as the things that link back to it. The timestamp method, where you date-stamp the note and let search and links carry the rest, is a completely mainstream alternative and not a heresy I invented on my own.&lt;/p&gt;

&lt;p&gt;So the deletion was low-drama. I stripped the &lt;code&gt;3a2&lt;/code&gt; prefixes with a script, kept the dates, and my retrieval, being search all along, never noticed the IDs were gone.&lt;/p&gt;

&lt;p&gt;Now the part that hurts, because I do not trust a case that only flatters the person making it.&lt;/p&gt;

&lt;p&gt;Folgezettel's second job, the at-a-glance topography, does not come back. When the shape of the branch &lt;em&gt;is&lt;/em&gt; the information, when seeing that one vein has grown a forty-character address tells you instantly that this is where your real work has accumulated, backlinks genuinely do not give you that. A list of backlinks is a set, not a shape. For the two years I was building toward one long argument, that topography might have been worth its tax. I killed it because I am not building one long argument. I am running a software product and catching fast-moving thoughts before &lt;a href="https://dev.to/simple_memo/a-thought-you-cant-capture-in-a-second-is-already-gone-20n9"&gt;they evaporate&lt;/a&gt;. Different job, different tool. But I would rather say plainly that I traded something away than pretend I found a strictly dominant move.&lt;/p&gt;

&lt;h2&gt;
  
  
  So when does a Zettel ID still earn its keep?
&lt;/h2&gt;

&lt;p&gt;Three honest cases. When your corpus has to outlive your tools, whether that means paper or a format you will migrate across decades, an ID that is independent of any one app is a real asset. When the branch shape is the product, as in genuine long-form theory-building, the topography is worth maintaining by hand. And when you have no trustworthy full-text search, the coordinate system stops being ceremony and goes back to being load-bearing.&lt;/p&gt;

&lt;p&gt;None of those is true for me. All of them might be true for you, and if so, keep your IDs; the method earned its fame honestly.&lt;/p&gt;

&lt;p&gt;What I run now is almost the opposite of a system. One folder, one dated line per capture, written in nouns and verbs a stranger could parse, no addresses. Search is the index. Backlinks are the graph. And the second reader I actually optimize for is not future-me squinting at &lt;code&gt;1a1&lt;/code&gt;; it is the model I will paste these lines into next week. In the LLM era, the second brain worth keeping is the one the machine can also read.&lt;/p&gt;

&lt;p&gt;I deleted 1,400 IDs and replaced them with the date.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Isn't this just a worse Zettelkasten?&lt;/strong&gt; It is a different bet. Classic Zettelkasten optimizes for a human building a connected argument over years. I optimize for cheap capture and for two readers, search and a model. If your work looks like Luhmann's, mine is the worse choice. If it looks like shipping and debugging, I think the reverse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you link notes with no IDs?&lt;/strong&gt; Plain backlinks and the date. If two notes belong together I link their titles directly; the date gives me a stable ordering and a rough "what was I chewing on that week" axis. I also split capture and curation into &lt;a href="https://dev.to/simple_memo/i-gave-my-notes-a-cache-hierarchy-phone-l1-vault-l2-73m"&gt;a fast layer and a slow one&lt;/a&gt;, so the linking happens later, while I am reading, not while I am trying to catch the thought.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does full-text search really hold up over years?&lt;/strong&gt; So far, yes. My notes are a couple thousand plain Markdown files and search is instant. The one place it strains is genuinely ambiguous terms, which a date range or a single backlink resolves. Plain text is also the one format I am confident will still be searchable in twenty years, which is more than I can say for any app's database.&lt;/p&gt;

&lt;p&gt;If you run a real folgezettel Zettelkasten, here is what I want from you: the single retrieval you can do that I no longer can. The specific lookup where walking the ID graph beats my search box. Not the philosophy, the concrete case. That is the thing I might have thrown away without noticing.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build Simple Memo by myself: an iOS app that puts one line of text into my email about a second after I type it, and appends the same line to my Markdown vault on the evenings I sit down to curate. I wrote up &lt;a href="https://simplememofast.com/obsidian/" rel="noopener noreferrer"&gt;how the Markdown side works&lt;/a&gt; if that is the part you came for.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>pkm</category>
      <category>obsidian</category>
      <category>watercooler</category>
    </item>
    <item>
      <title>I stopped counting hours and started counting energy</title>
      <dc:creator>Simple Memo</dc:creator>
      <pubDate>Tue, 14 Jul 2026 13:10:41 +0000</pubDate>
      <link>https://dev.to/simple_memo/i-stopped-counting-hours-and-started-counting-energy-4kam</link>
      <guid>https://dev.to/simple_memo/i-stopped-counting-hours-and-started-counting-energy-4kam</guid>
      <description>&lt;p&gt;Day 1. I logged 4 hours and 40 minutes of work and shipped nothing I kept. The time tracker said it was a decent day. I had spent most of those 240 minutes re-reading a crash report I did not have the head to fix, and the twenty good minutes went into a settings toggle nobody asked for. That night I closed the timer app for the last time and wrote a different kind of line in my text file: &lt;code&gt;wake 4 / mid 2 / eve 2 - burned on a crash report I couldn't hold in my head.&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Day 2. The schema, because a ledger with no schema becomes a diary and a diary becomes nothing. One appended line per day, four fields: three energy readings on a 1-5 scale at roughly 08:00, 14:00 and 21:00, then one clause naming where the energy actually went. No categories. No tags. No app. It takes about fifteen seconds a day, which matters more than any of the rest of it, because a ledger that costs two minutes is a ledger I will abandon by Thursday.&lt;/p&gt;

&lt;p&gt;Day 3. First honest reading: &lt;code&gt;wake 5 / mid 5 / eve 4&lt;/code&gt;. Five hours of real work on the Outbox retry path. The hours tracker would have recorded this as roughly the same day as Day 1. That is the whole complaint in one comparison, and it is the reason I bothered.&lt;/p&gt;

&lt;p&gt;Day 6. I notice I am gaming the scale. Rating my own energy at 21:00 after a bad day feels like grading my own homework, and I catch myself writing &lt;code&gt;3&lt;/code&gt; when the truthful answer is &lt;code&gt;2&lt;/code&gt;, because a &lt;code&gt;2&lt;/code&gt; looks like an accusation. The fix is dumb and it works: I stopped calling it energy in my own head and started calling it &lt;em&gt;available depth&lt;/em&gt; - how many layers of a problem I can hold at once before one slips. Depth is a fact about my brain at that moment. It does not sound like a verdict on my character, so I stopped lying about it.&lt;/p&gt;

&lt;p&gt;Day 9. First real finding, and it annoyed me. My 5s do not arrive at the same time every day, but they cluster hard in the first ninety minutes after I wake up, and I had been spending that window on email and App Store Connect. I had been feeding my best cognition to a task any tired person could do. Swapping those two blocks was the highest-return change of the month and it cost nothing but the discomfort of leaving the inbox unread until 10:00.&lt;/p&gt;

&lt;p&gt;Day 12. A &lt;code&gt;1&lt;/code&gt; day. Total. Slept badly, a support email got under my skin, and I sat in front of Xcode for six hours producing a commit I reverted the next morning. Under the hours regime this would have registered as a full working day and I would have felt vaguely virtuous about it. The ledger just says: &lt;code&gt;wake 2 / mid 1 / eve 1 - support thread; reverted everything.&lt;/code&gt; No credit for the six hours. That is the point of the instrument. It refuses to pay me for attendance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Day 14. What is this ledger actually measuring?
&lt;/h2&gt;

&lt;p&gt;Not mood. Not productivity. It measures how much of a problem I can hold in working memory at once, which for a solo developer is the only genuinely scarce input. I have unlimited hours in the sense that nobody is stopping me from sitting at the desk. What I do not have is a second person to hand the hard thing to when my head is thin, and that is exactly the assumption every hours-based system is built on. Time tracking was invented to bill clients and to coordinate teams. Neither of those things is happening here. I am the only worker, the only manager, and the only budget.&lt;/p&gt;

&lt;p&gt;Here is the honest comparison after two weeks of running both:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Hours ledger&lt;/th&gt;
&lt;th&gt;Energy ledger&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What it counts&lt;/td&gt;
&lt;td&gt;Minutes at the desk&lt;/td&gt;
&lt;td&gt;Depth available, 3x a day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How I cheat it&lt;/td&gt;
&lt;td&gt;Sit still, feel virtuous&lt;/td&gt;
&lt;td&gt;Round a 2 up to a 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What it predicts&lt;/td&gt;
&lt;td&gt;Almost nothing&lt;/td&gt;
&lt;td&gt;Whether tomorrow's hard task lands&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost to keep&lt;/td&gt;
&lt;td&gt;An app and a habit&lt;/td&gt;
&lt;td&gt;One appended line, ~15 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worst failure&lt;/td&gt;
&lt;td&gt;Rewards a bad six hours&lt;/td&gt;
&lt;td&gt;Says nothing about &lt;em&gt;why&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is not a rhetorical concession. It is where the method broke.&lt;/p&gt;

&lt;p&gt;Day 18. The ledger told me I had four low days in a row and offered zero explanation, because I had written the &lt;em&gt;where it went&lt;/em&gt; clause lazily: "app stuff," "app stuff," "admin," "app stuff." Four days of data, no signal. A drain field only works if it names something specific enough to be avoided. So I rewrote the rule: the clause must contain a noun another person could recognise. &lt;code&gt;Xcode archive failing on a provisioning profile I did not change&lt;/code&gt; is a drain. &lt;code&gt;App stuff&lt;/code&gt; is a feeling.&lt;/p&gt;

&lt;p&gt;Day 20. Re-reading the fixed lines, the pattern is embarrassing and clear. Interruption is not what costs me. &lt;em&gt;Unresolved ambiguity&lt;/em&gt; is. A support email I know how to answer takes eight minutes and costs nothing. A support email I do &lt;em&gt;not&lt;/em&gt; know how to answer costs the rest of the morning even if I never open it again, because some part of my head keeps a thread running on it. Ambiguity does not wait its turn. It runs in the background and eats the depth I was saving for the hard task.&lt;/p&gt;

&lt;p&gt;Day 22. So I started closing loops in the cheapest way available, which usually means writing one line and getting it out of my head immediately rather than "handling it properly later." The line goes to my own email in about a second, and at the end of the day it also lands under today's heading in my daily note, so I have stopped carrying it around. None of this is a new insight; it is the reason I built my capture app the way I did, and I have argued before that &lt;a href="https://dev.to/simple_memo/a-thought-you-cant-capture-in-a-second-is-already-gone-20n9"&gt;a thought you cannot capture in about a second is already gone&lt;/a&gt;. What surprised me is that the ledger &lt;em&gt;measured&lt;/em&gt; it. The days after I closed the loops read &lt;code&gt;mid 4&lt;/code&gt;, &lt;code&gt;mid 4&lt;/code&gt;, &lt;code&gt;mid 5&lt;/code&gt;. The days I told myself I would deal with it later read &lt;code&gt;mid 2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Day 25. The counter-take, because I do not trust a system that only ever confirms me. Energy is a lagging, self-reported, one-observer metric. It has none of the properties you would want in an instrument. I cannot calibrate it against anything, I cannot compare it to yours, and on a bad enough day my ability to rate my own state is exactly the faculty that is impaired. Hours, for all their stupidity, are at least objective. If I had a client to bill or a co-founder to coordinate with, I would keep the timer and I would not apologise for it. The energy ledger works for me because my failure mode is not laziness. It is grinding: sitting at the desk at a 1 and calling it work. If your failure mode runs the other way, this instrument will lie to you in the direction you want.&lt;/p&gt;

&lt;p&gt;Day 28. The tally, run with &lt;code&gt;grep&lt;/code&gt; and my own eyes, not a dashboard:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Days rated 4-5 at wake: 19 of 30. Days I actually &lt;em&gt;used&lt;/em&gt; that window on the hardest available task: 11.&lt;/li&gt;
&lt;li&gt;The gap between those two numbers is the whole remaining opportunity, and it is larger than anything a productivity app was ever going to hand me.&lt;/li&gt;
&lt;li&gt;Named drains appearing three or more times: unresolved support threads, provisioning and signing errors, and one long-running design decision I kept re-opening instead of deciding.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Day 30. What I keep: the three readings, the drain clause, the fifteen seconds. What I throw away: any attempt to average it, chart it, or turn it into a score. The moment this becomes a number I optimise, I will start optimising the number instead of the work, and I will get very good at scoring 5s on days that produce nothing. The ledger is a mirror, not a KPI. It works precisely because nobody, including me, is grading it.&lt;/p&gt;

&lt;p&gt;Day 31 starts tomorrow, and the only change is a fourth field, on trial: what I &lt;em&gt;deferred&lt;/em&gt; that day. My suspicion, based on Day 20, is that the deferral list predicts the next day's low reading better than anything I currently write down. I will know in a month.&lt;/p&gt;

&lt;h2&gt;
  
  
  What if I already know I'm a morning person?
&lt;/h2&gt;

&lt;p&gt;Then you know one of the four things this told me, and it was the least surprising one. The useful findings were behavioural: which specific class of task was eating the good window, and which specific class of open loop was draining the afternoon. Those are not personality facts. They shift month to month, and I could not have guessed either of them from a chronotype.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is fifteen seconds a day really enough data?
&lt;/h2&gt;

&lt;p&gt;It was enough to change two habits, which is more than eleven months of automatic time tracking ever did for me. Precision is not the constraint here. Survival is. A rich schema I abandon in nine days beats nothing, but it loses badly to a crude schema I still keep on day thirty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I want to be told I'm wrong
&lt;/h2&gt;

&lt;p&gt;I want to hear from anyone running both instruments at once who has a real opinion about the overlap: not a philosophy, a specific case where the hours told you something the energy reading missed. And if you keep any daily self-log that survived past a month, name the one field in it you would fight to keep. Mine is the drain clause, and I am fairly sure it is the only field doing real work.&lt;/p&gt;




&lt;p&gt;I develop &lt;a href="https://apps.apple.com/us/app/captio-style-simple-memo/id6758438948" rel="noopener noreferrer"&gt;Simple Memo&lt;/a&gt; by myself: an iOS app that takes one line of text and has it in your email roughly a second later. It is also the thing I use to close the open loops this ledger kept catching me carrying around.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;So here is the question I actually want answered, because I think it exposes the whole thing. What is the one field in your tracker that you fill in on every single ticket and have never once read back? Story points is my bet. Tell me I'm wrong, and tell me which field genuinely earns its keep when nobody but you will ever look at it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://apps.apple.com/us/app/captio-style-simple-memo/id6758438948" rel="noopener noreferrer"&gt;Simple Memo&lt;/a&gt; alone — an iOS app that turns a line of text into a sent email in about a second, then gets out of the way. I post here a couple of times a week, usually when one of my strong opinions survives a fight with my own logs.&lt;/em&gt;&lt;/p&gt;

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

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

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

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

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

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

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

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

&lt;p&gt;The only sanctioned door in is &lt;code&gt;UIDocumentPickerViewController&lt;/code&gt;. The user picks a folder (their vault folder, in my case) through the system file picker, and iOS hands your process a security-scoped &lt;code&gt;URL&lt;/code&gt; — a token, effectively, not just a path string. That URL is only valid while you call &lt;code&gt;startAccessingSecurityScopedResource()&lt;/code&gt; on it, and only for as long as the system feels like honoring it in that session. This is the first real design constraint: the user has to grant access once, explicitly, through UI you do not control. There is no way to skip that dialog and no way to pre-select the folder for them. I tried, because a one-tap flow was the whole point of the app; Apple's sandbox model says no, and the "why" is the same reason your banking app can't read your email app's cache: cross-app storage access is opt-in per user gesture, not per developer intent.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;requestVaultAccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nv"&gt;viewController&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UIViewController&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;picker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UIDocumentPickerViewController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forOpeningContentTypes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;folder&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;picker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delegate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;
    &lt;span class="n"&gt;viewController&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;present&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;picker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;animated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;documentPicker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UIDocumentPickerViewController&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                     &lt;span class="n"&gt;didPickDocumentsAt&lt;/span&gt; &lt;span class="nv"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;vaultURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;vaultURL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startAccessingSecurityScopedResource&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// grant refused or already revoked — surface this, don't silently no-op&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;vaultURL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stopAccessingSecurityScopedResource&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;persistBookmark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;for&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;vaultURL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line, &lt;code&gt;persistBookmark&lt;/code&gt;, is where layer two starts, and it is where I made my first wrong assumption.&lt;/p&gt;

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

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

&lt;p&gt;The fix is a security-scoped bookmark, which is a small opaque &lt;code&gt;Data&lt;/code&gt; blob you create once and can resolve back into a working &lt;code&gt;URL&lt;/code&gt; on every future launch, without asking the user again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;persistBookmark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="nv"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;bookmark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bookmarkData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
        &lt;span class="nv"&gt;includingResourceValuesForKeys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;relativeTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kt"&gt;UserDefaults&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;standard&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bookmark&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;forKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"vaultBookmark"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;resolveVaultURL&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;UserDefaults&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;standard&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"vaultBookmark"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;isStale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;resolvingBookmarkData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
        &lt;span class="nv"&gt;relativeTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;bookmarkDataIsStale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;isStale&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;isStale&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// the folder moved, got renamed, or iCloud re-issued its identity — bookmark is dead, re-prompt&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;isStale&lt;/code&gt; is not a hint you can ignore. I ignored it for about two weeks of internal testing, on the theory that a stale bookmark would still resolve to something close enough. It does not. A stale bookmark that you use anyway will sometimes resolve to a URL that looks correct and fails on the write, and sometimes resolve to nothing your app can read at all, and the difference depends on exactly how the underlying folder moved — which is not something I control, because the user can rename their vault, or iCloud can re-shuffle the container during a sync conflict, entirely outside my app's runtime. Once &lt;code&gt;isStale&lt;/code&gt; comes back &lt;code&gt;true&lt;/code&gt;, the only honest move is to drop the bookmark and ask the user to re-grant access through the picker. I now show one line of UI for that ("Reconnect your vault folder") instead of failing the write silently, which is what the app did for those first two weeks.&lt;/p&gt;

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

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

&lt;p&gt;A naive append looks reasonable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="kt"&gt;FileHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forWritingTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;fileURL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seekToEndOfFile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lineData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;&lt;code&gt;FileHandle&lt;/code&gt; has no concept of "someone else might be touching this file right now." &lt;code&gt;NSFileCoordinator&lt;/code&gt; does — it is the actual mechanism iCloud Drive apps are supposed to use for exactly this, and it existed the entire time; I just did not reach for it on the first pass because the "write a string to a file" tutorials online almost never mention that a coordinator exists.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;appendLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="nv"&gt;fileURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;coordinator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;NSFileCoordinator&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;coordinationError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;NSError&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;writeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;

    &lt;span class="n"&gt;coordinator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;coordinate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;writingItemAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;fileURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;forMerging&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;coordinationError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;safeURL&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="kt"&gt;FileManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fileExists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;atPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;safeURL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="kt"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;safeURL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="kt"&gt;FileHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forWritingTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;safeURL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seekToEndOfFile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utf8&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;writeError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;coordinationError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;coordinationError&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;writeError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;writeError&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in that block are the ones I would flag if I were reviewing someone else's PR. First, &lt;code&gt;.forMerging&lt;/code&gt;, not the default writing intent, which assumes you are replacing the whole file. &lt;code&gt;.forMerging&lt;/code&gt; tells the coordinator (and, by extension, any &lt;code&gt;NSFilePresenter&lt;/code&gt; the other app registered, including that app's own sync layer if it's watching) that this is an additive change, which changes how the coordinator schedules your access relative to a concurrent iCloud upload instead of just serializing "biggest write wins." Second, the coordinator hands you a &lt;code&gt;safeURL&lt;/code&gt; inside the closure — not the URL you passed in. Writing to the original &lt;code&gt;fileURL&lt;/code&gt; instead of the coordinator-provided one defeats the entire point; I found that mistake in my own first draft by re-reading the closure a third time, not by a test failing.&lt;/p&gt;

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

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

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

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

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

&lt;p&gt;You have to check for this explicitly, before you coordinate the write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;fileURL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resourceValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forKeys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ubiquitousItemDownloadingStatusKey&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ubiquitousItemDownloadingStatus&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="kt"&gt;FileManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startDownloadingUbiquitousItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;fileURL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// and then either poll resourceValues again, or accept the write will&lt;/span&gt;
    &lt;span class="c1"&gt;// land on next app launch — I chose the second, on purpose&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;p&gt;If you've built a write path into a file iCloud is actively syncing, I'd like to know whether &lt;code&gt;NSFileCoordinator&lt;/code&gt; alone solved it for you, or whether you also had to add your own conflict resolution on top of it — that's the part I'm least confident I've fully covered.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write at &lt;a class="mentioned-user" href="https://dev.to/simple_memo"&gt;@simple_memo&lt;/a&gt;. Simple Memo is the iOS app behind the pipe this post takes apart. On the days I have background note-forwarding turned on, &lt;a href="https://simplememofast.com/en/obsidian/" rel="noopener noreferrer"&gt;the same write path lands a line in my vault&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

</description>
      <category>indiehackers</category>
      <category>solodev</category>
      <category>sideprojects</category>
    </item>
    <item>
      <title>A thought you can't capture in a second is already gone</title>
      <dc:creator>Simple Memo</dc:creator>
      <pubDate>Fri, 19 Jun 2026 13:15:10 +0000</pubDate>
      <link>https://dev.to/simple_memo/a-thought-you-cant-capture-in-a-second-is-already-gone-20n9</link>
      <guid>https://dev.to/simple_memo/a-thought-you-cant-capture-in-a-second-is-already-gone-20n9</guid>
      <description>&lt;p&gt;A thought you cannot capture in about a second is, for practical purposes, already gone. Not slower to retrieve. Not filed somewhere inconvenient. Gone, with no copy anywhere, and usually you do not even notice the loss, which is the part that should bother you.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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



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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

&lt;p&gt;&lt;code&gt;AVAudioEngine&lt;/code&gt;'s input node format (often 48 kHz, hardware-dependent) usually does &lt;strong&gt;not&lt;/strong&gt; match &lt;code&gt;SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith:)&lt;/code&gt;. Feed a mismatched buffer and you get a clean compile and &lt;strong&gt;zero transcription&lt;/strong&gt; — no error. Run every buffer through &lt;code&gt;AVAudioConverter&lt;/code&gt; first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;converter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;AudioBufferConverter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// capture locals; never touch self in the tap&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;audioEngine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inputNode&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;micFormat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;outputFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forBus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;installTap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;onBus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;bufferSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;micFormat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;converted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;converter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;analyzerFormat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;AnalyzerInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;converted&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;audioEngine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;audioEngine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;Transcription is on-device, but the language model is a &lt;strong&gt;system-shared asset&lt;/strong&gt; that may not be installed yet (it doesn't count against your app bundle). A first run with no network can't download it, so handle that state explicitly instead of failing silently.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;installed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;SpeechTranscriber&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;installedLocales&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bcp47&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;installed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bcp47&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;AssetInventory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assetInstallationRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;supporting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;transcriber&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;downloadAndInstall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// has .progress for a UI&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Volatile vs. finalized results
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;transcriber&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;piece&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;characters&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isFinal&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;finalizedText&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;piece&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;volatileText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;              &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;volatileText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;piece&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. There is no Custom Vocabulary
&lt;/h2&gt;

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

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// watchOS — the system handles dictation and returns text:&lt;/span&gt;
&lt;span class="kt"&gt;TextFieldLink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Speak or type"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;systemName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"mic.fill"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nv"&gt;onSubmit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A note on latency (with the conditions attached)
&lt;/h2&gt;

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

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

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

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




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

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

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

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

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

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

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

        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;analyzer&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;inputSequence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="nf"&gt;startMic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// installs the tap, converts buffers, yields AnalyzerInput&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire spine of the live dictation I wired into the iOS app I build by myself. Forty-odd lines, no third-party packages, running fully on-device on iOS 26. It took me about a day to write and the better part of a week to stop getting wrong. This post is the week, not the day.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

&lt;p&gt;The fix is the two-string split at the top of the class. A volatile result is a guess about the same span of audio the recognizer is still chewing on. It is meant to &lt;em&gt;replace&lt;/em&gt; the previous guess, not extend it. A final result is the recognizer committing: this span is settled, it will never be revised. So volatile text gets assigned, final text gets appended, and the moment a final arrives I clear the volatile buffer so the same words don't show up twice.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isFinal&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;committed&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;          &lt;span class="c1"&gt;// permanent, append&lt;/span&gt;
    &lt;span class="n"&gt;volatile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;AttributedString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;     &lt;span class="c1"&gt;// the guess is now redundant&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;volatile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;            &lt;span class="c1"&gt;// transient, overwrite&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

&lt;p&gt;The microphone tap has to convert every buffer before it goes in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;startMic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inputNode&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;micFormat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;outputFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forBus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;analyzerFormat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;converter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;AVAudioConverter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;micFormat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;analyzerFormat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="kt"&gt;TranscriberError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;noUsableFormat&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

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

    &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AnalyzerInput(buffer:)&lt;/code&gt; is the envelope the stream carries. The tap closure runs on an audio thread, so I keep it cheap: convert, yield, done. Nothing else belongs in there. I learned that the hard way too, by doing string work in the closure and watching the audio glitch.&lt;/p&gt;

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

&lt;p&gt;On-device is the headline, and it is true: nothing I record leaves the phone, and because there is no metered speech API behind it, a user can talk all day and my server bill stays exactly zero, which for a solo dev with no backend is the entire reason this feature was even thinkable. But "on-device" is not the same as "already on the device." The language model has to be present, and the first time a given locale comes up it may not be.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;ensureModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="nv"&gt;transcriber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;SpeechTranscriber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Locale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;wanted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bcp47&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;supported&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;SpeechTranscriber&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;supportedLocales&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;supported&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bcp47&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;wanted&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="kt"&gt;TranscriberError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;localeUnsupported&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;installed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;SpeechTranscriber&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;installedLocales&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;installed&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bcp47&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;wanted&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;AssetInventory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assetInstallationRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;supporting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;transcriber&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;downloadAndInstall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// this is the "Preparing…" state the user sees&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two checks, not one. &lt;code&gt;supportedLocales&lt;/code&gt; answers "can this device ever transcribe this language" — at the time of writing the list runs to forty-some locales, from &lt;code&gt;en_US&lt;/code&gt; to &lt;code&gt;ja_JP&lt;/code&gt; to &lt;code&gt;yue_CN&lt;/code&gt;. &lt;code&gt;installedLocales&lt;/code&gt; answers "is the model on disk right now." Only when a locale is supported but not installed do I ask &lt;code&gt;AssetInventory&lt;/code&gt; to fetch it, and that download is what surfaces as a "Preparing…" label in the app.&lt;/p&gt;

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

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

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

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

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

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

&lt;p&gt;The bug that survived longest into production was a lifecycle one, and it is worth flagging because it is counterintuitive. Finishing the input stream does &lt;strong&gt;not&lt;/strong&gt; finish the session. Calling &lt;code&gt;continuation.finish()&lt;/code&gt; just tells the analyzer no more audio is coming; the analyzer stays alive, holding resources, waiting. To actually wind down you have to call a finish method on the analyzer itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;inputBuilder&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;analyzer&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;finalizeAndFinishThroughEndOfInput&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;// flush, then close for real&lt;/span&gt;
    &lt;span class="n"&gt;resultsTask&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;finalizeAndFinishThroughEndOfInput()&lt;/code&gt; flushes whatever audio is still in flight into final results before it closes, so you don't lose the last word someone spoke. I had the &lt;code&gt;engine.stop()&lt;/code&gt; and the stream &lt;code&gt;finish()&lt;/code&gt; from day one and assumed that was teardown. It wasn't, and the leak only showed up after a few dozen start/stop cycles in a long session.&lt;/p&gt;

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

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;AVAudioPCMBuffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                     &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="nv"&gt;converter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;AVAudioConverter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                     &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="nv"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;AVAudioFormat&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;AVAudioPCMBuffer&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sampleRate&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sampleRate&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;capacity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;AVAudioFrameCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buffer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;frameLength&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;AVAudioPCMBuffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;pcmFormat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;frameCapacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;capacity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;supplied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;conversionError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;NSError&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;
    &lt;span class="n"&gt;converter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;conversionError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;supplied&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pointee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;noDataNow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;supplied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pointee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;haveData&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;buffer&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;conversionError&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;out&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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




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

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

</description>
      <category>swift</category>
      <category>ios</category>
      <category>programming</category>
      <category>softwareengineering</category>
    </item>
  </channel>
</rss>
