swift-concurrency
Swift Structured Concurrency
async/await Fundamentals
Mark functions that perform asynchronous work with async. Use throws alongside async for fallible operations. Call async functions with await.
func fetchUser(id: String) async throws -> User {
let url = URL(string: "https://api.example.com/users/\(id)")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw APIError.invalidResponse
}
return try JSONDecoder().decode(User.self, from: data)
}
// Calling from a synchronous context
Task {
do {
let user = try await fetchUser(id: "42")
print(user.name)
} catch {
print("Failed: \(error)")
}
}
Task and Task.detached
Task { } creates unstructured work that inherits the current actor context. Task.detached creates work with no inherited context -- use it sparingly.
// Inherits current actor isolation (e.g., @MainActor)
Task {
let data = try await fetchData()
updateUI(with: data) // safe on MainActor if caller was MainActor
}
// No inherited context -- runs on a cooperative thread pool
Task.detached(priority: .background) {
let report = await generateReport()
await MainActor.run {
displayReport(report)
}
}
Actor Isolation
Actors protect mutable state from data races. Only one task can execute on an actor at a time.
actor BankAccount {
private var balance: Decimal
init(initialBalance: Decimal) {
self.balance = initialBalance
}
func deposit(_ amount: Decimal) {
balance += amount
}
func withdraw(_ amount: Decimal) throws {
guard balance >= amount else {
throw BankError.insufficientFunds
}
balance -= amount
}
func getBalance() -> Decimal {
balance
}
}
// All access is async from outside the actor
let account = BankAccount(initialBalance: 1000)
try await account.withdraw(200)
let balance = await account.getBalance()
@MainActor
Use @MainActor to confine work to the main thread, required for all UI updates.
@MainActor
final class ProfileViewModel: ObservableObject {
@Published var user: User?
@Published var isLoading = false
private let service: UserService
init(service: UserService) {
self.service = service
}
func loadProfile() async {
isLoading = true
defer { isLoading = false }
do {
user = try await service.fetchCurrentUser()
} catch {
print("Load failed: \(error)")
}
}
}
nonisolated
Use nonisolated to opt specific methods out of actor isolation when they only read immutable state or perform no state access.
actor CacheManager {
let maxSize: Int
private var entries: [String: Data] = [:]
init(maxSize: Int) {
self.maxSize = maxSize
}
// No await needed to call this -- it reads only a let property
nonisolated func description() -> String {
"CacheManager(maxSize: \(maxSize))"
}
func store(_ data: Data, forKey key: String) {
entries[key] = data
}
}
Sendable and @Sendable
The Sendable protocol marks types safe to transfer across concurrency domains. The compiler enforces this at build time with strict concurrency checking.
// Value types are implicitly Sendable
struct Coordinate: Sendable {
let latitude: Double
let longitude: Double
}
// Classes must be final with only immutable stored properties
final class Configuration: Sendable {
let apiKey: String
let baseURL: URL
init(apiKey: String, baseURL: URL) {
self.apiKey = apiKey
self.baseURL = baseURL
}
}
// @Sendable closures cannot capture mutable state
func process(items: [Item], transform: @Sendable (Item) -> Result) async {
await withTaskGroup(of: Result.self) { group in
for item in items {
group.addTask {
transform(item)
}
}
}
}
TaskGroup for Parallel Work
Use withTaskGroup or withThrowingTaskGroup to fan out work and collect results.
func fetchAllProfiles(ids: [String]) async throws -> [Profile] {
try await withThrowingTaskGroup(of: Profile.self) { group in
for id in ids {
group.addTask {
try await fetchProfile(id: id)
}
}
var profiles: [Profile] = []
for try await profile in group {
profiles.append(profile)
}
return profiles
}
}
Limit concurrency manually when hitting external services:
func fetchWithLimit(ids: [String], maxConcurrent: Int = 5) async throws -> [Profile] {
try await withThrowingTaskGroup(of: Profile.self) { group in
var iterator = ids.makeIterator()
var profiles: [Profile] = []
// Seed the group with initial batch
for _ in 0..<min(maxConcurrent, ids.count) {
if let id = iterator.next() {
group.addTask { try await fetchProfile(id: id) }
}
}
// As each completes, add the next
for try await profile in group {
profiles.append(profile)
if let id = iterator.next() {
group.addTask { try await fetchProfile(id: id) }
}
}
return profiles
}
}
AsyncSequence and AsyncStream
AsyncSequence is the async counterpart to Sequence. Use AsyncStream to bridge callback-based APIs into structured concurrency.
// Consuming an AsyncSequence
func processNotifications() async {
for await notification in NotificationCenter.default.notifications(named: .userDidUpdate) {
guard let user = notification.userInfo?["user"] as? User else { continue }
await handleUserUpdate(user)
}
}
// Creating an AsyncStream from a delegate/callback pattern
func locationUpdates() -> AsyncStream<CLLocation> {
AsyncStream { continuation in
let delegate = LocationDelegate(
onUpdate: { location in
continuation.yield(location)
},
onFinish: {
continuation.finish()
}
)
// Store delegate to keep it alive
continuation.onTermination = { _ in
delegate.stopUpdating()
}
delegate.startUpdating()
}
}
// Consuming the stream
Task {
for await location in locationUpdates() {
print("Lat: \(location.coordinate.latitude)")
}
}
Cancellation
Structured concurrency propagates cancellation automatically through task hierarchies. Check for cancellation in long-running work.
func processLargeDataset(_ items: [DataItem]) async throws -> [Result] {
var results: [Result] = []
for item in items {
// Throws CancellationError if the task was cancelled
try Task.checkCancellation()
let result = await process(item)
results.append(result)
}
return results
}
// Alternative: check without throwing
func processWithGracefulCancel(_ items: [DataItem]) async -> [Result] {
var results: [Result] = []
for item in items {
if Task.isCancelled {
break // stop early, return partial results
}
let result = await process(item)
results.append(result)
}
return results
}
// Cancelling a task
let task = Task {
try await processLargeDataset(hugeList)
}
// Later, if the user navigates away
task.cancel()
Common Anti-Patterns
Blocking the Main Actor
Never perform synchronous blocking work on @MainActor. Offload heavy computation to a detached task or a background actor.
// BAD -- blocks the main thread
@MainActor
func loadData() {
let data = heavySyncComputation() // UI freezes
self.items = data
}
// GOOD -- offload and return to main actor for UI update
@MainActor
func loadData() async {
let data = await Task.detached(priority: .userInitiated) {
heavySyncComputation()
}.value
self.items = data
}
Unstructured Tasks Leaking
Creating Task { } without storing or cancelling it leads to leaked work that outlives its logical scope.
// BAD -- task leaks if view disappears
func viewDidAppear() {
Task {
while true {
await pollServer()
try await Task.sleep(for: .seconds(30))
}
}
}
// GOOD -- store and cancel on disappear
private var pollingTask: Task<Void, Never>?
func viewDidAppear() {
pollingTask = Task {
while !Task.isCancelled {
await pollServer()
try? await Task.sleep(for: .seconds(30))
}
}
}
func viewDidDisappear() {
pollingTask?.cancel()
pollingTask = nil
}
Actor Reentrancy Hazard
Awaiting inside an actor method yields the actor, allowing other callers to mutate state before your method resumes. Always re-validate state after an await.
actor ImageCache {
private var cache: [URL: UIImage] = [:]
private var inFlight: [URL: Task<UIImage, Error>] = [:]
func image(for url: URL) async throws -> UIImage {
// Check cache first
if let cached = cache[url] { return cached }
// Deduplicate in-flight requests
if let existing = inFlight[url] {
return try await existing.value
}
let task = Task {
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else {
throw ImageError.decodingFailed
}
return image
}
inFlight[url] = task
let image = try await task.value
// Re-check: another caller may have populated cache while we awaited
cache[url] = image
inFlight[url] = nil
return image
}
}
Key Conventions
- Prefer structured concurrency -- use
TaskGroupover looseTask { }whenever possible; structured tasks propagate cancellation and errors automatically. - Mark types Sendable -- enable strict concurrency checking (
-strict-concurrency=complete) and resolve all warnings before they become errors in Swift 6. - Use actors for shared mutable state -- avoid manual locks; actors provide compiler-verified safety.
- Cancel what you create -- every
Taskstored in a property should have a corresponding cancellation path. - Minimize @MainActor surface -- isolate only the UI layer; keep business logic and networking off the main actor.
More from notque/claude-code-toolkit
generate-claudemd
Generate project-specific CLAUDE.md from repo analysis.
12pptx-generator
PPTX presentation generation with visual QA: slides, pitch decks.
12data-analysis
Decision-first data analysis with statistical rigor gates.
9spec-writer
Structured specification: user stories, acceptance criteria, scope.
8wordpress-uploader
WordPress REST API integration for posts and media uploads.
8roast
Constructive critique via 5 HackerNews personas with claim validation.
8