Swift Sendable, @MainActor, Global Actors, and Isolation Rules

Swift concurrency uses isolation rules to help you write code that is safe to use across tasks, actors, and threads. This article explains Sendable, @MainActor, global actors, and the rules Swift uses to decide when data and methods can cross concurrency boundaries.

Quick answer: Sendable marks values that can be safely shared across concurrency domains, @MainActor isolates code to the main actor, and global actors let you define your own shared isolation domain. Swift prevents unsafe cross-actor access unless the type and access pattern are known to be safe.

Difficulty: Intermediate

You'll understand this better if you know: basic Swift types, functions, closures, and the idea that concurrency means more than one task may run at the same time.

1. What Is Sendable, @MainActor, Global Actors, and Isolation Rules?

These features are part of Swift’s concurrency safety model. Together, they tell the compiler which values may cross task boundaries and which pieces of code must stay isolated to one execution context.

In practice, these rules prevent data races and make concurrent code more predictable.

2. Why These Rules Matter

Without isolation rules, two tasks could mutate the same object at the same time and produce hard-to-reproduce bugs. Swift uses compile-time checks to catch many of those mistakes before they ship.

These rules matter because they help you:

They also matter for library design. If you build a type or API that will be used from async code, the right isolation annotations help other developers use it correctly.

3. Basic Syntax or Core Idea

The core idea is that Swift tracks where code is isolated and whether values can move safely across those boundaries.

Sendable basics

A type is Sendable when it can be safely used across concurrency domains. Many value types are automatically sendable when all of their stored properties are sendable.

struct UserProfile: Sendable {
    let id: Int
    let name: String
}

This type is safe to pass between tasks because its data is immutable and its stored properties are sendable.

Main actor basics

Annotating a declaration with @MainActor isolates it to the main actor.

@MainActor
final class ProfileViewModel {
    var title = "Loading..."

    func updateTitle(_ newTitle: String) {
        title = newTitle
    }
}

Any code that touches this type must respect main-actor isolation.

Global actor basics

A custom global actor lets you create a shared isolation domain for related APIs.

@globalActor
actor ImageProcessingActor {
    static let shared = ImageProcessingActor()
}

You can then annotate properties, methods, or types with that actor to keep them isolated together.

4. Step-by-Step Examples

Example 1: Passing a sendable value into a task

Immutable value types are the easiest place to start. Here, a task receives a simple configuration value that can be shared safely.

struct RequestConfig: Sendable {
    let endpoint: String
    let timeoutSeconds: Int
}

func loadData(config: RequestConfig) async {
    let message = "Loading from \(config.endpoint)"
    print(message)
}

This works because the configuration value can safely move into asynchronous work.

Example 2: Updating main-actor state from async code

When you need to update UI-related state or other main-actor-isolated data, you must enter the main actor explicitly.

@MainActor
final class CounterModel {
    var count = 0

    func increment() {
        count += 1
    }
}

func refreshCount(model: CounterModel) async {
    await MainActor.run {
        model.increment()
    }
}

Here, the state change happens on the correct actor, which avoids cross-actor mutation errors.

Example 3: A custom global actor for shared background work

If several functions must not run concurrently with each other, a global actor can group them.

@globalActor
actor DatabaseActor {
    static let shared = DatabaseActor()
}

@DatabaseActor
func saveRecord(_ name: String) {
    print("Saving \(name)")
}

Any code isolated to DatabaseActor will be serialized through that same shared actor.

Example 4: Capturing non-sendable state in a task

Not every capture is safe. If a closure passed to a concurrent task captures mutable, non-sendable state, Swift may reject it.

final class Cache {
    var items: [String] = []
}

let cache = Cache()

Task {
    cache.items.append("A")
}

This kind of code can trigger concurrency warnings or errors because the task may run concurrently with other accesses to the same object.

5. Practical Use Cases

These patterns appear often in app code, especially when moving from synchronous code to async / await.

6. Common Mistakes

Mistake 1: Treating every class as Sendable

Reference types are not automatically safe just because they are familiar. A class with mutable shared state is often not sendable unless you protect its access carefully.

Problem: This class can be mutated from more than one task at the same time, so the compiler may warn that it is not sendable.

final class SessionStore: Sendable {
    var token: String = ""
}

Fix: Make the state immutable, move the mutable state behind an actor, or remove the sendable claim if it is not true.

actor SessionStore {
    var token: String = ""
}

The corrected version works because the actor serializes access to the mutable value.

