Swift Concurrency: Rethinking Actor Boundaries Through ViewModels and ViewControllers
Many teams do not start taking Swift Concurrency seriously because they fully understand Actors first. They usually start because they enable stricter concurrency checking and suddenly face hundreds, sometimes thousands, of data-race-safety warnings. The official Swift migration guide is explicit that this can happen during a real migration. At the same time, Swift 6.2 makes concurrency adoption feel more approachable, including a more “single-threaded by default” path and easier MainActor-oriented defaults. The result is predictable: @MainActor can quickly drift from being part of a sound UI isolation model into becoming the fastest available way to make warnings disappear.
That is exactly where many codebases begin to accumulate debt. MainActor itself is not mysterious: it is a global actor whose executor is equivalent to the main dispatch queue. The real problem is not whether it “means the main thread.” The real problem is that once you start using it as a holding area for every piece of shared mutable state, UI updates, lifecycle callbacks, coordination state, request deduplication, pagination gates, and expensive formatting work all begin competing for the same executor. On the surface, your concurrency diagnostics get quieter. In practice, you have only disguised a boundary-design problem as isolation consistency.
Actor is not “a more modern lock”; it is a boundary for ownership of mutable state
For experienced iOS engineers, the most useful mental model to rebuild is not that “an actor is similar to a serial queue,” but that an actor primarily defines ownership of state, not a thread of execution. Actors run on a shared global concurrent pool by default. Actor isolation means that actor-isolated mutable state can only be synchronously accessed from the same isolation domain, and cross-actor access must happen through an asynchronous hop. In other words, the first question an actor answers is not “which thread runs this code,” but “who owns this state and who is allowed to mutate it.”
But actor guarantees have a ceiling. The actor proposal is very clear about reentrancy: once an actor-isolated function suspends at an await, that actor may execute other work before the original function resumes. That means actors can eliminate low-level data races, but they do not automatically preserve business invariants across suspension points. Actors solve data races, not transactions. That is why the most robust actors in production systems are usually not giant wrappers around all business logic. They are small, focused owners of the specific mutable state that truly must be coordinated.
That leads to the central claim of this article:
@MainActor should mark ownership of UI state. It should not be the default answer to concurrency diagnostics.
Why teams almost always end up misusing @MainActor
Because it is cheap.
During migration, redesigning state boundaries is expensive. Adding @MainActor to a type, a protocol, or a method is cheap. But Swift’s migration documentation also warns that the blast radius is not small: annotating a protocol or type with @MainActor propagates into conformances, subclasses, and extension methods. So this is not really a local fix. It is a silent architectural change to the isolation topology of an entire module.
In many projects, the deeper problem is not a lack of thread safety. The deeper problem is that three fundamentally different categories of state have been collapsed into the same object:
- UI state
- Shared mutable business state that multiple tasks may compete over
- Pure values assembled temporarily for rendering
Once those three are mixed together, ViewModels and UIViewControllers naturally grow into giant @MainActor types. From there, a false sense of legitimacy emerges: “it is related to the screen anyway, so maybe the whole type should just be @MainActor.” That choice does quiet the compiler. But what it really hides is the shape of the complexity.
Scenario 1: Why SwiftUI ViewModels misuse MainActor so easily
In SwiftUI, it is very natural for a ViewModel to own observable state. So @MainActor appears there frequently, and that part is not inherently wrong. The real problem starts when teams do not stop at “publishing UI state on MainActor,” and instead leave the entire data pipeline on MainActor as well.
Consider a very common pattern:
@MainActor
final class FeedViewModel: ObservableObject {
@Published private(set) var sections: [FeedSection] = []
private let api: FeedAPI
init(api: FeedAPI) {
self.api = api
}
func reload() {
Task {
let response = try await api.fetchFeed()
let sections = response.items
.sorted(by: FeedItem.sort)
.chunked(into: 20)
.map(FeedSection.init)
self.sections = sections
}
}
}This code looks modern. It uses Task, it uses await, and the observable UI state sits on @MainActor. But the engineering problem is typical and subtle. The proposal on actor isolation inheritance makes it explicit that closures passed directly to Task { ... } inherit the current actor isolation when the surrounding context is isolated to a global actor. It also clarifies that nonisolated synchronous functions dynamically inherit caller isolation. In plain terms, inside a @MainActor view model, Task {} is not shorthand for “background work.” And moving sorting, chunking, or section-building into a plain synchronous helper does not automatically move that work off MainActor either.
That is why many real-world UI stalls are so confusing. Engineers think, “I already awaited the network request, so the expensive part should no longer be on the main actor.” But the costly work is often not the network wait. The costly part is everything after the await: sorting, grouping, formatting, transforming, and preparing renderable structures. If that CPU-heavy work still lives in the MainActor isolation domain, it competes with user input, layout, animations, and event handling on the same executor.
So the real fix is not asking whether the view model should be @MainActor. The real fix is asking what kind of state the view model actually owns.
A more stable split usually looks like this:
- The
ViewModelowns only UI state. - Fetching, decoding, and assembly live in a pipeline or repository.
- Shared mutable coordination state belongs to a dedicated actor.
For example:
struct FeedPipeline {
let api: FeedAPI
func loadSections() async throws -> [FeedSection] {
let response = try await api.fetchFeed()
return try await Task.detached(priority: .userInitiated) {
response.items
.sorted(by: FeedItem.sort)
.chunked(into: 20)
.map(FeedSection.init)
}.value
}
}
@MainActor
final class FeedViewModel: ObservableObject {
@Published private(set) var sections: [FeedSection] = []
@Published private(set) var isLoading = false
private let pipeline: FeedPipeline
init(pipeline: FeedPipeline) {
self.pipeline = pipeline
}
func reload() {
Task {
isLoading = true
defer { isLoading = false }
sections = try await pipeline.loadSections()
}
}
}The key improvement here is not the use of Task.detached as such. The real improvement is that “rendering state” and “heavy data assembly” are now separate responsibilities. The view model is no longer the center of data processing. It is responsible only for publishing UI state.
Many teams go one step further in the wrong direction: after marking the view model @MainActor, they also store pagination gates, request deduplication state, and inflight task tracking inside it. At that point, the correct move is usually not “more @MainActor.” It is usually a small actor with a very specific job:
actor PageGate<Page: Sendable> {
private var nextPage = 1
private var inflight: Task<Page, Error>?
func loadNext(
using operation: @Sendable @escaping (Int) async throws -> Page
) async throws -> Page {
if let inflight {
return try await inflight.value
}
let page = nextPage
let task = Task { try await operation(page) }
inflight = task
defer { inflight = nil }
let result = try await task.value
nextPage += 1
return result
}
}The value of this actor is not that it moved a Boolean somewhere else. The value is that it explicitly takes ownership of shared coordination state. And storing a Task instead of only an isLoading flag is not an implementation detail. It reflects the reality of actor reentrancy: during suspension, interleaving is allowed, so a plain Boolean often cannot fully represent “there is already inflight work and its result can be reused.”
This is one of the places where actors are genuinely valuable in engineering practice: use an actor to own coordination state, instead of letting coordination logic pile up on MainActor.
Scenario 2: Why UIKit UIViewControllers misclassify coordination state as UI state
UIKit has a slightly different failure mode.
In SwiftUI, the ViewModel at least has a natural claim to observable state. In UIKit, UIViewController often ends up owning four responsibilities at once: lifecycle, navigation, user-event handling, and data assembly. That makes it especially easy for a controller to grow into a seemingly reasonable but deeply confused @MainActor type.
Consider this pattern:
@MainActor
final class ArticlesViewController: UIViewController {
private let api: ArticlesAPI
private var nextPage = 1
private var isLoading = false
func refresh() {
Task {
guard !isLoading else { return }
isLoading = true
defer { isLoading = false }
let response = try await api.fetch(page: nextPage)
let snapshot = makeSnapshot(from: response)
dataSource.apply(snapshot, animatingDifferences: true)
nextPage += 1
}
}
}The biggest problem here is not merely that this may hurt responsiveness. The deeper problem is that the controller owns state it should not own. nextPage, isLoading, request reuse, retry policy, inflight coordination: those are not UI state. They are coordination state. They are shared, contested, serially managed business concerns. Leaving them inside a UIViewController effectively makes MainActor act as a request coordinator.
Because dataSource.apply(...) does belong to UI work, this kind of code often survives code review. Reviewers see that refresh() is a UI entry point and that the final application step is UI-bound, so keeping everything on MainActor can look superficially reasonable. But once the controller also owns pagination gates, request deduplication, and snapshot construction, it is no longer “just a UI object.”
A healthier structure is to let the controller receive a value that is already suitable for display, instead of letting it own the coordination state itself:
struct ArticlesScreenState: Sendable {
let sections: [ArticleSection]
let canLoadMore: Bool
}
actor ArticlesPager {
private var nextPage = 1
private var inflight: Task<PageResponse, Error>?
func loadNext(using api: ArticlesAPI) async throws -> PageResponse {
if let inflight {
return try await inflight.value
}
let page = nextPage
let task = Task { try await api.fetch(page: page) }
inflight = task
defer { inflight = nil }
let response = try await task.value
nextPage += 1
return response
}
}
struct ArticlesPipeline {
let api: ArticlesAPI
let pager: ArticlesPager
func nextScreenState() async throws -> ArticlesScreenState {
let response = try await pager.loadNext(using: api)
return try await Task.detached(priority: .userInitiated) {
ArticlesScreenState(
sections: response.items
.sorted(by: Article.sort)
.chunked(into: 20)
.map(ArticleSection.init),
canLoadMore: response.hasMore
)
}.value
}
}
@MainActor
final class ArticlesViewController: UIViewController {
private let pipeline: ArticlesPipeline
func refresh() {
Task { [weak self] in
guard let self else { return }
let state = try await pipeline.nextScreenState()
apply(state)
}
}
private func apply(_ state: ArticlesScreenState) {
dataSource.apply(makeSnapshot(from: state), animatingDifferences: true)
}
}This is not the only correct architecture. But it has a crucial engineering property: the controller’s role becomes simple again. It triggers work and applies results. It does not own pagination coordination. It does not own request deduplication. It does not perform expensive assembly work. Shared mutable coordination state belongs to ArticlesPager, a focused actor. Cross-isolation transfer is expressed as a Sendable value snapshot.
That is one of the best practical rules for UIKit:
A controller should own screen lifecycle. It should not own concurrency coordination state.
A frequently missed trap: moving expensive work into a helper does not move it off MainActor
This is one of the easiest traps to miss in real code.
Once teams realize that a view model or controller is too heavy, they often perform a refactor that looks sensible: move sorting, grouping, and snapshot building into helper functions such as ArticleAssembler.makeSections(...). Visually, the code looks cleaner. But very often, the execution boundary has not changed at all.
The actor isolation inheritance proposal is explicit that nonisolated synchronous functions dynamically inherit caller isolation. So if you call a plain synchronous helper from a @MainActor context, that helper still runs within the MainActor isolation domain.
For engineering work, this leads to a very important and very counterintuitive rule:
Extracting a function does not mean extracting work from MainActor.
If work is still synchronous and still called from a MainActor-isolated context, you have only moved lines of code around. You have not changed where that work executes. To truly leave MainActor, you need a real async boundary, or you need to explicitly route blocking synchronous work through a background execution path such as a detached task.
This explains many frustrating cases where a team says, “we already cleaned up the code, so why is scrolling still hitching?” The answer is often simple: frame drops do not care which file your helper lives in. They care which executor is doing the work.
MainActor.run and assumeIsolated are useful, but they are not architecture
Both APIs are valuable. Neither should become default patch tape.
MainActor.run is exactly what it sounds like: an explicit hop to run a closure on MainActor. It is good for narrow and intentional UI handoff points, such as committing the final result of background work back into UI state.
MainActor.assumeIsolated is heavier. It lets you assert that you are already running on MainActor’s serial executor, and it traps if that assumption is wrong. That makes it much closer to a bridging or migration tool than a general-purpose convenience. It is appropriate when you truly know, at runtime, that a synchronous callback is already on MainActor, but the type system does not currently express that fact.
So the practical distinction is simple:
runis a hopassumeIsolatedis an assertion- neither one is architecture
If you find yourself using them to patch over muddled protocol isolation, overloaded controllers, or view models that own too much coordination state, then you are treating symptoms, not causes.
A set of engineering rules that actually holds up
At this point, the article can be reduced to four rules that are consistently useful in production code.
1. @MainActor should mark ownership of UI state, not “code that might eventually touch UI”
As soon as a type owns both presentation state and concurrency coordination state, it is already growing in the wrong direction.
2. Put shared mutable coordination state in an actor; keep renderable results on MainActor
Pagination gates, inflight requests, deduplication, cache metadata, and retry bookkeeping are usually actor material. isLoading, sections, errorMessage, and other state that directly drives rendering are the right fit for MainActor.
3. Prefer passing Sendable value snapshots across isolation boundaries
The value of Sendable is not just satisfying the compiler. It pushes the design toward cleaner, easier-to-reason-about boundaries, where results are packaged as safe values instead of shared mutable references.
4. Type-level or protocol-level @MainActor should be treated as an architectural declaration
Because it propagates into conformances, subclasses, and extension methods, it is not a casual local fix. It is a decision about the isolation model of a subsystem.
Final takeaway
Mature concurrency code often converges on a similar shape:
- MainActor owns UI state
- actors own shared mutable coordination state
- pure transformation and heavy computation stay off MainActor whenever possible
- cross-boundary results are returned as
Sendablevalues
Once that structure is in place, many Swift Concurrency problems stop feeling mysterious. The compiler is no longer “being annoying.” It is forcing state ownership to become explicit.
And that is the real lesson behind misusing MainActor:
Actors solve ownership of mutable state. MainActor solves isolation of UI state. The moment you start using @MainActor as the default answer to concurrency warnings, you are making the isolation system absorb the cost of a design problem.