Concurrency Boundaries in SwiftUI Screens
The confusion
SwiftUI makes async work feel easy until the screen starts doing too much. Then people mix task creation, state mutation, and rendering into the same place and wonder why the code feels fragile.
The problem is not concurrency itself. The problem is that the boundary between UI and async work gets blurry.
What SwiftUI is actually doing
SwiftUI redraws views frequently. That means any async work tied to a view must be designed carefully so it can start, stop, and restart without causing duplicate side effects.
The screen boundary is the right place to manage that work. The body is not.
In practice, that means:
- the view describes the UI state
.taskstarts work when the view is present- the model or service owns long-lived async behavior
- cancellation is handled intentionally
The mental model
Concurrency belongs to the feature boundary, not the rendering boundary.
If the work needs lifecycle, retries, or cancellation, it probably belongs somewhere more explicit than body.
This matters because SwiftUI can rebuild views at any time. Your code should remain stable even when the rendering layer is not.
A small proof
import SwiftUI
struct SyncScreen: View {
@State private var message = "Idle"
var body: some View {
Text(message)
.task {
message = "Loading..."
await Task.yield()
message = "Done"
}
}
}
This works, but it also shows the boundary clearly: the view can trigger the work, while the work itself stays small and explicit. If the task grew larger, it would be a sign to move the async logic into a dedicated model or service.
Why this matters in real apps
Concurrency bugs are often ownership bugs in disguise. If a row starts a task, who cancels it? If a screen starts a task, what happens when the screen disappears? If a service does the work, how does the UI receive updates?
Those are architecture questions, not syntax questions.
The cleaner the boundary, the easier it is to answer them.
Where this model breaks down
Very small async work can stay close to the view if it is easy to reason about and harmless to repeat. But once you introduce fetching, caching, or cancellation, the boundary should become explicit.
That is especially true in scrolling lists, where views appear and disappear constantly.
One sentence to remember
Treat concurrency as feature logic with a lifecycle, not as a side effect of rendering.
Next steps
These follow-ups keep the same boundary discipline across concurrency and side effects: