I built ZumNum on Combine and ran it that way for almost exactly a year. ZumNum is a small app for learning German numbers: it says a number out loud, you type what you heard, it tells you whether you got it right.
Then a bug sent me looking. Tap the play button twice quickly and the app would jump to the next question before you’d answered the current one. Small, silly, and easy to reproduce.
The fix took an afternoon, deleted about 1,700 lines, and removed every last trace of Combine. This is the story of that migration: what moved, what broke on the way, and the uncomfortable part I keep coming back to, which is that the migration itself is not what fixed the bug.
Why the app was built on Combine
Combine is Apple’s framework for reactive programming, and the idea behind it is genuinely appealing. Instead of calling functions in order, you describe relationships once: when this value changes, update that one. Then you stop thinking about it. The updates flow through on their own.
ZumNum had a chain of five of these relationships:
speech synthesizer → voice manager → step manager → game engine → view model
Read that as a row of dominoes. iOS tells the speech synthesizer that a number finished playing. That knocks over the first domino, which knocks over the next, all the way down the line until the screen updates.
Each link was a few lines. Each file was small and did one job. It looked like good code, because it is the shape you get from following all the usual advice. That is worth sitting with before touching any of it: nothing about the code looked wrong.
The first thing that was broken
Before deciding what to migrate to, I had to understand what was actually broken. Two things were.
The first was a data race, and it hid for months. iOS delivers that “speech finished” message on a background thread, not the main thread where the rest of the app lives. That is normal and documented, and I knew it. What I didn’t think about: because each domino knocks over the next one immediately, all five links ran on that background thread.
At the fourth link, my code read the list of questions, changed one item, and wrote the list back. Three separate steps. Meanwhile, if you typed an answer, a different part of the app changed that same list from the main thread. Two threads, one list, nothing coordinating them. Depending on the timing, that corrupts the list or loses an update.
The promise that fooled me
Combine does make a safety promise here. The container I was using, CurrentValueSubject, guarantees that each individual update is atomic: two updates can’t tangle up halfway through. I think that promise is exactly what fooled me, because it sounds like “this is thread-safe, stop worrying.”
My code wasn’t doing one update. It was doing three things in a row, and the other thread could slip in between them. Here is the note I wrote when I finally worked it out:
CurrentValueSubjectmakes eachsendatomic; it does not make read-then-write atomic.
Combine gave me safe delivery. I heard safe state. That distinction is the whole bug.
Why it stayed hidden
No single file showed the problem. Open any one of those five files and the threading isn’t just hard to spot, it isn’t there. Nothing says “this runs on a background thread.” To know which thread the fourth link ran on, you had to trace four relationships backwards through four files to a callback in a fifth, and then already know how iOS delivers speech callbacks. The threading was a property of the whole chain, and the chain only really exists while the app is running.
The second thing that was broken
The data race could corrupt state. It wasn’t what made the app skip. That was a second problem, and a simpler one.
When speech stops, iOS calls the same method whether it finished naturally or was cut off early. One message for two different situations. My code treated that message as “the number finished, move on to the answer.”
So you tap play a second time. The app cancels the first utterance to start the new one. Cancelling produces the same message as finishing. The app moves on. It was working exactly as written, just not as intended.
That one isn’t Combine’s fault. It’s a modelling mistake: I never asked what the message meant. But the chain is what let me get away with never asking, because a stream of events doesn’t force you to define them.
What a straight migration would have done
Here is the part that decided how I did the migration, and it’s the thing I’d most want another engineer to take from this.
I could have done a mechanical swap. Every Combine chain becomes an async stream. Every subscription becomes a for await loop. It compiles, the tests pass, and it is the same five dominoes: same hidden threading, same unwritten state machine, same one message meaning two things. Tap play twice and it still skips.
If I’d migrated that way, I’d have concluded that Swift concurrency was overrated, because it would have carried the bug across intact. The framework was never the disease. So the migration had to change the shape of the code, not just its vocabulary.
Step one: await instead of subscribe
The whole speech system now sits behind a single function:
/// Speaks `number`, returning only once playback has genuinely finished.
///
/// - Throws: `CancellationError` if playback was interrupted before it
/// finished — either by cancelling the calling task, or by a later
/// `speak` superseding this one.
func speak(_ number: Int, rate: SpeechRate) async throws
You call it, you await it, and it returns when the sound has actually stopped. That is the entire replacement for the first two dominoes.
Before, finishing was a message that arrived from somewhere, on some thread, whether or not anyone was ready for it. After, finishing is a function returning. It comes back to the exact place that called it, on the thread that called it. There is nowhere for the background thread to sneak in, because there is no longer a chain running on it.
Cancelling now throws instead of returning normally, so finished and cancelled come back on completely different paths. The compiler won’t let you treat an error as a normal return, so the two can’t be confused by accident. That closes off the skip bug at the language level, rather than with a rule I have to remember.
Step two: write down the state machine
The chain of dominoes was a state machine all along. It had states, events, and rules about which led to which. I had just never written it down; it lived across five files as “who subscribes to whom.”
So I wrote it down. Every event that can happen to a round is now one case in a list:
enum GameFlowEvent: Equatable {
case playTapped
case answerSubmitted
case nextTapped
/// Playback finished on its own.
case speechFinished
/// Playback was cancelled before it finished — the player tapped play
/// again, or left the screen. Deliberately NOT the same as finishing.
case speechCancelled
/// Cut short by a phone call, Siri, or an alarm. Recoverable, so the
/// player keeps their progress.
case speechInterrupted
/// The app couldn't produce the audio at all. Not fixable by replaying.
case speechFailed
}
The single “speech stopped” message became four separate events, because those are four genuinely different situations that deserve four different responses. A plain function then takes the current state and an event and returns the new state: no side effects, just a table you can read and test.
Two things fell out of that for free. If an event doesn’t make sense in the current state, nothing happens, so “move to the next question when there isn’t one” went from a crash to a no-op. And a speech-finished event that arrives late lands in a state with no rule for it and is ignored, which is exactly the skipping bug, now unable to occur.
None of this came from Combine, and none of it came from async/await. Writing down the states is work the language doesn’t do for you.
The migration introduced its own bug
I’d be selling you something if I stopped there.
Waiting for a function to return means something has to make it return. Playback finishing does that. Cancelling does that. Between them they cover everything the app does. They don’t cover what the phone does.
If a call comes in, or Siri, or an alarm, iOS takes the audio away and stops playback mid-sentence. The “finished” callback never fires, so nothing ever makes speak return. The app just waits. Not a crash and not a race: the screen freezes on “playing” and never moves. You take a call, come back, and the app is dead.
That was the trade I made without noticing. Under Combine, a message that never arrives is a missed update. Under async/await, a function that never returns is a hang. Same underlying gap, different failure.
The fix needed one more distinction. An interruption does have to make speak return, but it can’t pretend to be a cancellation, because the app treats cancellation as “the user asked for this, stay put.” A phone call disguised as a cancellation would freeze the screen just the same. So an interruption became its own error and its own event: the round drops back to the number, your typed answer is still there, you tap play and carry on. That is how one message ended up as four events, and I didn’t see it coming when I drew the first version.
The freeze I never fully explained
The interruptions above are the ones I reasoned my way to. There was one more that a user actually reported, and I’m including it precisely because I never got fully to the bottom of it.
Start a round, leave the app for half an hour, come back, and the play button did nothing. Not slow, not wrong. Dead. You had to force-quit and relaunch.
My first guess was that iOS had freed the audio player out from under the suspended app, so I was tapping play into nothing. I never managed to prove that, and I don’t really believe it now. What I could see was the shape of it. The round was stuck in its speaking state, waiting on a spoken number to finish, and that finish never arrived. Tapping play there did nothing, because a round that is already speaking has no rule for another play. It was the same dead end as the phone-call hang, reached by a slower road: backgrounding the app for long enough is one more way for iOS to take the audio out from under a speak that is still waiting on it.
Here is the honest part. After the migration it stopped happening, and I can’t fully tell you why. The likely reason is the one this whole post is about: an utterance that gets cut short is now a named, recoverable event instead of silence on a subscription, so a round that would once have stranded itself has somewhere to go. But I never instrumented what iOS actually does to the audio session at the thirty-minute mark, and I won’t pretend I did. A bug that quietly disappears when you rebuild the thing around it is a good outcome and an unsatisfying one, and this was both.
What Combine was actually doing
Deleting a framework you built the app around forces one honest question: what was it actually for? Combine had one job I genuinely needed, which was telling several screens at once that something had changed. That part is real, and I kept it.
I just didn’t need Combine to do it. It’s now a few lines using Apple’s @Observable: the state is a property, the views read it, and they update when it changes. No subscriptions to leak, and no question about which thread a change arrives on, because there are no more chains delivering it.
The receipts
Two commits, one afternoon. Ninety-four files, roughly 4,500 lines added and 1,700 deleted, seven types deleted entirely. Combine had been in the app for almost exactly a year.
The commit message lists four bugs that became impossible: the data race, a crash, a locale bug in number parsing, and the double-tap. To be straight, only two of those were Combine’s doing. The crash was a fatalError I’d left in release builds, and the locale bug was me misusing NumberFormatter. They got fixed because the rewrite touched the same files, not because reactive programming caused them. Blaming all four on the framework would be easy and wouldn’t be honest.
One thing I didn’t expect: the migration revealed a bug instead of fixing one. Once speak could safely run off the main thread, the app started preparing the next number while the current one was still playing, and two threads began writing to an unprotected list inside a test helper I’d marked safe long ago and never rechecked. The note I left was blunter than usual:
Its
@unchecked Sendableinvariant was a lie.
It had always been a lie. Nothing had ever run it from two threads before, so nothing had ever caught me.
What I’d tell you before you start
If you are about to move an app off Combine, the migration is worth doing. Async functions say which thread they run on, and the compiler holds you to it, which beats threading that only exists while the app is running. But treat the migration as the occasion, not the cure.
The two things that actually fixed my bug were things I’d left implicit: which thread the work ran on, and what states the app could be in. Swift concurrency made me write down the first. Nothing but a piece of paper made me write down the second. A chain of reactive operators is a state machine you haven’t written down yet, and a compiler can’t check something that was never written.
ZumNum is on the App Store if you want to see where it landed. It says a number in German, you type what you heard. No chains behind it anymore.