Concurrency in SwiftUI: The Three Mental Models Every Developer Needs
You have read every blog post about async/await, you understand actors, and you can explain Sendable at a
whiteboard. Yet your SwiftUI app still hangs, still triggers purple runtime warnings, and still crashes in ways that
feel random. The gap is not in your concurrency knowledge — it is in your mental model of where SwiftUI runs your
code.
This post gives you three mental models that explain how concurrency actually works inside SwiftUI: which code is
main-actor-isolated, which code is explicitly not, and how to bridge the two safely. We will not rehash
async/await basics or actor fundamentals — those are covered in
Using async/await in SwiftUI and Actors in Swift.
Contents
- The Problem
- Mental Model 1: The View Body Contract
- Mental Model 2: The Off-Main-Thread Exceptions
- Mental Model 3: Bridging Sync UI to Async Work
- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
The Problem
Consider this innocent-looking view. You fetch a list of Pixar movies, compute an expensive sort, and display the results. Everything compiles with zero warnings under Swift 6:
struct MovieListView: View {
@State private var movies: [Movie] = []
@State private var isLoading = false
var body: some View {
List(movies) { movie in
MovieRow(movie: movie)
}
.task {
isLoading = true
let fetched = await MovieService.fetchAll()
// Expensive sort — runs on... which thread?
let sorted = fetched.sorted {
$0.releaseDate < $1.releaseDate
}
movies = sorted
isLoading = false
}
}
}
Where does that sorted call execute? On the main thread? Off it? The answer depends on your Swift language version,
your concurrency settings, and whether you adopted Swift 6.2’s default main actor isolation. If you cannot answer that
question confidently, you are making threading decisions by accident.
The real cost shows up when that sort takes 200ms on a large dataset. If it runs on the main actor, your scroll view
hitches. If you force it off the main actor without thinking, you introduce a data race on movies. Neither outcome is
acceptable, and the compiler will not save you from the performance variant.
Mental Model 1: The View Body Contract
The first mental model is the simplest and the most important: View.body is always @MainActor.
This is not a convention or a best practice. The View protocol’s body requirement is explicitly annotated with
@MainActor. Every line of code inside your body computed property — every if statement, every ForEach closure,
every inline computation — runs on the main actor. Period.
struct PixarDashboard: View {
@State private var selectedStudio = "Pixar"
@State private var films: [Film] = []
// Everything here is @MainActor-isolated
var body: some View {
NavigationStack {
VStack {
// This string interpolation? Main actor.
Text("Welcome to \(selectedStudio)")
// This filtering closure? Main actor.
let animated = films.filter { $0.isAnimated }
ForEach(animated) { film in
FilmRow(film: film)
}
}
}
}
}
This means every computation you inline into body blocks the main thread. SwiftUI calls body frequently — on state
changes, on geometry changes, on trait collection updates. If your body getter takes 5ms, your frame budget is gone.
What inherits main actor isolation
Understanding inheritance is where most developers trip up. These all inherit @MainActor from the View protocol
conformance:
- The
bodyproperty itself - Computed properties called from
body - Helper methods called from
body(unless explicitly markednonisolated) - View modifier closures like
.overlay { ... }and.background { ... } @State,@Binding, and@Environmentproperty access
struct ToyBoxView: View {
@State private var toys: [Toy] = []
// @MainActor because ToyBoxView conforms to View
var favoriteCount: Int {
toys.filter { $0.isFavorite }.count
}
var body: some View {
Text("\(favoriteCount) favorites")
}
}
Tip: If you find yourself doing expensive filtering or sorting in a computed property called from
body, that is your signal to move the work into a.taskmodifier or a dedicated model object.
Mental Model 2: The Off-Main-Thread Exceptions
Here is where the mental model gets interesting. While body is always @MainActor, several SwiftUI callbacks are
explicitly nonisolated or run outside the main actor. These are the pressure relief valves SwiftUI provides for
computationally expensive work.
Shape.path(in:) is nonisolated
When you conform to Shape, your path(in:) method does not run on the main actor. SwiftUI may call it on any thread,
and it may call it concurrently for different proposed sizes:
struct WavyLine: Shape {
var amplitude: CGFloat
var frequency: CGFloat
// This is nonisolated — it can run on any thread
func path(in rect: CGRect) -> Path {
var path = Path()
let width = rect.width
let midY = rect.midY
path.move(to: CGPoint(x: 0, y: midY))
for x in stride(from: 0, through: width, by: 1) {
let relativeX = x / width
let sine = sin(relativeX * frequency * .pi * 2)
let y = midY + amplitude * sine
path.addLine(to: CGPoint(x: x, y: y))
}
return path
}
}
Because path(in:) is nonisolated, you cannot access @State properties or any main-actor-isolated state from within
it. The compiler enforces this. The parameters you receive — amplitude, frequency — must be Sendable values
copied into the shape.
Layout protocol methods
The Layout protocol’s sizeThatFits and placeSubviews
methods are nonisolated. SwiftUI’s layout engine may run these on a background thread:
struct PixarGridLayout: Layout {
var spacing: CGFloat
// Nonisolated — runs off the main actor
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
// Cannot access @State or @MainActor properties here
let sizes = subviews.map {
$0.sizeThatFits(.unspecified)
}
let totalWidth = sizes.reduce(0) {
$0 + $1.width + spacing
}
let maxHeight = sizes.map(\.height).max() ?? 0
return CGSize(width: totalWidth, height: maxHeight)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
var x = bounds.minX
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
subview.place(
at: CGPoint(x: x, y: bounds.minY),
proposal: .unspecified
)
x += size.width + spacing
}
}
}
visualEffect modifier
The visualEffect modifier’s closure
receives a GeometryProxy and runs off the main actor. This is by design — visual effects may be evaluated per-frame
during animations:
struct FloatingPosterView: View {
var body: some View {
Image("ratatouille-poster")
.visualEffect { content, proxy in
// This closure is nonisolated
let frame = proxy.frame(in: .global)
let midY = frame.midY
let screenHeight = proxy.size.height * 3 // Approximate visible area
// Pure geometry math — no state access allowed
let distance = abs(midY - screenHeight / 2)
let scale = max(0.8, 1 - distance / 1000)
content
.scaleEffect(scale)
.opacity(Double(scale))
}
}
}
Warning: If you try to capture and mutate a
@Stateproperty inside avisualEffectclosure, the compiler will stop you. But if you capture a reference type that is notSendable, you may get a runtime data race. Treat these closures as pure functions that transform geometry into visual output.
The common thread
These exceptions share a pattern: they are pure computation callbacks. SwiftUI gives them geometry or layout proposals and expects geometry or paths back. There is no state mutation, no side effects, no UI updates. SwiftUI runs them off the main actor precisely because they should not need main-actor-isolated state.
Mental Model 3: Bridging Sync UI to Async Work
The third mental model addresses the most common real-world scenario: your UI is synchronous and main-actor-isolated, but the work it triggers is asynchronous and should not block the main thread.
The .task modifier is your primary bridge
The .task modifier is the canonical way
to launch async work from a SwiftUI view. Its behavior has a critical subtlety that trips up many developers:
Under Swift 6.2 with default main actor isolation (the MainActorIsolatedByDefault upcoming feature or the Swift 6.2
default), the .task closure inherits @MainActor isolation. This means your async code runs on the main actor unless
you explicitly opt out:
struct MovieDetailView: View {
let movieId: String
@State private var movie: Movie?
@State private var isLoading = false
var body: some View {
Group {
if let movie {
MovieContent(movie: movie)
} else if isLoading {
ProgressView("Loading \(movieId)...")
}
}
.task {
// Under Swift 6.2 defaults, this entire closure
// is @MainActor-isolated
isLoading = true
// The await suspends, but resumes on the main actor
let result = await MovieService.fetch(id: movieId)
// Safe to update @State — we are on the main actor
movie = result
isLoading = false
}
}
}
This is the correct pattern for straightforward data fetching. The network call itself runs on whatever executor
MovieService.fetch uses internally, and when it returns, you resume on the main actor.
Moving expensive work off the main actor
When you need to do CPU-intensive work — parsing a large JSON response, sorting thousands of Pixar movie records,
processing image data — you need to explicitly move that work off the main actor. Use a @concurrent function or a
detached task with clear boundaries:
struct StudioCatalogView: View {
@State private var catalog: [Film] = []
var body: some View {
List(catalog) { film in
FilmRow(film: film)
}
.task {
// Fetch returns raw data — fine on main actor
let rawFilms = await FilmService.fetchCatalog()
// Heavy processing — move it off the main actor
let processed = await processFilms(rawFilms)
// Back on main actor for the state update
catalog = processed
}
}
}
// Explicitly concurrent — runs off the main actor
@concurrent
func processFilms(_ films: [Film]) async -> [Film] {
// Sort, filter, deduplicate, compute derived properties
films
.filter { $0.studio == "Pixar" }
.sorted { $0.releaseDate < $1.releaseDate }
.map { film in
var enriched = film
enriched.ageInYears = Calendar.current
.dateComponents(
[.year],
from: film.releaseDate,
to: .now
)
.year ?? 0
return enriched
}
}
The @concurrent attribute (introduced in Swift 6.2) tells the compiler this function should run on a background
executor even when called from a main-actor-isolated context. This is the recommended pattern for offloading work in
Swift 6.2 and later.
Apple Docs:
@concurrent— Swift 6.2 |View.task(priority:_:)— SwiftUI
The anti-pattern: Task.detached for UI work
You will see code that uses Task.detached to escape main actor isolation. While this works, it is a blunt instrument
that opts you out of structured concurrency. Prefer @concurrent functions:
// ❌ Avoid: Task.detached breaks structured concurrency
.task {
let films = await Task.detached {
await FilmService.fetchAndProcess()
}.value
catalog = films
}
// ✅ Prefer: @concurrent preserves structure
.task {
let films = await fetchAndProcessFilms()
catalog = films
}
Task.detached does not inherit the parent task’s priority, does not participate in cooperative cancellation by
default, and makes it harder to reason about which actor you are on when you return.
Advanced Usage
Combining models in a single view
Real views often exercise all three mental models simultaneously. Here is a more complete example — a Pixar movie browser with a custom layout, visual effects, and async data loading:
struct PixarBrowserView: View {
@State private var films: [Film] = []
@State private var searchText = ""
// Model 1: body is @MainActor — keep it lightweight
var body: some View {
ScrollView {
// Model 2: sizeThatFits runs off main actor
PixarGridLayout(spacing: 12) {
ForEach(filteredFilms) { film in
FilmPosterView(film: film)
// Model 2: visualEffect is nonisolated
.visualEffect { content, proxy in
let scale = scrollScale(for: proxy)
content.scaleEffect(scale)
}
}
}
}
.searchable(text: $searchText)
// Model 3: .task bridges to async work
.task {
let raw = await FilmService.fetchAll()
let processed = await sortAndEnrich(raw)
films = processed
}
}
// Inherits @MainActor — called from body
private var filteredFilms: [Film] {
guard !searchText.isEmpty else { return films }
return films.filter {
$0.title.localizedCaseInsensitiveContains(searchText)
}
}
}
// Nonisolated helper — pure geometry computation
nonisolated func scrollScale(
for proxy: GeometryProxy
) -> CGFloat {
let frame = proxy.frame(in: .scrollView)
let midY = frame.midY
// Use scroll view coordinate space instead of deprecated UIScreen.main
let visibleHeight = proxy.size.height * 3
let distance = abs(midY - visibleHeight / 2)
return max(0.85, 1 - distance / 2000)
}
nonisolated methods on your View
Sometimes you need a helper method on your View struct that should not be main-actor-isolated. Mark it nonisolated
explicitly:
struct RenderProgressView: View {
@State private var progress: Double = 0
var body: some View {
ProgressView(value: progress)
.task {
for await p in RenderEngine.progressStream() {
progress = p
}
}
}
// Explicitly nonisolated — safe to call from any context
nonisolated func formatProgress(
_ value: Double
) -> String {
String(format: "%.1f%%", value * 100)
}
}
Note: A
nonisolatedmethod on aViewcannot access@State,@Binding, or any other main-actor-isolated property. If you need the property’s value, pass it as a parameter.
Handling cancellation in .task
The .task modifier automatically cancels its task when the view disappears. This is structured concurrency at work,
but you need to cooperate with cancellation in your async code:
struct MovieStreamView: View {
@State private var frames: [FrameData] = []
var body: some View {
Canvas { context, size in
for frame in frames {
context.draw(
frame.image,
in: frame.rect(in: size)
)
}
}
.task {
do {
// Stops automatically when view disappears
for try await frame in RenderPipeline.stream() {
try Task.checkCancellation()
let processed = await processFrame(frame)
frames.append(processed)
}
} catch is CancellationError {
// Expected on view disappear — clean exit
} catch {
// Handle actual errors
}
}
}
}
Performance Considerations
Understanding these mental models is not just about correctness — it directly affects your app’s performance.
Measuring main actor time
Use Instruments’ Time Profiler with the “Main Thread” filter to see how much time your body evaluations consume.
In Xcode 26, the SwiftUI Instruments template includes a dedicated “View Body” instrument that shows exactly which
views are being re-evaluated and how long each takes.
A healthy SwiftUI app keeps each body evaluation under 1ms. If you see evaluations taking 5ms or more, check for:
- Expensive computed properties called from
body - Large array operations (filtering, sorting, mapping) inline in
body - Date or number formatting without caching the formatter
The cost of actor hops
Every time you cross an actor boundary — from nonisolated code back to @MainActor — there is a context switch cost.
This is typically microseconds, but it adds up in tight loops:
// ❌ Expensive: one actor hop per film
.task {
for film in rawFilms {
let processed = await processOneFilm(film)
films.append(processed) // hop back to main actor
}
}
// ✅ Efficient: one actor hop for the entire batch
.task {
let processed = await processAllFilms(rawFilms)
films = processed // single hop back
}
Batch your off-main-actor work. Send a collection out, get a collection back. Minimize the number of actor boundary crossings.
Layout protocol performance
Because sizeThatFits and placeSubviews run off the main actor, they can execute concurrently with rendering.
However, they are called frequently — potentially every frame during animations. Keep these methods allocation-free
when possible. Avoid creating temporary arrays or strings inside layout methods on hot paths.
Apple Docs:
Layout— SwiftUI
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| Simple data fetch | Use .task directly. The await suspends without blocking. |
| CPU-heavy processing | Move work into a @concurrent function. Keep .task as the bridge. |
| Complex path math | Rely on Shape.path(in:) being nonisolated. Do not access @State. |
| Real-time visual effects | Use visualEffect for per-frame geometry transforms. Keep it pure. |
| Custom layout algorithms | Implement the Layout protocol for off-main-actor computation. |
| Streaming data | Use .task with for await over an AsyncSequence. Auto-cancels. |
| Fire-and-forget work | Prefer .task over Task { } in onAppear for lifecycle safety. |
The overarching principle: let SwiftUI’s built-in concurrency contracts do the work. The framework has already
decided which code is main-actor and which is not. Your job is to understand those decisions, respect them, and use
@concurrent functions when you need to move expensive computation off the main thread.
Summary
- Mental Model 1:
View.bodyand everything it calls is@MainActor-isolated. Keepbodyevaluations fast — under 1ms — by moving computation out of the render path. - Mental Model 2:
Shape.path(in:),Layoutprotocol methods, andvisualEffectclosures are explicitly nonisolated. They run off the main actor and cannot access@State. Treat them as pure functions. - Mental Model 3:
.taskis the bridge between synchronous, main-actor-isolated UI and asynchronous work. Under Swift 6.2 defaults,.taskclosures inherit@MainActor. Use@concurrentfunctions to move expensive work off the main actor explicitly. - Batch your actor hops. Send collections across actor boundaries, not individual items. Each boundary crossing has a real cost.
- The compiler catches correctness bugs but not performance bugs. You still need Instruments to find main-thread
bottlenecks caused by doing too much work in
body.
For a deeper look at Swift 6.2’s @concurrent attribute and the shift to default main actor isolation, read
Swift 6.2 Approachable Concurrency. If you want to profile and fix the
performance issues these mental models help you avoid, check out
SwiftUI Performance.