In the previous lesson, we fetched article titles in synchronous order. This means that we would only fire the subsequent request when the request before has finished executing. While this works fine, it’s not the best performance. We could start fetching all titles in parallel and await the results altogether.

What is async let?

async let allows you to run multiple asynchronous operations concurrently within a structured scope. Unlike Task { }, which creates an unstructured task, async let ensures that tasks are automatically canceled when they go out of scope. We’ll go deeper into cancelation, structured, and unstructured tasks in the next module.

Making use of async let

We can use async let as follows:

func fetchData(_ id: Int) async -> String {
    return "Data \(id)"
}

func loadAllData() async {
    async let data1 = fetchData(1)
    async let data2 = fetchData(2)
    async let data3 = fetchData(3)

    let results = await [data1, data2, data3]
    print(results) // Output: ["Data 1", "Data 2", "Data 3"]
}

Each async let creates a child task that executes in parallel to fetch the data. These tasks are automatically awaited when accessed, like we do in the results array.

We can use the same technique to update the code from our previous lesson:

let fetcher = ArticleTitleFetcher()

async let titleOne = fetcher.fetchTitle(for: URL(string: "https://www.avanderlee.com/swiftui/how-to-develop-an-app-for-ios/")!)
async let titleTwo = fetcher.fetchTitle(for: URL(string: "https://www.avanderlee.com/swift-testing/parameterized-tests-reducing-boilerplate-code/")!)
async let titleThree = fetcher.fetchTitle(for: URL(string: "https://www.avanderlee.com/swift/result-builders/")!)

/// Wait for all the requests to finish by using an array:
let titles = try await [titleOne, titleTwo, titleThree]
print(titles.joined(separator: ", "))

There are a few changes to the synchronous execution code example:

We can notice the difference in execution after adding an extra Finished fetching print statement in the title fetcher and looking at the printed results:

Fetching title for https://www.avanderlee.com/swiftui/how-to-develop-an-app-for-ios/
Fetching title for https://www.avanderlee.com/swift/result-builders/
Fetching title for https://www.avanderlee.com/swift-testing/parameterized-tests-reducing-boilerplate-code/
Finished Fetching title for https://www.avanderlee.com/swiftui/how-to-develop-an-app-for-ios/
Finished Fetching title for https://www.avanderlee.com/swift-testing/parameterized-tests-reducing-boilerplate-code/
Finished Fetching title for https://www.avanderlee.com/swift/result-builders/

While the requests are defined top-to-bottom, Swift schedules them for concurrent execution without guaranteeing the order in which they start. As a result, responses may be completed in any order. This proves that we didn’t await the outcome of the first request and that responses are handled in parallel.

Note that it’s redundant to use try await in the same async let definition since results are only awaited when accessed:

/// This line:
async let titleOne = try await fetcher.fetchTitle(for: URL(string: "https://www.avanderlee.com/swiftui/how-to-develop-an-app-for-ios/")!)

/// Can be written without `try await`:
async let titleOne = fetcher.fetchTitle(for: URL(string: "https://www.avanderlee.com/swiftui/how-to-develop-an-app-for-ios/")!)

This is similar to omitting the return keyword in Swift.

Async methods execute right away when using async let

It’s essential to understand that asynchronous methods execute immediately when using async let. This is not different compared to awaiting an async method without an async let. The critical difference is that we’re no longer awaiting the result, and the execution of the outer body continues right away.

In other words, the titles are already fetched, even if you haven’t awaited the results yet. This can be confusing, as you might expect methods to only execute after using the await keyword. To demonstrate this matter, I’ve added a sleep in-between fetching the first two titles:

func fetchTitlesInParallel() async throws {
    let fetcher = ArticleTitleFetcher()

    print("Defining title one")
    async let titleOne = try await fetcher.fetchTitle(for: URL(string: "https://www.avanderlee.com/swiftui/how-to-develop-an-app-for-ios/")!)

    try await Task.sleep(nanoseconds: 1_000_000_000)

    print("Defining title two and three")
    async let titleTwo = try await fetcher.fetchTitle(for: URL(string: "https://www.avanderlee.com/swift-testing/parameterized-tests-reducing-boilerplate-code/")!)
    async let titleThree = try await fetcher.fetchTitle(for: URL(string: "https://www.avanderlee.com/swift/result-builders/")!)

    let titles = try await [titleOne, titleTwo, titleThree]
    print(titles.joined(separator: ", "))
}

The printed output looks as follows:

