Users expect instant feedback and fluid interfaces, so modern mobile apps have to be responsive. Swift concurrency, which landed in Swift 5.5, gives us a whole new toolkit for async operations that reshapes how we build mobile architectures. The idea is to write cleaner, safer code, replacing the mess of completion handlers and Grand Central Dispatch queues we used to rely on. So how does this actually change the way we work day-to-day?
Key Takeaways
- Get a handle on async/await and Actors to manage async tasks.
- Use Task groups for structured concurrency to run related operations in parallel with clear cancellation.
- Refactor old callback code to modern Swift concurrency to make it more readable and cut down on race conditions.
- Use @Sendable and actor isolation for thread safety and to stop data corruption.
- Tie Swift concurrency into UI updates on the main actor to keep the interface responsive.
1. Embrace Async/Await for Sequential Asynchronous Code
The core of Swift concurrency is just two keywords: async and await. They let you write async code that looks synchronous, which is a huge win for readability and makes it easier to maintain. Before Swift 5.5, we were stuck with completion handlers or Combine publishers, which usually meant callback hell or hard-to-follow reactive chains. Now, you just mark a function with async to show it can suspend and do work, and you use await to tell the code “wait here until this async thing is done.”
Take a classic example: you need to fetch user data from an API and then slap it onto the UI. In the old world, that was a network call with a completion closure. Now, you just make your fetch function async:
func fetchUserProfile(for userID: String) async throws -> User { // Simulate network delay try await Task.sleep(nanoseconds: 1_000_000_000) guard let url = URL(string: "https://api.example.com/users/\(userID)") else { throw URLError(.badURL) } let (data, response) = try await URLSession.shared.data(from: url) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw URLError(.badServerResponse) } return try JSONDecoder().decode(User.self, from: data)
}
Then, from your view model or controller, you just call it with await inside a Task or another async function:
Task { do { let user = try await fetchUserProfile(for: "user123") // Update UI on the main actor await MainActor.run { self.userNameLabel.text = user.name self.profileImageView.image = user.profileImage } } catch { print("Failed to fetch user profile: \(error)") await MainActor.run { self.showErrorAlert(error.localizedDescription) } }
}
That await MainActor.run call is non-negotiable. You have to do all UI updates on the main thread (which Swift concurrency calls the main actor) or you’ll get weird glitches and crashes. Apple’s own docs on the MainActor are the best place to go for the full story on doing this safely.
Pro Tip: If you’re refactoring old code, a good first step is to find your functions that take completion handlers. You can almost always convert them into async functions that throw errors. This cleans up your error handling a ton, since you’re not passing `Result` types around in closures anymore.
2. Implement Structured Concurrency with Task Groups
So, async/await is great for sequential async work, but what happens when you need to run a bunch of things at once and wait for them all? That’s what Task groups are for. A task group lets you spin up a bunch of child tasks that run in parallel, and it keeps track of them. This structure is great because it has built-in cancellation and helps you avoid common problems like orphaned tasks that keep running in the background, leaking resources.
Let’s say you’re building a dashboard screen and need to hit three different endpoints for user details, recent orders, and notification settings. You can fire off all those requests in parallel using withTaskGroup:
struct DashboardData { let user: User let orders: [Order] let preferences: UserPreferences
} func loadDashboardData(for userID: String) async throws -> DashboardData { try await withThrowingTaskGroup(of: Any.self) { group in group.addTask { return try await fetchUserProfile(for: userID) } group.addTask { return try await fetchUserOrders(for: userID) } group.addTask { return try await fetchNotificationPreferences(for: userID) } var user: User? var orders: [Order]? var preferences: UserPreferences? for try await result in group { if let fetchedUser = result as? User { user = fetchedUser } else if let fetchedOrders = result as? [Order] { orders = fetchedOrders } else if let fetchedPreferences = result as? UserPreferences { preferences = fetchedPreferences } } guard let finalUser = user, let finalOrders = orders, let finalPreferences = preferences else { throw DataLoadingError.incompleteData } return DashboardData(user: finalUser, orders: finalOrders, preferences: finalPreferences) }
}
The best part about this is if any one of those child tasks throws an error, the whole group is torn down. The error gets propagated up, and any other running tasks in the group are automatically cancelled. This behavior is fantastic for managing data dependencies and making sure you’re not wasting resources. If you want the deep-dive on the design philosophy here, check out the original SE-0304: Structured Concurrency proposal.
Common Mistake: A common mistake is forgetting to handle cancellation inside your tasks. The group will tell its children to cancel, but the tasks themselves need to actually check for it with Task.isCancelled or catch a CancellationError to stop what they’re doing. If you have a long-running calculation or a network stream, and you don’t build in these checks, your task could just keep chugging along, wasting CPU and battery, even after it was told to stop.
3. Use Actors for Isolated Mutable State
Managing shared mutable state is one of the hardest parts of concurrent programming. When multiple threads try to read and write to the same piece of data without any locks, you get race conditions, corrupted data, and bugs that are impossible to track down. Swift’s solution is the Actor. An actor is basically a class that automatically protects its own data. It serializes access, meaning only one piece of code can touch its state at a time, and the compiler forces you to use await to interact with it, making the synchronization explicit.
Think about something like an ImageCache. If you have multiple parts of your app trying to read and write images to it at the same time, you’re asking for trouble. An actor fixes this:
actor ImageCache { private var images: [URL: UIImage] = [:] func getImage(for url: URL) async -> UIImage? { // Accessing 'images' is safe here return images[url] } func setImage(_ image: UIImage, for url: URL) async { // Modifying 'images' is safe here images[url] = image } func clearCache() async { images.removeAll() }
}
And to use the ImageCache, you just await your calls:
let imageCache = ImageCache() Task { let url = URL(string: "https://example.com/image.jpg")! if let cachedImage = await imageCache.getImage(for: url) { // Use cached image } else { let downloadedImage = await downloadImage(from: url) await imageCache.setImage(downloadedImage, for: url) }
}
The compiler is your safety net here, enforcing actor isolation and preventing you from synchronously touching the actor’s state from the outside. This alone cuts down on race conditions and makes the whole system easier to reason about. For any complex mobile app, managing different data sources and keeping state consistent is a huge job. This is where the engineering side connects with the business goals. A digital marketing agency like Moburst, for instance, knows from its Media Buying work that app growth depends on a solid, performant architecture. They’ll often advise clients that good engineering, like solid concurrency management, is essential because marketing campaigns are only effective if the app provides a smooth user experience.
Pro Tip: You can use the nonisolated keyword on an actor’s methods or properties if they don’t touch any of its mutable state. This lets you call them synchronously, without await, which is a nice little optimization since they don’t need to get in line for access.
“Apple released macOS 27 Golden Gate on Monday, which brings the new Siri AI assistant, Liquid Glass improvements, improved performance, and more.”
4. Understand and Apply @Sendable for Type Safety
When you’re passing data between different tasks or into an actor, you have to make sure that data is safe to share concurrently. This is what the Sendable protocol and @Sendable attribute are for. If a type conforms to Sendable, it’s a promise to the compiler that it can be passed around between threads without causing data races.
Most value types like structs and enums get to be Sendable for free, as long as all their properties are also Sendable. Classes are a different story. To be Sendable, a class usually has to be immutable, use its own internal locking, or just be an actor. You can also mark a closure as @Sendable, which tells the compiler it only captures other Sendable values.
For example, here’s a user profile data model:
struct UserProfile: Codable, Hashable, Sendable { // Structs are often Sendable by default let id: String let name: String let email: String // No mutable state here, so it's safe to send across tasks
} class UserSettings: @unchecked Sendable { // Class needs explicit handling private let queue = DispatchQueue(label: "com.yourapp.settingsqueue") private var _isNotificationsEnabled: Bool init(isNotificationsEnabled: Bool) { self._isNotificationsEnabled = isNotificationsEnabled } var isNotificationsEnabled: Bool { get { queue.sync { _isNotificationsEnabled } } set { queue.sync { _isNotificationsEnabled = newValue } } }
}
The @unchecked Sendable attribute is an escape hatch, and you need to be very careful with it. You’re telling the compiler, “I swear this is thread-safe, don’t check my work.” You’d only use this for a class where you’ve manually implemented your own thread safety (like our UserSettings example using a `DispatchQueue`) or for a class that’s effectively immutable. If you get this wrong, you’re right back in the world of data races that Swift concurrency is trying to save you from.
The official Swift Language Guide has the full breakdown on Sendable and Actor Isolation.
Common Mistake: Don’t ignore Sendable warnings. When the compiler complains that you’re passing a non-Sendable type into an actor or a task, it’s not being picky, it’s flagging a potential data race. You have to fix these, either by making your type conform to Sendable, making it immutable, or wrapping it in some kind of synchronization.
5. Integrate with UI Frameworks and Legacy Code
You don’t have to rewrite your whole app to start using Swift concurrency. Most of us are going to adopt it gradually. A huge part of the work is just integrating async/await with the UIKit and SwiftUI code we already have, not to mention all the old APIs that still use callbacks.
With UIKit, the rule is still the same: UI updates have to happen on the main thread. In Swift concurrency, you enforce this with @MainActor or by wrapping your code in await MainActor.run { ... }. You can even stick @MainActor on a whole class, and the compiler will make sure all its methods run on the main thread:
@MainActor
class UserProfileViewController: UIViewController { @IBOutlet weak var nameLabel: UILabel! @IBOutlet weak var avatarImageView: UIImageView! override func viewDidLoad() { super.viewDidLoad() Task { await loadUserProfile() } } private func loadUserProfile() async { do { let user = try await fetchUserProfile(for: "currentUserID") self.nameLabel.text = user.name self.avatarImageView.image = user.profileImage } catch { self.showErrorAlert(error.localizedDescription) } }
Because UserProfileViewController is marked with @MainActor, its methods are automatically on the main actor, so all the UI updates inside `loadUserProfile` are safe. If you have some heavy lifting to do that’s *not* UI-related, you can hop off the main actor by using Task.detached or by calling another async function that isn’t isolated to the main actor.
To deal with old APIs that use completion handlers, you can wrap them using withCheckedContinuation or withCheckedThrowingContinuation. This lets you present an old-school callback API to the rest of your app as a clean, modern async function:
func oldSchoolDataFetch(completion: @escaping (Result<Data, Error>) -> Void) { // Simulate old network request DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) { if Bool.random() { completion(.success(Data("Hello from old API".utf8))) } else { completion(.failure(URLError(.cannotConnectToHost))) } }
} func newAsyncDataFetch() async throws -> Data { return try await withCheckedThrowingContinuation { continuation in oldSchoolDataFetch { result in continuation.resume(with: result) } }
}
This bridging technique is your best friend for adopting Swift concurrency piece by piece without having to do a massive rewrite. It lets you put a nice wrapper around legacy code and expose it as a modern async interface. Apple has more examples in its `withCheckedContinuation` documentation.
Editorial Aside: While withCheckedContinuation is a lifesaver, it puts all the responsibility on you to handle the continuation correctly. You *must* call continuation.resume() exactly one time for every possible path. If you forget, your task will hang forever. If you call it twice, your app will crash. There’s no in-between, so be careful.
Swift concurrency really does give us a solid, type-safe way to build apps. By actually using async/await, task groups, actors for state, and paying attention to Sendable, you can write code that’s more reliable, easier to read, and faster. Yes, the transition involves some refactoring work, but the payoff in better maintainability and fewer bugs is absolutely worth it.
Primary benefit of async/await over completion handlers?
It’s about readability and maintainability. Async/await lets you write async code that reads top-to-bottom, like synchronous code. This gets rid of the “callback hell” of nested completion handlers and makes complex logic way easier to follow and debug.
When to use an Actor vs. a DispatchQueue for shared state?
Actors are the modern preference for managing shared mutable state. The compiler enforces their data isolation which is a safer way to prevent race conditions than managing dispatch queues by hand. Reach for an actor whenever you have a piece of state that multiple concurrent tasks need to access safely.
Swift concurrency with Objective-C code?
Yep, you can mix them. You can expose Swift’s async methods to Objective-C and use withCheckedContinuation to wrap Objective-C completion-block APIs into Swift async functions. Calling a Swift async function directly from Objective-C is a bit more involved and usually requires writing a wrapper function to bridge the two worlds.
What if a Task in a Task Group throws an error?
If a task inside a withThrowingTaskGroup fails, the group doesn’t wait around. The error is thrown immediately, and all other tasks in that same group are automatically cancelled. It’s a great way to handle errors and clean up resources efficiently.
How does Swift concurrency handle UI thread safety?
It uses the MainActor. By marking your UI code (a class, a function, or just a block) with @MainActor, you tell the compiler to guarantee it runs on the main thread. This is how you prevent crashes and weird UI bugs from background thread updates.