Swift async let, nonisolated, and Main-Thread Affinity
Swift concurrency gives you tools for running work in parallel, controlling actor isolation, and keeping UI updates on the right thread. This article explains how async let, nonisolated, and main-thread affinity fit together so you can write faster code without breaking safety rules.
Quick answer: Use async let when you want structured parallel work inside one scope, use nonisolated to opt a member out of actor isolation when it does not need actor state, and keep UI or MainActor-bound work on the main thread by awaiting it from the correct context.
Difficulty: Intermediate
You'll understand this better if you know: basic Swift functions, async/await, and the idea that actors protect shared state.
1. What Is async let, nonisolated, and Main-Thread Affinity?
These three ideas all belong to Swift concurrency, but they solve different problems.
- async let starts an asynchronous child task immediately and lets you await its value later.
- nonisolated marks an actor member as not needing that actor's isolation rules.
- Main-thread affinity means a value, method, or object should be used on the main thread, usually because it updates UI or other thread-bound state.
In practice, you often combine them: start several independent requests with async let, process pure helper logic in nonisolated code, and deliver results back to a MainActor-isolated context.
2. Why These Concepts Matter
Without these tools, concurrency code becomes either too slow or too risky. You might run everything serially and waste time, or you might accidentally touch UI state from the wrong context and get compiler errors or unstable behavior.
Swift's model is designed to make the safe path the easy path. async let helps you express parallelism without manually creating detached tasks. nonisolated helps you avoid unnecessary actor hops when a method only reads constant or independent data. Main-thread affinity protects code that must stay on the main executor, such as app state connected to user interfaces.
3. Basic Syntax or Core Idea
Here is the simplest shape of each concept.
async let syntax
You declare an async let binding inside an asynchronous context. The expression on the right begins running right away.
func loadProfile() async -> String { "Profile" }
func loadStats() async -> String { "Stats" }
func buildScreen() async {
async let profile = loadProfile()
async let stats = loadStats()
let profileText = await profile
let statsText = await stats
print(profileText, statsText)
}This shows the key idea: each child operation starts before you await it, so independent work can overlap.
nonisolated syntax
Inside an actor, nonisolated marks a member that can be called without hopping onto the actor.
actor CacheKeyBuilder {
let prefix = "user:"
nonisolated func key(for id: Int) -> String {
"user:\(id)"
}
}The method does not read isolated actor state, so it can run without actor isolation.
Main-thread affinity with MainActor
Swift often represents main-thread-bound code with MainActor rather than a raw thread check.
@MainActor
final class ViewModel {
var title = "Loading"
func updateTitle(to value: String) {
title = value
}
}When a type or method is isolated to MainActor, Swift expects it to be used in a main-thread-affine context.
4. Step-by-Step Examples
Example 1: Fetch two independent values in parallel with async let
If two operations do not depend on each other, start both immediately and await both results later.
func fetchUsername() async -> String {
"Ava"
}
func fetchBadgeCount() async -> Int {
12
}
func loadHeader() async -> String {
async let name = fetchUsername()
async let badges = fetchBadgeCount()
let username = await name
let count = await badges
return "\(username) has \(count) badges"
}This is a structured, readable way to run both requests concurrently.
Example 2: Use nonisolated for a pure helper on an actor
If a method only formats input and does not read actor state, it does not need isolation.
actor MessageFormatter {
nonisolated func normalize(text: String) -> String {
text.trimmingCharacters(in: .whitespacesAndNewlines)
}
func store(text: String) {
print(normalize(text: text))
}
}The helper can be called more freely because it is not tied to the actor's isolated state.
Example 3: Keep UI state on the main thread
Main-thread affinity is critical when updating values that drive the UI. A MainActor-isolated type makes that explicit.
@MainActor
final class ProfileViewModel {
var status = "Idle"
func load() async {
status = "Loading"
await Task.sleep(1_000_000_000)
status = "Finished"
}
}Because the type is main-actor isolated, its state changes stay consistent with UI expectations.
Example 4: Combine async let with a main-actor result handoff
Parallel work does not mean UI updates should happen off the main actor. Compute the results first, then assign them in a main-actor context.
@MainActor
final class DashboardViewModel {
var summary = ""
func refresh() async {
async let name = fetchUsername()
async let count = fetchBadgeCount()
let result = await "\(name) — \(count) items"
summary = result
}
}This pattern keeps the expensive work concurrent and the final mutation on the main actor.
5. Practical Use Cases
- Loading multiple independent network resources for a screen, such as a user profile, notifications, and recommendation counts.
- Creating actor methods that expose pure formatting or validation helpers with nonisolated.
- Building view models where the data fetch can happen off the main actor but state assignment must return to it.
- Precomputing derived strings, cache keys, or identifiers in actor types without paying isolation overhead.
- Refactoring code that previously used ad hoc task creation into structured concurrency with clearer ownership and cancellation.
6. Common Mistakes
Mistake 1: Assuming async let makes dependent work faster
async let is best for independent operations. If the second step needs the first result before it can start, there is no real parallelism to gain.
Problem: This code pretends the second request can start before the first finishes, but it actually depends on the user ID returned by the first call.
func fetchUserID() async -> Int { 42 }
func fetchDetails(for id: Int) async -> String { "User \(id)" }
func loadProfile() async -> String {
async let id = fetchUserID()
async let details = fetchDetails(for: await id)
return await details
}Fix: Await the dependency first, then start the work that needs it.
func loadProfile() async -> String {
let id = await fetchUserID()
return await fetchDetails(for: id)
}The corrected version works because it matches the actual dependency chain instead of pretending the calls are independent.
Mistake 2: Marking a method nonisolated while reading isolated state
A nonisolated member cannot access actor-isolated stored properties directly.
Problem: This actor method tries to read count, but nonisolated removes the protection that would make the access safe.
actor Counter {
var count = 0
nonisolated func description() -> String {
"Count: \(count)"
}
}Fix: Keep the method isolated, or expose only immutable data that can safely be used nonisolated.
actor Counter {
var count = 0
func description() -> String {
"Count: \(count)"
}
}The fixed version works because the method stays inside the actor's isolation domain.
Mistake 3: Updating UI state from a non-main context
Swift's concurrency checks often prevent this at compile time, especially with MainActor-isolated types.
Problem: This code mutates main-actor-bound state from a regular asynchronous function, which can trigger a compiler isolation error such as a message about actor-isolated property access from a nonisolated context.
@MainActor
final class StatusModel {
var message = "Ready"
}
func reload(model: StatusModel) async {
model.message = "Loading"
}Fix: Run the mutation inside a main-actor context, either by making the function main-actor isolated or by awaiting a main-actor hop.
@MainActor
func reload(model: StatusModel) async {
model.message = "Loading"
}The corrected version works because the mutation happens on the same actor that owns the state.
7. Best Practices
Use async let only for truly independent work
async let is most useful when child tasks can proceed without waiting for one another. That makes the code faster and easier to reason about.
func loadDashboard() async -> String {
async let name = fetchUsername()
async let badgeCount = fetchBadgeCount()
return await "\(name) / \(badgeCount)"
}When the operations are independent, this is cleaner than manually creating separate tasks.
Prefer nonisolated for pure, deterministic helpers
If a method reads no actor state and only transforms its inputs, nonisolated can reduce unnecessary actor hops.
actor SlugMaker {
nonisolated func slug(from text: String) -> String {
text.lowercased().replacingOccurrences(of: " ", with: "-")
}
}This keeps the actor available for truly isolated work.
Keep main-actor updates small and obvious
Only the code that needs the main actor should live there. Do expensive background work elsewhere, then return to the main actor for the final state change.
@MainActor
final class ReportViewModel {
var summary = ""
func refresh() async {
let computed = await makeReportSummary()
summary = computed
}
}This makes actor boundaries easier to audit and keeps UI code responsive.
8. Limitations and Edge Cases
- async let bindings must be awaited before the scope ends, or Swift will implicitly await them when the scope exits. That can surprise beginners who expect fire-and-forget behavior.
- nonisolated cannot freely access mutable actor state. It is mainly for constant data, pure helpers, and members that do not depend on isolation.
- Main-thread affinity is about actor isolation and execution context, not just a manual thread check. A value can be on a background thread and still be conceptually isolated to MainActor because Swift will schedule the access safely.
- Calling a MainActor-isolated API from nonisolated code often requires await. If you see an error about crossing actor boundaries, the fix is usually to make the caller async and hop correctly.
- Putting too much code on the main actor can hurt responsiveness. UI-bound state belongs there; expensive parsing, decoding, or image processing usually should not.
9. Practical Mini Project
Let's build a small profile summary loader that fetches two independent values in parallel, formats a cache key with a nonisolated helper, and updates a main-actor view model.
func fetchDisplayName() async -> String {
"Mira"
}
func fetchPoints() async -> Int {
180
}
actor KeyBuilder {
nonisolated func cacheKey(for userID: Int) -> String {
"profile:\(userID)"
}
}
@MainActor
final class ProfileSummaryViewModel {
var summary = "Loading..."
func refresh(userID: Int) async {
let builder = KeyBuilder()
async let name = fetchDisplayName()
async let points = fetchPoints()
let key = builder.cacheKey(for: userID)
let resolvedName = await name
let resolvedPoints = await points
summary = "\(resolvedName) has \(resolvedPoints) points (\(key))"
}
}This small example shows the three ideas working together: parallel fetches, nonisolated helper logic, and a final main-actor update.
10. Key Points
- async let starts structured child tasks immediately inside an async scope.
- nonisolated is for actor members that do not need isolated access to actor state.
- Main-thread affinity is usually expressed with MainActor, especially for UI-bound code.
- Independent work belongs in async let; dependent work should stay sequential.
- Use the main actor only for code that truly needs it, and keep expensive work elsewhere.
11. Practice Exercise
- Create an async function that fetches a username and a notification count in parallel.
- Add an actor with a nonisolated method that formats a stable cache key.
- Wrap the final result in a MainActor-isolated view model property named headline.
Expected output: A single string such as "Nora — 5 notifications", stored safely on the main actor.
Hint: Start both network-style calls with async let, await them before building the final string, and keep the actor helper pure.
Solution:
func fetchUsername() async -> String {
"Nora"
}
func fetchNotificationCount() async -> Int {
5
}
actor KeyMaker {
nonisolated func cacheKey(for userID: Int) -> String {
"user:\(userID)"
}
}
@MainActor
final class HeadlineModel {
var headline = ""
func refresh(userID: Int) async {
let maker = KeyMaker()
async let name = fetchUsername()
async let count = fetchNotificationCount()
let key = maker.cacheKey(for: userID)
let resolvedName = await name
let resolvedCount = await count
headline = "\(resolvedName) — \(resolvedCount) notifications [\(key)]"
}
}The solution works because each part is used in the role Swift expects: parallel work with async let, pure actor helper logic with nonisolated, and UI-bound state updates on MainActor.
12. Final Summary
async let, nonisolated, and main-thread affinity are three pieces of the same concurrency story. They help you express parallel work, reduce unnecessary actor isolation, and keep UI-facing code safe.
The most important habit is to match the tool to the job: use async let for independent child work, use nonisolated only for pure actor members, and keep state that drives the interface on the main actor. When you do that, your code stays fast, readable, and predictable.
If you want to go further, next study structured concurrency cancellation and actor reentrancy, because those details shape how these features behave in larger apps.