Fucking Approachable
Swift Concurrency
A no-bullshit guide to understanding async/await, actors, and Sendable. No jargon. Just clear mental models.
In the tradition of fuckingblocksyntax.com and fuckingifcaseletsyntax.com
The One Thing You Need to Understand
The Core Mental Model
Isolation is the key to everything. It's Swift's answer to the question: "Who is allowed to touch this data right now?"
Think of isolation like rooms in a house. Each room (isolation domain) can only have one person working in it at a time. If you want to share something between rooms, you either need to:
- Make a copy (Sendable values)
- Pass it through a secure handoff (actors)
Swift concurrency is not about threads. Stop thinking about threads. Start thinking about where your code runs and who owns the data.
The Two Types of Data
Swift divides ALL data into two groups:
| Type | What it means | Examples |
|---|---|---|
| Sendable | Safe to share across isolation boundaries | Integers, Strings, Structs with Sendable properties |
| Non-Sendable | Must stay in one isolation domain | UIView, most classes with mutable state |
That's it. Everything else in Swift concurrency flows from this simple division.
Async/Await: It's Not What You Think
Common Misconception
"Adding async makes my code run in the background."
Wrong. The async keyword just means the function can pause. It says nothing about where it runs.
The Cafe Analogy
Imagine a cafe with one barista:
Without async/await:
- Customer orders
- Barista makes drink (everyone waits)
- Barista serves drink
- Next customer orders
With async/await:
- Customer orders
- Barista starts espresso machine
- While machine runs, barista takes next order
- Machine beeps, barista finishes first drink
- Continues with next order
The barista isn't cloned (no new threads). They're just not standing idle while waiting.
Suspension vs Blocking
This is the key insight:
// BLOCKING - Thread sits idle, doing nothing
Thread.sleep(forTimeInterval: 5) // Bad!
// SUSPENSION - Thread is freed to do other work
try await Task.sleep(for: .seconds(5)) // Good!
Think of it this way
Blocking = Sitting in the doctor's waiting room staring at the wall.
Suspension = Leaving your phone number and running errands. They'll call when ready.
The Pizza Ordering Pattern
func makeDinner() async {
let pizza = await orderPizza() // Pause here, don't block
let salad = await makeSalad() // Pause here too
serve(pizza, salad)
}
The code reads top-to-bottom, but executes with pauses. No callback hell. No nested closures.
Actors: The Security Guards
An actor is like a security guard standing in front of your data. Only one visitor allowed at a time.
actor BankAccount {
var balance: Double = 0
func deposit(_ amount: Double) {
balance += amount // Safe! Only one caller at a time
}
}
Without actors: Two threads read balance = 100, both add 50, both write 150. You lost $50.
With actors: Swift automatically queues access. Both deposits complete correctly.
When Should You Use an Actor?
Don't overuse actors!
According to Matt Massicotte, you need an actor only when ALL FOUR conditions are met:
- You have non-Sendable state
- Multiple places need to access that state
- Operations must be atomic
- It can't just live on MainActor
If ANY condition is false, you probably don't need an actor.
MainActor: The Special One
@MainActor is a special actor that runs on the main thread. Use it for UI code.
@MainActor
class ViewModel: ObservableObject {
@Published var data: [Item] = []
func loadData() async {
let items = await fetchItems() // May suspend
self.data = items // Guaranteed back on main thread
}
}
Practical advice
For most apps, @MainActor is your best friend. Matt Massicotte recommends putting it on:
- ViewModels
- Any class that touches UI
- Singletons that need thread-safe access
Performance concerns are usually overblown. Start with @MainActor, optimize only if you measure actual problems.
Sendable: The Thread-Safety Certificate
Sendable is Swift's way of saying "this type is safe to share between isolation domains."
Automatically Sendable
These are Sendable without any work:
- Value types (structs, enums) with Sendable properties
- Actors (they protect themselves)
- Immutable classes (
final classwith onlyletproperties)
Not Sendable
These require thought:
- Mutable classes
- Closures that capture mutable state
// Automatically Sendable
struct Point: Sendable {
let x: Int
let y: Int
}
// NOT Sendable - has mutable state
class Counter {
var count = 0 // Danger zone!
}
The Non-Sendable First Design
Matt Massicotte advocates starting with regular, non-isolated, non-Sendable types. Add isolation only when you need it.
This isn't laziness - it's strategic simplicity. A non-Sendable type:
- Stays simple
- Works synchronously from any actor that owns it
- Avoids protocol conformance headaches
When the Compiler Complains
If Swift says something isn't Sendable, you have options:
- Make it a value type (struct instead of class)
- Isolate it (
@MainActor) - Keep it non-Sendable and don't cross boundaries
- Last resort:
@unchecked Sendable(you're promising it's safe)
Patterns That Work
The Network Request Pattern
@MainActor
class ViewModel: ObservableObject {
@Published var users: [User] = []
@Published var isLoading = false
func fetchUsers() async {
isLoading = true
// This suspends - thread is free to do other work
let users = await networkService.getUsers()
// Back on MainActor automatically
self.users = users
isLoading = false
}
}
No DispatchQueue.main.async. The @MainActor attribute handles it.
Parallel Work with async let
func loadProfile() async -> Profile {
async let avatar = loadImage("avatar.jpg")
async let banner = loadImage("banner.jpg")
async let details = loadUserDetails()
// All three run in parallel!
return Profile(
avatar: await avatar,
banner: await banner,
details: await details
)
}
Preventing Double-Taps
This pattern comes from Matt Massicotte's guide on stateful systems:
@MainActor
class ButtonViewModel {
private var isLoading = false
func buttonTapped() {
// Guard SYNCHRONOUSLY before any async work
guard !isLoading else { return }
isLoading = true
Task {
await doExpensiveWork()
isLoading = false
}
}
}
Critical: The guard must be synchronous
If you put the guard inside the Task after an await, there's a window where two button taps can both start work. Learn more about ordering and concurrency.
Common Mistakes to Avoid
These are common mistakes that even experienced developers make:
Mistake 1: Thinking async = background
// This STILL blocks the main thread!
@MainActor
func slowFunction() async {
let result = expensiveCalculation() // Synchronous = blocking
data = result
}
// Fix: Move work off MainActor
func slowFunction() async {
let result = await Task.detached {
expensiveCalculation()
}.value
await MainActor.run { data = result }
}
Mistake 2: Actors everywhere
Don't create an actor for everything. Too many actors = too many isolation boundaries = slow code.
// Over-engineered
actor NetworkManager { }
actor CacheManager { }
actor DataManager { }
// Every call hops between actors!
// Better: One actor or MainActor for most things
@MainActor
class AppState { }
Mistake 3: MainActor.run everywhere
This is a problematic pattern:
// Don't do this
await MainActor.run { doMainActorStuff() }
// Do this instead
@MainActor func doMainActorStuff() { }
Let isolation be part of the function signature, not scattered through your code.
Mistake 4: Making everything Sendable
Not everything needs to be Sendable. If you're adding @unchecked Sendable everywhere, you're probably creating too many isolation boundaries.
Mistake 5: Ignoring compiler warnings
Every Sendable warning is pointing to a potential data race. Don't suppress them - understand them. Enable complete concurrency checking to learn how Swift concurrency actually works.
The Mental Model Cheat Sheet
Async/Await
What it is: Pause and resume without blocking.
Mental model: "I'll leave my number, call me when ready."
Key insight: async doesn't mean background.
Actors
What it is: A security guard for your data.
Mental model: "One visitor at a time."
Key insight: Don't overuse them.
MainActor
What it is: The main thread, but type-safe.
Mental model: "UI code lives here."
Key insight: Use it more than you think.
Sendable
What it is: A certificate saying "safe to share."
Mental model: "Can I hand this to another room?"
Key insight: Start non-Sendable, add only when needed.
Three Levels of Swift Concurrency
You don't need to learn everything at once. Progress through these levels:
Level 1: Basic Async (Start Here)
- Use
async/awaitfor network calls - Mark UI classes with
@MainActor - Use SwiftUI's
.taskmodifier
This handles 80% of apps.
Level 2: Structured Concurrency
- Use
async letfor parallel work - Use
TaskGroupfor dynamic parallelism - Understand task cancellation
For when you need performance.
Level 3: Advanced Safety
- Create custom actors for shared state
- Deep understanding of Sendable
- Custom executors
For library authors and complex systems.
Start simple
Most apps never need Level 3. Don't over-engineer.
Quick Reference
Making Things Work on Main Thread
// Entire type on main thread
@MainActor
class MyViewModel { }
// Single function on main thread
@MainActor
func updateUI() { }
// One-off main thread work (rarely needed)
await MainActor.run {
// UI updates here
}
Running Work in Parallel
// Fixed number of parallel tasks
async let a = fetchA()
async let b = fetchB()
let results = await (a, b)
// Dynamic number of parallel tasks
await withTaskGroup(of: Item.self) { group in
for id in ids {
group.addTask { await fetch(id) }
}
for await item in group {
process(item)
}
}
Making Types Sendable
// Value types are usually Sendable automatically
struct MyData: Sendable {
let id: Int
let name: String
}
// Actors are Sendable (they protect themselves)
actor MyActor { }
// Classes need work - consider if you really need this
final class MyClass: Sendable {
let immutableValue: Int // Must be let, not var
}
Further Reading
This guide distills the best resources on Swift concurrency.
Matt Massicotte's Blog (Highly Recommended)
- A Swift Concurrency Glossary - Essential terminology
- An Introduction to Isolation - The core concept
- When should you use an actor? - Practical guidance
- Non-Sendable types are cool too - Why simpler is better
- Crossing the Boundary - Working with non-Sendable types
- Problematic Swift Concurrency Patterns - What to avoid
- Making Mistakes with Swift Concurrency - Learning from errors