Swift Concurrency in Real Apps: async/await Beyond the Tutorials
Swift 6 made strict concurrency the default. Here’s what that actually means for networking, data layers, and UI code in production apps.
Most Swift concurrency tutorials show you a URLSession call wrapped in async/await and call it done. That part is easy. What’s harder — and what caused most of the migration pain when Swift 6 dropped — is understanding what actor isolation actually means and how to structure a real app around it.
Here’s what I’ve learned from migrating production apps to Swift 6 concurrency.
Why Swift 6 Concurrency Feels Hard at First
Swift 6 enables strict concurrency checking by default. This means the compiler enforces that data accessed from multiple concurrent contexts is properly protected. If you’ve been ignoring the concurrency warnings in Swift 5.5–5.9, Swift 6 turns those warnings into errors.
The most common errors I see:
// Error: Sending 'userProfile' risks causing data races
await updateProfile(userProfile)
// Error: Main actor-isolated property 'title' can not be
// mutated from a nonisolated context
self.title = response.name
These aren’t arbitrary restrictions — they’re the compiler telling you your code has a real data race potential. The question is how to fix it cleanly.
Structuring Your Networking Layer
I settled on this pattern for networking after trying a few approaches:
actor NetworkClient {
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
func fetch<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
let request = try endpoint.urlRequest()
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else {
throw NetworkError.invalidResponse
}
return try JSONDecoder().decode(T.self, from: data)
}
}
Using an actor for the network client means Swift’s actor isolation handles thread safety automatically. No DispatchQueue, no @synchronized — the compiler guarantees that fetch runs sequentially within the actor.
The @MainActor Pattern for ViewModels
For ViewModels that drive SwiftUI views, I mark the entire class @MainActor:
@MainActor
final class ProfileViewModel: ObservableObject {
@Published var profile: UserProfile?
@Published var isLoading = false
@Published var error: Error?
private let client: NetworkClient
init(client: NetworkClient) {
self.client = client
}
func loadProfile(id: String) async {
isLoading = true
defer { isLoading = false }
do {
profile = try await client.fetch(.profile(id: id))
} catch {
self.error = error
}
}
}
@MainActor ensures all property mutations happen on the main thread — which is what UIKit and SwiftUI require. The await client.fetch(...) call suspends without blocking the main thread, and control returns to the main actor when the result is ready.
Structured Concurrency for Parallel Fetches
When you need to fire multiple network requests in parallel and wait for all of them:
func loadDashboard() async {
async let profile: UserProfile = client.fetch(.profile)
async let feed: [Post] = client.fetch(.feed)
async let notifications: [Notification] = client.fetch(.notifications)
do {
let (p, f, n) = try await (profile, feed, notifications)
self.profile = p
self.posts = f
self.notifications = n
} catch {
self.error = error
}
}
The three requests run concurrently. try await (profile, feed, notifications) waits for all three. If any throws, the error propagates and the other tasks are cancelled. This is structured concurrency doing exactly what it’s supposed to.
The Sendable Problem with Your Data Models
If your model types cross actor boundaries, they need to be Sendable. For pure value types (structs with only Sendable properties), conformance is automatic. For classes — mark them final and @unchecked Sendable if you’ve manually ensured they’re safe, but prefer structs.
// This works automatically
struct UserProfile: Codable, Sendable {
let id: String
let name: String
let avatarURL: URL?
}
The migration from legacy completion handler style to async/await is mechanical once you understand the pattern. The harder part is redesigning the boundary between your data layer (actors) and your UI layer (@MainActor ViewModels). Get that boundary right and the rest follows.
What’s Still Annoying
AsyncStream and AsyncThrowingStream for bridging delegate-based APIs (like CLLocationManagerDelegate) have a learning curve. The pattern works, but it’s more verbose than I’d like.
Debugging concurrent code is still harder than single-threaded code. Thread Sanitiser is your friend during development — enable it in the scheme settings and let it catch races that the compiler might not catch in complex cases.
Swift 6 concurrency is genuinely better than what came before. The initial migration friction is real, but the result is code that the compiler actually verifies is safe. That’s worth it.