A few months ago, I started noticing something strange in my app.
Nothing was crashing.
Nothing obvious was broken.
But occasionally:
- users saw stale data appear briefly
- network requests were triggered twice
- UI updates happened after a screen had already disappeared
Naturally, we checked the usual suspects:
- race conditions
- caching bugs
- API inconsistencies
Everything looked fine.
Until we started tracing the execution paths.
That's when we found them.
Not one.
Not two.
But dozens of tiny Task {} blocks scattered across the codebase.
Each one looked harmless during code review.
Each one worked perfectly in isolation.
But together they created something far more subtle:
unstructured concurrency.
And once we looked deeper, we discovered something even more
interesting.
Many of those tasks were also interacting with @MainActor-isolated
code in ways that quietly turned the UI thread into a traffic
bottleneck.
No crashes.
No compiler warnings.
Just strange behavior that only appeared under real user
interaction.
The kind of bugs that make you stare at logs for hours.
This article is about those bugs.
And more importantly:
- why unstructured concurrency often causes them
- how
@MainActorcan accidentally amplify them - and how a few simple patterns can make Swift concurrency behave the way it was actually designed to.
The Promise of Structured Concurrency
Swift's concurrency model is built around a simple but powerful idea:
Tasks should have a clear parent--child relationship.
When a parent task is cancelled, its children cancel automatically.
func loadProduct() async throws -> Product {
async let details = api.fetchDetails()
async let reviews = api.fetchReviews()
return try await Product(
details: details,
reviews: reviews
)
}
Here:
- both child tasks belong to
loadProduct - if the parent cancels → children cancel
- errors propagate cleanly
- lifetimes are predictable
This is structured concurrency.
Think of it like a well-managed engineering team: everyone knows who
they report to.
Then Someone Writes This
Sooner or later, someone writes:
Task {
await api.fetchProducts()
}
Looks harmless.
Feels convenient.
But this is unstructured concurrency.
That task:
- has no parent
- has no ownership
- has no automatic cancellation
It's basically a free‑roaming asynchronous creature.
Once created, it just... keeps living its life.
The Ghost Task Problem
Let's look at a SwiftUI example.
struct ProductView: View {
var body: some View {
Text("Products")
.onAppear {
Task {
await viewModel.loadProducts()
}
}
}
}
User opens the screen → task starts.
User navigates away immediately → view disappears.
But the task?
Still running.
Because that Task {} has no relationship with the view lifecycle.
Congratulations.
You just created a ghost task.
Eventually it might finish and update state for a view that no longer
exists.
These bugs are subtle, unpredictable, and extremely hard to trace.
The Accidental Parallelism Problem
Unstructured tasks also create hidden duplication.
func refresh() {
Task {
await repository.loadData()
}
}
User taps refresh twice quickly.
Now you have two independent tasks fetching the same data.
Possible outcomes:
- duplicated requests
- racing state updates
- overwritten results
- inflated analytics metrics
And during code review, nobody notices because:
"It's just a
Task {}."
The Uncancelled Work Problem
A very common example appears in view models.
class ProductViewModel: ObservableObject {
func loadProducts() {
Task {
let products = await api.fetchProducts()
self.products = products
}
}
}
Looks perfectly normal.
But the view model cannot cancel this task.
If:
- the screen disappears
- the user logs out
- the view model is recreated
...the task keeps running anyway.
You've effectively lost control over your concurrency.
The Structured Alternative
SwiftUI actually gives us a much better option.
.task {
await viewModel.loadProducts()
}
The .task modifier automatically ties the task to the view
lifecycle.
When the view disappears:
the task is cancelled automatically.
Now Enter @MainActor
And Why @MainActor Is Both a Blessing... and a Trap
If Task {} is the most casually abused API in Swift concurrency,
@MainActor might be a close second.
Before Swift concurrency, UI updates looked like this:
DispatchQueue.main.async {
self.titleLabel.text = title
}
Swift improved this with:
@MainActor
class ProductViewModel: ObservableObject {
@Published var products: [Product] = []
}
Now UI updates are automatically safe.
Clean.
Elegant.
Predictable.
But like many convenient tools in software engineering...
it's also easy to misuse.
The "Just Add @MainActor" Fix
Soon someone sees a concurrency warning and writes:
@MainActor
func loadProducts() async {
let products = await api.fetchProducts()
self.products = products
}
Looks harmless.
But now the entire function executes on the main actor.
Which means orchestration begins on the UI executor.
Multiply this across several view models and suddenly:
- UI work
- async orchestration
- state mutation
...all compete on the same actor.
Your main thread becomes a traffic intersection at rush hour.
The MainActor ViewModel Trap
Another common pattern:
@MainActor
class FeedViewModel: ObservableObject {
func loadFeed() async {
let posts = await api.fetchFeed()
self.posts = posts
}
}
Now every method in the class runs on the main actor.
Including things that shouldn't:
- JSON parsing
- sorting large datasets
- heavy data transformations
The UI thread quietly becomes your data processing engine.
Scrolling performance will eventually protest.
The Better Mental Model
Think of @MainActor as UI state protection, not a concurrency
shortcut.
class FeedViewModel: ObservableObject {
@MainActor
@Published var posts: [Post] = []
func loadFeed() async {
let posts = await api.fetchFeed()
await MainActor.run {
self.posts = posts
}
}
}
Here:
- networking happens off the main actor
- computation happens off the main actor
- only UI state touches the main actor
Exactly what we want.
When Both Problems Meet
The most interesting bugs appear when unstructured tasks meet careless
@MainActor usage.
Task {
await viewModel.loadFeed()
}
If loadFeed() is @MainActor, you now have:
- an unstructured task
- executing on the main actor
- doing work that might not belong there
Perfect recipe for:
- UI hiccups
- mysterious delays
- hard‑to‑reproduce bugs
The kind that only appear in production.
A Simple Rule I Follow
Whenever I see:
Task { ... }
I ask:
Who owns this task?
And whenever I see:
@MainActor
class Something
I ask:
Is this truly UI state, or just convenient isolation?
Those two questions alone catch a surprising number of concurrency
bugs.
Final Thoughts
Structured concurrency gives us:
- predictable task lifetimes
- automatic cancellation
- safe error propagation
- clear ownership
Unstructured concurrency gives us:
- invisible background work
- duplicated tasks
- uncancellable operations
- unpredictable behavior
And careless @MainActor usage can quietly turn the UI thread into a
bottleneck.
Or as I like to think about it:
Structured concurrency is like a well‑run orchestra.
Unstructured concurrency is everyone playing instruments whenever they
feel like it.
Sometimes it still sounds okay.
Until it suddenly doesn't.



Top comments (0)