Why Swift 6 Keeps Yelling About ‘Isolated’ and What It’s Actually Trying to Tell Us
If you are an iOS developer moving your codebase to modern Swift (or you just turned on Strict Concurrency Checking), you have probably experienced the same thing.
Some time ago, I was working on a SwiftUI project and noticed my build log full of yellow warnings. The most common one was this:
Property isolated to global actor ‘MainActor’ cannot be referenced from a non-isolated context
At first, I just wanted the warnings to disappear.
I added Task { @MainActor in ... } everywhere just to silence the noise.
But then I realized something: I didn’t actually understand why Swift was complaining.
So I decided to look deeper into the idea of isolation. What I learned completely changed how I think about my code.
Here is what I learned, so you don’t have to struggle with it alone.
The Mental Shift: From Threads to Islands
In the old days (GCD and DispatchQueue), we mostly thought about threads.
We worried about two threads touching the same variable at the same time and crashing the app.
Swift Concurrency asks us to stop thinking about threads and start thinking about islands instead.
🌊 The Sea (Non-Isolated)
This is the default state.
Normal classes, structs, and functions live in the “sea” of concurrency. Anyone can swim up and access them at any time. This can be dangerous when the data can change.
🏝️ The Islands (Actors)
An actor (including @MainActor) is a protected island. It has walls around it.
-
Only one thing can run on the island at a time
-
If you are in the sea and want to read or change data on the island, you must wait at the dock using
await
The Most Common Trap: MainActor
In SwiftUI, your Views and usually your ViewModels live on the MainActor island. This is the most important island, because the UI lives there.
Most warnings appear when you are floating in the sea (for example, in a background task, delegate method, or completion handler) and you try to reach the island to change a property without waiting properly.
Below are the three situations I ran into most often, and how to fix them the right way.
1. The Background Update
The Scenario
You fetch data in a background Task and try to update a @Published property on your ViewModel.
The Warning
Property ‘state’ isolated to global actor ‘MainActor’ cannot be mutated from a non-isolated context
The Fix
You must jump back onto the island.
func loadData() {
Task {
// We are in the "Sea" (background)
let data = await api.fetch()
// ❌ You can't touch the UI from the Sea
// self.state = .loaded(data)
// ✅ Hop onto the MainActor island
await MainActor.run {
self.state = .loaded(data)
}
}
}
2. The Protocol Conformance
The Scenario
Your @MainActor ViewModel conforms to a standard protocol (for example, a delegate).
The Warning
MainActor-isolated instance method cannot be used to satisfy a non-isolated protocol requirement
The Why
The protocol definition lives in the sea. It promises that its methods can be called immediately from anywhere.
But your ViewModel lives on the island and says, “You can only call me from the main thread”. These two promises don’t match.
The Fix
If the function does not touch the UI, mark it as nonisolated. This tells Swift: “I promise this function does not need the island’s protection.”
@MainActor
class ViewModel: MyDelegate {
// ✅ Allow the Sea to call this directly
nonisolated func didFinishWork() {
print("Work finished!")
}
}
3. The “Legacy API” Confusion
The Scenario
This one confused me the most. I was using NotificationCenter with queue: .main.
NotificationCenter.default.addObserver(
forName: .updated,
object: nil,
queue: .main
) { _ in
self.updateUI()
}
I was sure this code runs on the main thread. So why doesn’t the compiler trust me?
The Lesson
Swift Concurrency checks code at compile time. queue: .main is a runtime guarantee.
The compiler only sees a normal closure, so it treats it as non-isolated, even if we know it will run on the main thread.
Fix 1: The Performance-Friendly Way
If you are absolutely sure you are already on the main thread, you can use assumeIsolated.
NotificationCenter.default.addObserver(
forName: .updated,
object: nil,
queue: .main
) { _ in
MainActor.assumeIsolated {
self.updateUI()
}
}
This avoids creating a new async task.
Fix 2: The Modern Swift Way
The better long-term solution is to avoid closure-based APIs and use Swift’s AsyncSequence instead.
Task {
// This Task runs on the MainActor
for await _ in NotificationCenter.default.notifications(named: .updated) {
self.updateUI()
}
}
This approach handles isolation and memory management for you, with no warnings and no tricks.
Conclusion
These warnings can be annoying, but they exist to protect us from race conditions that used to crash our apps in unpredictable ways.
Once I stopped fighting the compiler and started visualizing my code as islands, the solutions became much clearer.
If you are stuck with these warnings, ask yourself:
“Am I in the sea, or am I on an island?”
Happy coding 🚀