Dev Genius

Coding, Tutorials, News, UX, UI and much more related to development

Follow publication

Understanding Swift Concurrency: A Deep Dive into async/await

--

Photo by Kaung Myat Min on Unsplash

Modern iOS apps demand smooth, responsive user experiences while handling complex operations like network calls, image processing, and data persistence. Swift’s async/await pattern revolutionizes how we handle these concurrent operations. Let’s dive deep into why this matters and how it transforms our code.

The Pain Points of Completion Handlers

Example 1: The Notification Nightmare

Imagine building a social media app’s notification system. You need to
1. Fetch user preferences
2. Get unread notifications
3. Filter based on preferences
4. Update the UI

Here’s how this looks with completion handlers

func loadNotifications(completion: @escaping (Result<[Notification], Error>) -> Void) {
getUserPreferences { [weak self] result in
guard let self = self else { return }

switch result {
case .success(let preferences):
self.fetchUnreadNotifications { result in
switch result {
case .success(let notifications):
let filtered = self.filterNotifications(notifications, preferences: preferences)

DispatchQueue.main.async {
self.updateUI(with: filtered) { result in
switch result {
case .success:
completion(.success(filtered))
case .failure(let error):
completion(.failure(error))
}
}
}
case .failure(let error):
completion(.failure(error))
}
}
case .failure(let error):
completion(.failure(error))
}
}
}

Issues with this approach:

  • Pyramid of doom with nested closures
  • Error handling repeated at each level
  • Memory management with [weak self]
  • Manual thread switching with DispatchQueue.main.async

Example 2: The Authentication Flow

Consider an app’s authentication flow requiring:
1. Biometric authentication
2. Token refresh
3. Profile fetch

func authenticateUser(completion: @escaping (Result<UserProfile, Error>) -> Void) {
biometricAuth { [weak self] authResult in
guard let self = self else { return }

switch authResult {
case .success:
self.refreshToken { tokenResult in
switch tokenResult {
case .success(let token):
self.fetchUserProfile(token: token) { profileResult in
switch profileResult {
case .success(let profile):
completion(.success(profile))
case .failure(let error):
completion(.failure(error))
}
}
case .failure(let error):
completion(.failure(error))
}
}
case .failure(let error):
completion(.failure(error))
}
}
}

Additional challenges:

  • Hard to handle multiple error scenarios
  • Difficult to implement retry logic
  • Complex cancellation handling
  • Testing becomes cumbersome

The async/await Revolution

Example 1: Streamlined Notifications

Let’s transform our notification system using async/await:

func loadNotifications() async throws -> [Notification] {
let preferences = try await getUserPreferences()
let notifications = try await fetchUnreadNotifications()
let filtered = filterNotifications(notifications, preferences: preferences)

await MainActor.run {
try await updateUI(with: filtered)
}

return filtered
}

Key improvements:

  • Linear code flow
  • Built-in error propagation
  • Automatic memory management
  • Clear thread management with MainActor
  • Easy to read and maintain

Example 2: Simplified Authentication

The authentication flow becomes remarkably cleaner:

func authenticateUser() async throws -> UserProfile {
try await biometricAuth()
let token
= try await refreshToken()
return try await fetchUserProfile(token: token)
}

Benefits:

  • Sequential reading and execution
  • Simplified error handling
  • Easy to add middleware or logging
  • Testable with async unit tests

Advanced Patterns with async/await

Parallel Execution

Sometimes we want operations to run concurrently:

func loadDashboard() async throws -> Dashboard {
async let preferences = getUserPreferences()
async let notifications = fetchUnreadNotifications()
async let profile = getCurrentProfile()

return try await Dashboard(
preferences: preferences,
notifications: notifications,
profile: profile
)
}

Structured Concurrency with TaskGroup

For dynamic parallel operations:

func loadUserPosts(ids: [String]) async throws -> [Post] {
try await withThrowingTaskGroup(of: Post.self) { group in
var posts: [Post] = []

for id in ids {
group.addTask {
try await self.fetchPost(id: id)
}
}

for try await post in group {
posts.append(post)
}

return posts
}
}

Error Handling and Retries

Elegant retry logic becomes straightforward:

func fetchWithRetry<T>(maxAttempts: Int = 3, operation: () async throws -> T) async throws -> T {
var lastError: Error?

for attempt in 1...maxAttempts {
do {
return try await operation()
} catch {
lastError = error
if attempt < maxAttempts {
try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(attempt))) * 1_000_000_000)
}
}
}

throw lastError!
}

Best Practices and Tips

Actor Isolation

actor DataManager {
private var cache: [String: Any] = [:]

func getData(for key: String) async -> Any? {
return cache[key]
}

func setData(_ data: Any, for key: String) async {
cache[key] = data
}
}

Cancellation Handling

func loadData() async throws -> Data {
try Task.checkCancellation()
let result = try await fetchFromNetwork()
try Task.checkCancellation()
return result
}

MainActor Usage

@MainActor
class ViewController: UIViewController {
func updateUI() async {
// Automatically runs on main thread
tableView.reloadData()
}
}

Migration Strategy

When moving from completion handlers to async/await:

Wrapper Pattern

// Bridge old API to new async world
func fetchData() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
legacyFetchData { result in
switch result {
case .success(let data):
continuation.resume(returning: data)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}

Gradual Migration

// Keep both APIs temporarily
func getData(completion: @escaping (Result<Data, Error>) -> Void) {
Task {
do {
let data = try await getDataAsync()
completion(.success(data))
} catch {
completion(.failure(error))
}
}
}

Performance Considerations

Task Priority

Task(priority: .userInitiated) {
try await processUserAction()
}

Resource Management

let taskHandle = Task {
try await performHeavyOperation()
}

// Later, if needed:
taskHandle.cancel()

Conclusion

Swift’s async/await pattern isn’t just syntactic sugar — it’s a fundamental shift in how we handle asynchronous operations. It brings:

  • Cleaner, more maintainable code
  • Better error handling
  • Improved performance through structured concurrency
  • Easier testing and debugging

The key is to understand not just the syntax, but the patterns and principles that make async/await powerful. Start with simple conversions, then explore advanced features like actors and task groups as you become more comfortable with the paradigm.

Follow me on LinkedIn and Twitter/X for more such content!!

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

--

--

Published in Dev Genius

Coding, Tutorials, News, UX, UI and much more related to development

Written by Jatin Mishra

Senior iOS Engineer with 6+ years of experience, specializing in high-performance app development and architecture optimization. Join me for special insights.

No responses yet

Write a response