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.

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

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

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

11. Practice Exercise

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.