Practical examples of Swift async/await examples for asynchronous programming

If you’re trying to write modern, readable concurrency code in Swift, you need good, practical examples of Swift async/await examples for asynchronous programming, not another dry overview of theory. Swift’s async/await model, introduced in Swift 5.5 and evolving through Swift 5.10 and beyond, radically cleans up callback-heavy code while staying type-safe and predictable. In this guide, we’ll walk through real examples of how async/await fits into everyday iOS and server-side Swift work: networking, image loading, structured concurrency with task groups, cancellation, error handling, and interoperability with older completion-handler APIs. These examples include small, focused snippets you can paste into a project today, along with commentary on why the patterns work well in 2024 and 2025-era Swift. If you’ve ever stared at nested closures wondering where your logic went wrong, these examples of Swift async/await examples for asynchronous programming will show you how to flatten that mess into readable, testable functions that behave the way you expect.
Written by
Jamie
Published

Real-world examples of Swift async/await examples for asynchronous programming

Let’s skip the abstract talk and start with code. The best examples of Swift async/await examples for asynchronous programming are the ones that mirror what you actually do every day: calling APIs, updating UI, saving data, and coordinating multiple operations.

1. Simple async network request with URLSession

This is the “hello world” example of Swift async/await examples for asynchronous programming: fetching JSON from an API.

struct User: Decodable {
    let id: Int
    let name: String
    let email: String
}

func fetchUser(id: Int) async throws -> User {
    let url = URL(string: "https://jsonplaceholder.typicode.com/users/\(id)")!

    let (data, _) = try await URLSession.shared.data(from: url)
    let user = try JSONDecoder().decode(User.self, from: data)
    return user
}

// Usage from an async context
Task {
    do {
        let user = try await fetchUser(id: 1)
        print("User:", user.name)
    } catch {
        print("Failed to fetch user:", error)
    }
}

Compare that to the old completion-handler version: fewer levels of nesting, linear control flow, and errors bubble up using throw. This is one of the best examples of why async/await dramatically improves readability.

2. Updating UIKit or SwiftUI UI on the main actor

Swift’s @MainActor attribute pairs nicely with async/await, especially when you’re handling results from background work and then touching UI.

@MainActor
final class UserViewModel: ObservableObject {
    @Published var userName: String = ""
    @Published var isLoading: Bool = false

    func loadUser() async {
        isLoading = true
        defer { isLoading = false }

        do {
            let user = try await fetchUser(id: 1)
            userName = user.name
        } catch {
            userName = "Error loading user"
        }
    }
}

In SwiftUI, you might call this from a button:

Button("Load User") {
    Task {
        await viewModel.loadUser()
    }
}

This is a clean example of Swift async/await examples for asynchronous programming where UI state and async work stay tightly coordinated without callback pyramids.

3. Parallel work with async let

Sometimes you need several independent values before you can continue: for example, user details, notifications, and settings. With async let, you can kick off multiple async calls in parallel and then await them together.

struct Notifications: Decodable { /* ... */ }
struct Settings: Decodable { /* ... */ }

func fetchDashboardData(userID: Int) async throws -> (User, Notifications, Settings) {
    async let user = fetchUser(id: userID)
    async let notifications = fetchNotifications(for: userID)
    async let settings = fetchSettings(for: userID)

    // All three run in parallel, then we await their results
    return try await (user, notifications, settings)
}

This pattern shows why many developers say the best examples of async/await shine when you have fan-out/fan-in workflows. You keep the code linear while still getting concurrency benefits.

4. Task groups for dynamic parallelism

async let is great when you know how many tasks you have at compile time. When the count is dynamic, task groups are a better example of Swift async/await examples for asynchronous programming.

func fetchUsers(ids: [Int]) async throws -> [User] {
    try await withThrowingTaskGroup(of: User.self) { group in
        for id in ids {
            group.addTask {
                try await fetchUser(id: id)
            }
        }

        var users: [User] = []
        users.reserveCapacity(ids.count)

        for try await user in group {
            users.append(user)
        }

        return users
    }
}

This scales nicely when you’re loading lists, thumbnails, or any batch of resources. If one task throws, the group cancels the rest, which is exactly the behavior you usually want in production code.

5. Bridging old completion handlers with withCheckedContinuation

Most real apps still depend on libraries that use completion handlers. You don’t have to rewrite everything at once. Instead, wrap the old API in an async function.

func legacyFetchImage(url: URL, completion: @escaping (UIImage?, Error?) -> Void) {
    // Imagine this is a third-party SDK you can't change
}

func fetchImage(url: URL) async throws -> UIImage {
    try await withCheckedThrowingContinuation { continuation in
        legacyFetchImage(url: url) { image, error in
            if let error = error {
                continuation.resume(throwing: error)
            } else if let image = image {
                continuation.resume(returning: image)
            } else {
                continuation.resume(throwing: URLError(.badServerResponse))
            }
        }
    }
}

This example of Swift async/await examples for asynchronous programming is practical when migrating large codebases. You gain the ergonomics of async/await without waiting for every dependency to modernize.

Apple’s concurrency docs have more detail on continuations and safety guarantees in the official Swift language guide at developer.apple.com.

6. Structured cancellation with Task.isCancelled

Cancellation used to be an afterthought, often bolted on with ad hoc flags. With Swift concurrency, cancellation is built into Task. Here’s how you might respect cancellation in a long-running operation.

func loadLargeDataset() async throws -> [Data] {
    var results: [Data] = []

    for page in 1...1000 {
        if Task.isCancelled {
            throw CancellationError()
        }

        let pageData = try await fetchPage(index: page)
        results.append(pageData)
    }

    return results
}

