A @MainActor class passes a closure to a protocol method. The closure fires on a background thread. Thread.isMainThread prints false. The response would that be to check if its the same @Mainactor if not then spawn a dispatchqueue on main thread or a Task with @Mainactor.
This is the story of discovering @isolated(any).
The Setup
We have a coordinator pattern. A Registrar on the @MainActor registers coordinators and kicks them off:
@MainActor
class Registrar {
var coordinators: [Coordinating] = []
func register(_ coordinator: Coordinating) {
coordinators.append(coordinator)
}
func run() {
for coordinator in coordinators {
coordinator.start(completion: { from in
print("\(Thread.isMainThread) : \(from)")
})
}
}
}
The Coordinating protocol is simple:
protocol Coordinating: AnyObject {
func start(completion: @escaping (String) -> Void)
}
And here’s a concrete coordinator — it does some async work and calls the completion:
class Coordinator: Coordinating {
func start(completion: @escaping (String) -> Void) {
Task {
try? await Task.sleep(for: .seconds(1))
completion("Coordinator")
}
}
}
The Bug / Annoyance
Run this and check the console:
false : Coordinator
The closure was defined inside Registrar.run(), which is @MainActor. But the coordinator calls it from inside an unstructured Task — which runs on the cooperative thread pool, not the main thread. The closure carries no isolation context. It runs wherever the caller happens to be and it happens be on a nonisolated context.
This is not a theoretical problem. In our codebase, this meant UI updates triggered from a background thread, which triggers a series of issues and most importantly a series of fixes which would most likely endup being a codesmell.
Fixes That Fall Short
Fix 1: DispatchQueue.main.async
The pre-concurrency reflex:
coordinator.start(completion: { from in
DispatchQueue.main.async {
print("\(Thread.isMainThread) : \(from)")
}
})
We are mixing two concurrency worlds — GCD and async/await — which is how you end up with hairy issues.
Fix 2: @MainActor @Sendable Closure
Annotate the closure type to force it onto the main actor:
protocol Coordinating: AnyObject {
func start(completion: @escaping @MainActor @Sendable (String) -> Void)
}
This works and it’s type-safe. The compiler enforces it. But you’ve hardcoded the actor. What happens when a class isolated to a different actor wants to use the same Coordinating protocol? You’d need a different protocol — or you’d force every consumer onto the main actor whether they need it or not. Also the most important point is that @MainActor does not guarantee that the closure will land on @Mainactor.
Fix 3: Task { @MainActor in } Inside the Closure
Wrap the closure body in a main-actor task:
coordinator.start(completion: { from in
Task { @MainActor in
print("\(Thread.isMainThread) : \(from)")
}
})
This has multiple problems:
- Hardcoded — same inflexibility as Fix 2, just moved to the call site.
- Fire-and-forget — the original closure returns immediately. The
Taskruns later. If anything depends on ordering, it’s broken. - Boilerplate tax — every call site needs to remember this wrapping. Miss one and the bug is back.
- Unstructured — you’ve abandoned structured concurrency entirely.
All three fixes share the same fundamental flaw: they require the caller to know and compensate for the fact that the callee might invoke the closure from the wrong context. The fix should live in the protocol itself.
Enter @isolated(any)
@isolated(any) is a function type attribute introduced in SE-0431. It tells the compiler: this closure captures the isolation context of whoever creates it, and the callee must respect that context when invoking it.
Here’s the fix:
protocol Coordinating: AnyObject {
func start(completion: @escaping @isolated(any) (String) -> Void)
}
One annotation on the protocol. Now look at what the conforming coordinators look like:
class Coordinator: Coordinating {
func start(completion: @escaping @isolated(any) (String) -> Void) {
print("\t\(Thread.isMainThread) : \(#function) : Coordinator")
Task {
print("\t\t\(Thread.isMainThread) : Coordinator:Task")
do {
try await Task.sleep(for: .seconds(1))
await completion("Coordinator")
} catch {
await completion("Coordinator")
}
}
}
}
class Coordinator1: Coordinating {
func start(completion: @escaping @isolated(any) (String) -> Void) {
print("\t\(Thread.isMainThread) : \(#function) : Coordinator1")
Task {
print("\t\t\(Thread.isMainThread) : Coordinator1:Task")
do {
try await Task.sleep(for: .seconds(1))
await completion("Coordinator1")
} catch {
await completion("Coordinator1")
}
}
}
}
Notice the key change: the completion is called with await. This is required — the closure might need to hop to a different actor, so the call is a potential suspension point.
Now run it:
true : Coordinator
true : Coordinator1
The closure runs on the main thread. Not because we hardcoded @MainActor, but because Registrar — where the closure was created — is @MainActor, and @isolated(any) preserved that context through the protocol boundary.
How It Actually Works
A regular closure like (String) -> Void carries no isolation information. It’s a function pointer and a capture list. The caller runs it wherever they happen to be.
An @isolated(any) closure is different. At the type level, it carries an optional actor reference — the actor that the closure is isolated to. When you create the closure inside a @MainActor context, Swift attaches the main actor’s identity to it. When you create it in a nonisolated context, the actor reference is nil and it behaves like a regular closure.
The Ifs and Buts
@isolated(any) vs @Sendable
These solve different problems.
@Sendable means “this closure can be safely sent across isolation boundaries.” It constrains what the closure can capture — no mutable references, no non-sendable types.
@isolated(any) means “this closure will execute on a specific actor.” It constrains where the closure runs.
They compose: @Sendable @isolated(any) (String) -> Void — a closure that can be sent anywhere and will run on the actor that created it. But one doesn’t replace the other.
await Is Mandatory
Because calling an @isolated(any) function requires await, the callee must be in an async context:
// This won't compile:
func start(completion: @escaping @isolated(any) (String) -> Void) {
completion("Coordinator") // Error: expression is 'async' but is not marked with 'await'
}
// You need an async context:
func start(completion: @escaping @isolated(any) (String) -> Void) {
Task {
await completion("Coordinator")
}
}
If your coordinator’s start method is already async, this is natural. If it’s synchronous, you need an unstructured Task to call the completion — which is a trade-off to consider.
Suspension Even on the Same Actor
await completion(...) is a suspension point even if you’re already on the target actor. In practice, the overhead is minimal — Swift’s runtime is optimized for same-actor hops. But in a tight loop, it’s worth profiling.
Nonisolated Callers
If the closure is created in a nonisolated context, the actor reference is nil. The closure behaves like a regular (String) -> Void — it runs wherever it’s called. No crash, no protection. This is by design, but it can be surprising if you expect @isolated(any) to always guarantee actor isolation.
Swift Version
@isolated(any) comes from SE-0431 and requires Swift 6.0+.
When to Reach for It
Use it when:
- You have a protocol-based callback pattern where multiple callers with different isolation contexts use the same API.
- You want the closure to run on the caller’s actor without hardcoding which actor that is.
- You’re migrating callback-heavy code to Swift concurrency and want a gradual, type-safe path.
Don’t use it when:
- The closure genuinely doesn’t care about isolation — a plain
(String) -> Voidis simpler. - You know it must always run on MainActor —
@MainActor (String) -> Voidis more explicit and communicates intent better.
The Takeaway
A closure created on the main actor doesn’t run on the main actor unless you tell it to. @isolated(any) is that “tell” — built into the type system, enforced by the compiler, and flexible enough to work across any actor boundary.
One annotation on the protocol. await at the call site. Closure’s having a way to define their isolation!.