The actor-isolation trap in "just move it off the main thread"
This app was later renamed Deep Cut Atlas. It’s called “Discoverer” throughout below, because that’s what it was called on the day this happened.
I spent part of this week fixing wasted work in Discover, the tab that surfaces deep cuts from artists in your library. One fix looked trivial on paper and turned into a small tour of Swift 6’s stricter concurrency rules.
The setup: the app’s on-disk cache for Discover was doing a blocking JSON decode right on the main thread, every time the tab checked whether its cache had gone stale. Not huge on its own, but it happened on every foreground, and the file grows with your library. The fix seemed obvious. Wrap the decode in a detached task so it runs off the main actor.
It didn’t compile.
main actor-isolated conformance of 'DiscoveryFeed' to 'Decodable' cannot be used in nonisolated context
This app is built with default actor isolation set to MainActor, meaning every plain type in the module is implicitly tied to the main actor unless you say otherwise. I knew that part. What I hadn’t internalized is that it applies to compiler-synthesized code too. A plain struct’s automatically generated Codable conformance, the encode and decode methods you never write yourself, inherits that same isolation. My struct’s stored properties were all Sendable. Didn’t matter. The struct itself, and therefore its synthesized decoder, was main-actor-isolated, and a detached task is not on the main actor by definition.
The fix is a single word: mark the type nonisolated. Not a method, the whole type. And it’s contagious in the direction you’d expect: my top-level cache struct held an array of a nested struct, which itself held an array of an album model used elsewhere in the app. All three needed the annotation, because Swift checks the isolation of whichever type is actually doing the encoding, not just its leaf properties.
Then a second, unrelated wall. I wanted two independent network calls to start concurrently instead of one after another, so I reached for async let, the textbook way to do exactly that. Compile error again, this time about a “non-Sendable type… cannot exit main actor-isolated context.” Turns out async let always spins up a real child task under the hood, and a child task has the same requirements as any other concurrent task: whatever it captures needs to be provably safe to hand off. My service was isolated to the main actor by design, which normally makes it safe to call from anywhere in the app, but that safety doesn’t automatically transfer into a brand new task, even one that will immediately call back into the same actor. The working fix was uglier than I wanted: a plain, unstructured Task { }, which inherits the calling actor context instead of asking for a fresh one. Less elegant than async let, but it’s the pattern the rest of this codebase already uses for the same reason, so at least it’s consistent now.
Neither error was really about the code being wrong. Both were about code that “looks pure” secretly depending on a build setting most of the time nobody looks at. The lesson I keep relearning with Swift 6 concurrency: don’t reason about isolation from what a type looks like on the page. Reason about it from the project’s actual default and check every boundary a value crosses, because the compiler will absolutely let you write something the type system was already unhappy about, right up until the moment you try to run it somewhere new.
Related reading
When @MainActor and TaskGroup don't mix in Swift 6
The region-based isolation checker tells me to file a bug, and the unfashionable unstructured Task turns out to be the correct tool.
Closing the test gaps: pure logic, an unreachable mock branch, and a Swift 6 flip
Extracting algorithms out of MusicKit's reach, a mock parameter that was silently ignored, and two tests that had been passing for the wrong reason all along.
Racing the main actor: when await is the race condition
@MainActor eliminates data races, not interleaving races. A generation token, a stale-failure guard, and a deterministic race test with no sleeps.