func startDatasetLoad() {
    let task = Task {
        do {
            let data = try await loadLargeDataset()
            print("Loaded dataset with", data.count, "pages")
        } catch is CancellationError {
            print("Load cancelled")
        } catch {
            print("Load failed:", error)
        }
    }

    // Later, when user navigates away or taps Cancel
    task.cancel()
}

This pattern matters in data-heavy apps, including health or research apps that may be working with large datasets from APIs such as those documented by the U.S. National Library of Medicine at nlm.nih.gov. You keep the user experience responsive while safely bailing out of expensive work.

7. Async sequences for streaming data

Another modern example of Swift async/await examples for asynchronous programming involves AsyncSequence. Think of it as a stream of values that arrive over time: WebSocket messages, sensor readings, or progress updates.

struct ProgressSequence: AsyncSequence {
    typealias Element = Double

    struct AsyncIterator: AsyncIteratorProtocol {
        private var current: Double = 0

        mutating func next() async -> Double? {
            guard current < 1.0 else { return nil }
            try? await Task.sleep(nanoseconds: 200_000_000) // 0.2s
            current += 0.1
            return current
        }
    }

    func makeAsyncIterator() -> AsyncIterator {
        AsyncIterator()
    }
}

func observeProgress() async {
    for await value in ProgressSequence() {
        print("Progress:", value)
    }
}

This is a simplified demo, but real examples include streaming health data, real-time chat, or live financial quotes. For context on streaming data patterns in research, the National Institutes of Health has good background material on large-scale data workflows at nih.gov.

8. Using @Sendable and avoiding data races

By 2024–2025, Swift’s concurrency model has matured to focus heavily on data-race safety. When you pass closures to concurrent tasks, you’ll often see @Sendable in the signature.

func performConcurrentWork(_ operations: [@Sendable () async -> Void]) async {
    await withTaskGroup(of: Void.self) { group in
        for op in operations {
            group.addTask(operation: op)
        }
    }
}

func exampleUsage() async {
    await performConcurrentWork([
        {
            await logEvent("First task")
        },
        {
            await logEvent("Second task")
        }
    ])
}

This example of Swift async/await examples for asynchronous programming highlights how the compiler nudges you toward safer patterns. When you see Sendable warnings, it’s usually about ensuring values can cross concurrency boundaries without undefined behavior.

9. Async/await in unit tests

Testing async code used to require expectations and waiting manually. With modern XCTest, you can mark tests as async and write them almost like synchronous tests.

import XCTest

final class UserServiceTests: XCTestCase {
    func testFetchUserReturnsValidUser() async throws {
        let user = try await fetchUser(id: 1)
        XCTAssertEqual(user.id, 1)
        XCTAssertFalse(user.name.isEmpty)
    }
}

This is one of the best examples of how async/await improves not just production code but also testability. Your tests read cleanly, and failures point exactly to the awaited line.

10. Server-side Swift with async/await

On the server, examples of Swift async/await examples for asynchronous programming look similar, but you’re often integrating with database drivers or HTTP frameworks like Vapor.

import Vapor

func routes(_ app: Application) throws {
    app.get("users", ":id") { req async throws -> User in
        guard let idString = req.parameters.get("id"),
              let id = Int(idString) else {
            throw Abort(.badRequest)
        }

        let user = try await fetchUser(id: id)
        return user
    }
}

Here, the route handler itself is async throws, which keeps the entire request path linear and easy to follow. This pattern carries over to database calls, caching layers, and third-party APIs.


Patterns and best practices from these examples

Looking across these examples of Swift async/await examples for asynchronous programming, a few patterns stand out:

  • Linear control flow: Error handling with try/catch and early returns keeps logic readable.
  • Structured concurrency: Task, async let, and task groups give you concurrency without losing track of lifetimes.
  • Gradual migration: Continuations let you wrap legacy completion-based APIs and move to async/await at your own pace.
  • Safety by default: @MainActor, @Sendable, and Sendable types help you avoid subtle threading bugs.

For developers working with sensitive domains like health or education, clarity and correctness matter more than cleverness. While this article focuses on code, it’s worth remembering that any app dealing with medical information should also align with security and privacy guidance from sources like healthit.gov and academic best practices from institutions such as harvard.edu.


FAQ: common questions and examples

Q: Can you show another example of using async/await with error handling?
Yes. Here’s a quick pattern that wraps a network error in a domain-specific error type:

enum DataServiceError: Error {
    case network(URLError)
    case decoding(Error)
}

func loadData(from url: URL) async throws -> User {
    do {
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(User.self, from: data)
    } catch let error as URLError {
        throw DataServiceError.network(error)
    } catch {
        throw DataServiceError.decoding(error)
    }
}

This is one of those small but powerful examples of Swift async/await examples for asynchronous programming that makes debugging production issues far less painful.

Q: Do I always need Task {} to call async functions?
No. You only need Task {} when you’re in a non-async context (for example, a button action in UIKit). Inside an async function, you can call other async functions directly with await.

Q: Are there performance downsides to using async/await?
In practice, async/await is usually at least as fast as callback-based code, and often faster because structured concurrency makes it easier to exploit parallelism correctly. Profiling with Instruments is still important, but for most apps, the readability and maintainability gains outweigh minor overhead.

Q: Where can I find more real examples of Swift async/await?
Apple’s official Swift concurrency documentation and WWDC videos are good starting points. You can also explore open-source projects on GitHub that have migrated to Swift concurrency to see how larger systems organize tasks, actors, and async APIs.


The short version: if you’re still writing new Swift code with nested completion handlers, you’re making your life harder than it needs to be. These examples of Swift async/await examples for asynchronous programming should give you enough patterns to start refactoring today, one function at a time.

Explore More Swift Code Snippets

Discover more examples and insights in this category.

View All Swift Code Snippets