Defining title one
Fetching title for https://www.avanderlee.com/swiftui/how-to-develop-an-app-for-ios/
Finished Fetching title for https://www.avanderlee.com/swiftui/how-to-develop-an-app-for-ios/

Defining title two and three
Fetching title for https://www.avanderlee.com/swift/result-builders/
Fetching title for https://www.avanderlee.com/swift-testing/parameterized-tests-reducing-boilerplate-code/
Finished Fetching title for https://www.avanderlee.com/swift-testing/parameterized-tests-reducing-boilerplate-code/
Finished Fetching title for https://www.avanderlee.com/swift/result-builders/

As you can see, the result of title one was already there before we even awaited the results.

How async let Handles Errors and Cancellation

One of the most essential aspects of async let is its error propagation and task cancellation behavior. It’s designed to be a more approachable version of TaskGroup and comes with many of the benefits we know from structured concurrency out of the box.

Error Handling with async let

If one of the async let calls throws an error, the remaining tasks continue execution until they are explicitly awaited.

func fetchDataWithPotentialFailure(_ id: Int) async throws -> String {
    if id == 2 {
        // Simulating an error for id `2`.
        throw URLError(.badServerResponse)
    }
    return "Data \(id)"
}

func loadAllDataWithFailure() async {
    async let data1 = fetchDataWithPotentialFailure(1)
    async let data2 = fetchDataWithPotentialFailure(2) // This throws an error
    async let data3 = fetchDataWithPotentialFailure(3)

    do {
        let results = try await [data1, data2, data3]
        print(results)
    } catch {
        print("Error occurred: \(error)")
    }
    // data1 and data3 are **implicitly canceled** when exiting scope
}

When we wait for the results, we’ll find out that data2 failed, and an error is being thrown. This implicitly cancels data1 and data3 and will stop them from executing if these methods respect cancelation properly.

async let does not stop execution immediately on error; remaining tasks continue until awaited. By awaiting the results in a joined array, we group the requests, which will also make any running tasks in the array cancel if one of the requests fails.

Cancellation behavior

async let produces structured tasks, which also means it’s automatically canceled when leaving scope. In other words, each async let is getting canceled if its parent is canceled (manually or automatically) or an error is thrown.

This behavior is essential to understand, as it means your task will be canceled when it’s not explicitly awaited. For example, it’s valid to write the following code:

async let _ = fetchData(1)

However, since it’s not explicitly awaited, the fetchData method might be canceled implicitly due to the scope exiting. Ensure to use await if you want to make sure a task completes before the scope finishes.

Performance Benefits of async let

Unlike Task { }, which creates unstructured concurrent work, async let ensures that tasks execute efficiently within Swift’s cooperative thread pool. This means:

Lastly, requests run in parallel, so they’ll execute faster since one does not have to wait for another to finish.

When to use async let?

If your async calls do not depend on each other, async let allows them to run in parallel efficiently. async let is also ideal when you know the number of concurrent tasks at compile-time. If you need dynamic task spawning, TaskGroup is a better solution (more on this in Module 3).

Since async let tasks are automatically canceled when out of scope, they provide safer and more structured concurrency than multiple unstructured Task { } instances.

Avoid async let if:

Can I declare async let at top level?

You might wonder if the following code is valid in Swift:

@Observable
final class ContentViewModel {

    async let title = await fetchTitle()

    // .. rest of your code
}

Unfortunately, the compiler will show an error:

async let can’t be used at top-level declarations.

In other words, you can only use async let on local declarations within methods.

Can I use async let with nonisolated methods?

Yes, you can! It’s actually a great way to turn a synchronous method into an asynchronous variant. The example we used earlier:

func fetchData(_ id: Int) async -> String {
    return "Data \(id)"
}

func loadAllData() async {
    async let data1 = fetchData(1)
    async let data2 = fetchData(2)
    async let data3 = fetchData(3)

    let results = await [data1, data2, data3]
    print(results) // Output: ["Data 1", "Data 2", "Data 3"]
}

Would also work without using async inside the fetchData method:

func fetchData(_ id: Int) -> String {
    return "Data \(id)"
}

func loadAllData() async {
    async let data1 = fetchData(1)
    async let data2 = fetchData(2)
    async let data3 = fetchData(3)

    let results = await [data1, data2, data3]
    print(results) // Output: ["Data 1", "Data 2", "Data 3"]
}

Summary

async let allows us to combine multiple asynchronous calls and await all the results in parallel. It’s a great way to benefit from available system resources to run requests simultaneously while combining results when all asynchronous requests are finished.

In the next lesson, we’ll bring our learns together by showing how you can convert a closure-based URLSession request into a request using async/await.