Mistake 2: Calling main-actor code from a background context directly

Code isolated to @MainActor cannot be used freely from a background task without crossing the actor boundary.

Problem: This direct call can produce a concurrency error such as “call to main actor-isolated instance method in a synchronous nonisolated context.”

@MainActor
final class DashboardModel {
    func reload() {
        print("Reloaded")
    }
}

func performWork(model: DashboardModel) {
    model.reload()
}

Fix: Make the caller async and hop to the main actor before calling isolated members.

@MainActor
final class DashboardModel {
    func reload() {
        print("Reloaded")
    }
}

func performWork(model: DashboardModel) async {
    await MainActor.run {
        model.reload()
    }
}

The fix works because the call now happens in the correct isolation domain.

Mistake 3: Assuming a global actor makes data automatically thread-safe

A global actor serializes access to code isolated to that actor, but it does not magically make unrelated shared data safe.

Problem: If you store shared mutable state outside the actor and access it from multiple tasks, you can still create a race.

@globalActor
actor LogActor {
    static let shared = LogActor()
}

var messages: [String] = []

@LogActor
func addMessage(_ text: String) {
    messages.append(text)
}

Fix: Keep the mutable state inside the actor or make access go through isolated methods.

@globalActor
actor LogActor {
    static let shared = LogActor()
    var messages: [String] = []

    func addMessage(_ text: String) {
        messages.append(text)
    }
}

The corrected version keeps the shared mutable state under the actor’s control.

7. Best Practices

Practice 1: Prefer immutable data for values that cross tasks

Immutable stored properties are easier for the compiler to reason about and easier for you to trust.

struct Payload: Sendable {
    let id: String
    let count: Int
}

Using immutable data reduces the chance of accidental sharing bugs.

Practice 2: Put UI-facing state behind the main actor

When a type represents presentation state, isolate the whole type to the main actor instead of hopping in and out repeatedly.

@MainActor
final class SettingsViewModel {
    var isEnabled = false

    func toggle() {
        isEnabled.toggle()
    }
}

This keeps view-related mutation consistent and avoids scattered actor hops.

Practice 3: Use a custom global actor for one shared resource domain

If several APIs must serialize access to the same resource, a global actor makes that rule obvious.

@globalActor
actor ImageCacheActor {
    static let shared = ImageCacheActor()
}

That can be clearer than spreading ad hoc synchronization across multiple files.

8. Limitations and Edge Cases

One common “not working” scenario is code that used to compile in older Swift versions but now triggers stricter concurrency warnings. That usually means the compiler has learned to enforce a rule that was already unsafe before.

9. Practical Mini Project

Let’s build a tiny profile loader that keeps network data sendable and UI state on the main actor. This combines the three ideas in one realistic flow.

struct ProfileData: Sendable {
    let name: String
    let headline: String
}

@MainActor
final class ProfileViewModel {
    var displayName = ""
    var statusText = "Idle"

    func apply(_ profile: ProfileData) {
        displayName = profile.name
        statusText = profile.headline
    }
}

func fetchProfile() async -> ProfileData {
    return ProfileData(name: "Ava", headline: "Ready to build")
}

func loadAndDisplayProfile(viewModel: ProfileViewModel) async {
    let profile = await fetchProfile()
    await MainActor.run {
        viewModel.apply(profile)
    }
}

This example works because the fetched value is sendable and the view model updates happen on the main actor.

10. Key Points

11. Practice Exercise

Expected output: A short example that compiles and updates the status string after the async work finishes.

Hint: Keep the transfer object immutable, and use MainActor.run for the final UI-like update.

struct DownloadJob: Sendable {
    let urlString: String
    let retryCount: Int
}

@MainActor
final class DownloadStatus {
    var text = "Idle"
}

func performDownload(status: DownloadStatus) async {
    let job = DownloadJob(urlString: "https://example.com/file", retryCount: 3)

    _ = job

    await MainActor.run {
        status.text = "Download finished"
    }
}

12. Final Summary

Sendable, @MainActor, global actors, and Swift isolation rules work together to keep concurrent code safe. They help the compiler catch data races, enforce where code runs, and make shared state explicit.

For everyday Swift, the most useful habit is to make data immutable by default, isolate UI work to the main actor, and move shared mutable state behind an actor instead of sharing it directly. When you follow those rules, your code becomes easier to reason about and much harder to break under concurrency.

If you want to go further, the next topic to study is actor isolation and cross-actor references in more depth, especially how async calls are formed and when Swift requires explicit hops between actors.