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.
- Sendable is a marker for values that are safe to transfer between tasks.
- @MainActor makes code run on the main actor, which is used for UI work and other main-thread-bound state.
- Global actors define a named isolation domain that multiple declarations can share.
- Isolation rules control what can be read, written, or called across actor boundaries.
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:
- protect shared mutable state from concurrent access
- keep UI updates on the main actor
- make async code easier to reason about
- document which APIs are safe to call from anywhere
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
- Passing network request models between async functions
- Keeping view models and UI state on the main actor
- Protecting database or cache access with a custom global actor
- Designing library APIs that may be called from multiple tasks
- Annotating closures, delegates, and helper types that must be safe under concurrency
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
- Sendable is about safe transfer, not automatic immutability of every object graph.
- Some types are only conditionally sendable when their generic parameters are sendable.
- Mutable classes usually need actor protection or explicit care before they can cross task boundaries.
- Passing closures across concurrency boundaries may require the closure and captured values to be sendable.
- @MainActor does not mean “runs on the UI thread forever”; it means code is isolated to the main actor and may require suspension to enter from elsewhere.
- Cross-actor calls are often async, so synchronous callers may need restructuring.
- Actor isolation is a compile-time model first, but it still reflects real runtime serialization rules.
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
- Sendable means a value can safely cross concurrency boundaries.
- @MainActor isolates code that must run with main-actor serialization.
- Global actors let you create named isolation domains for shared resources.
- Swift’s isolation rules prevent many data races at compile time.
- Actor boundaries usually require await when crossing them from other concurrency contexts.
11. Practice Exercise
- Create a Sendable struct named DownloadJob with a URL string and retry count.
- Create a @MainActor class named DownloadStatus with a text property.
- Write an async function that builds a DownloadJob, pretends to fetch a result, and updates the status on the main actor.
- Make sure no mutable shared state is accessed from outside its actor context.
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.