Async Doesn't Mean Ordered in SwiftUI
aka the bug that quietly rewired how I think about execution
Every developer eventually runs into that one bug.
Not the ones that fail loudly with a crash, a stack trace, or a clear signal pointing directly at the problem. Those bugs are honest. They announce themselves, narrow the search space, and typically allow you to move forward with a sense of progress.
The unforgettable bugs are quieter. They behave just well enough to pass casual testing. They disappear when logging is added. They resolve themselves the moment you try to demonstrate them. They survive refactors and rewrites because nothing about them looks definitively wrong. Years later, you may forget the code entirely, but you remember the feeling of trying to reason about something that refused to sit still.
This was one of those bugs.
The app mostly worked, and that was precisely the problem. The UI would occasionally stall without explanation. State updates would arrive out of sequence in ways that were noticeable but not repeatable. User interactions felt delayed or duplicated just often enough to undermine confidence, but never often enough to point to a single line of code. There were no crashes. There were no fatal errors. Once the code compiled cleanly, there were not even warnings to lean on.
I read the code carefully, then obsessively. The async functions were small and deliberate. Await points were placed exactly where they should have been. From a surface-level reading, the execution appeared linear and controlled. I assumed the fault must live somewhere else in the system. When that led nowhere, I blamed Swift concurrency. When that felt unproductive, I blamed SwiftUI. Eventually, and briefly, I blamed myself.
The behavior did not change.
The worst bugs do not break your code.
They break your trust in how the system behaves.
That loss of trust was the real issue. I could no longer rely on my understanding of how execution flowed through the app, which meant every fix felt provisional and every explanation felt incomplete.
At the center of the problem was an assumption I had never written down, but which I fully believed. I believed that async and await implied order in practice. If I awaited one operation before starting the next, I believed the effects would be serialized. I also believed SwiftUI, by virtue of being declarative and opinionated, would prevent me from accidentally creating unsafe execution paths by default.
That assumption was incorrect, but not in an abstract sense. It was incorrect in a specific, mechanical way.
SwiftUI views are not execution surfaces. They are descriptions that the system evaluates whenever it reconciles the state with presentation. A view can be recreated, reevaluated, or discarded entirely without notice. When async work is attached to views through task modifiers or event handlers, multiple tasks can be spawned implicitly, even when the code appears orderly and well-structured.
Each task awaited its own work correctly. The issue was that the tasks themselves were not coordinated.
Await suspends the current task. It does not impose order across tasks.
The turning point came when I stopped reading the code as a single sequence and instead examined how many independent execution paths could exist at runtime. Once I did that, the behavior stopped feeling mysterious.
Async does not mean ordered. Await does not mean exclusive.
That realization did not introduce new complexity. It removed guarantees I had assumed were present.
Multiple tasks were running concurrently. Some were triggered by user interaction. Others were triggered by view lifecycle changes. Others were triggered by SwiftUI deciding a view needed to be recomputed. All of them mutated the shared state under the assumption that they were participating in the same logical flow.
They were not.
State mutations were interleaving across tasks rather than occurring within a single, controlled execution context. From the runtime’s perspective, everything was valid and well-defined. From the application’s perspective, the result was instability that surfaced as delayed updates and inconsistent UI behavior.
My first instinct was to constrain the system aggressively. I considered annotating everything with the main actor and forcing serialization everywhere. That approach would have reduced the symptoms, but it would have preserved the flawed execution assumptions that caused the issue in the first place. It would have treated the behavior rather than correcting the cause.
The fix that held was architectural and deliberate. The clearest evidence of the problem surfaced in the view layer, not because the views were wrong, but because they were where multiple execution paths converged. Correcting the issue meant realigning the role of views themselves.
With that restructuring, views stopped mutating state and stopped owning async logic. Their responsibility narrowed to expressing intent, while execution and state changes moved into a service layer that could enforce order explicitly.
There was now a single execution context for state changes, a single place where ordering was defined rather than inferred. The UI stabilized immediately. State updates arrived predictably. Even compiler warnings were addressed intentionally rather than suppressed, treated as signals to resolve rather than noise to silence, because the code matters and undefined behavior has a way of resurfacing later.
Concurrency is not something you sprinkle in after the fact. It is a resource you either shape deliberately or let shape your system for you.
This lesson is not unique to SwiftUI. SwiftUI simply makes the trap easier to fall into because it abstracts away much of the execution machinery. The same failure mode exists in any reactive system where work can be triggered implicitly and shared state is mutated optimistically.
Concurrency cannot be understood by inspecting individual functions. It only becomes visible once you expand the context and look at how execution flows across the system as a whole. At that level, you are forced to think in terms of ownership, boundaries, and explicit serialization. If you rely on intuition or assume the language will preserve order for you, a bug like this is inevitable.
And when it shows up, you will remember it.
Bugs like this feel personal at first, but they are not. They are mismatches between the execution model you believe in and the execution model the system actually follows. Once those two align, the problem does not require a clever solution. It simply stops existing.
Async is powerful. SwiftUI is powerful. Neither one promises order.
That responsibility belongs to the programmer.
Once you accept that, the bug that lingered for weeks resolves quietly, leaves no trace behind, and becomes a reference point you carry forward as proof that understanding execution is more valuable than any individual fix.


