How to load parallel requests in Swift

Issue #1016

Loading data from multiple API endpoints is one of the most common tasks in iOS development. When your app needs to fetch a user’s profile, their recent orders, and notification count all at once, the naive approach is to call them one after another—waiting for each to complete before starting the next. This sequential pattern wastes precious seconds while your users stare at loading spinners.

The good news is that Swift’s concurrency model gives us an elegant solution: async let. With just a small change to how we structure our code, we can fire off multiple requests simultaneously and wait for them all to complete together.

Consider a typical dashboard screen that needs data from three different endpoints. Here’s what sequential fetching looks like:

func loadDashboard() async throws -> Dashboard {
    let accounts = try await fetchAccounts()
    let balances = try await fetchBalances()
    let cards = try await fetchCards()

    return Dashboard(accounts: accounts, balances: balances, cards: cards)
}

Each await pauses execution until that particular request finishes. If each call takes 300 milliseconds, your users wait nearly a full second before seeing anything—even though those three requests have no dependency on each other.

Firing Requests in Parallel with async let

Swift’s async let binding creates a child task that starts executing immediately, without waiting. You’re essentially telling Swift: “Start this work now, and I’ll collect the result later.”

Let’s transform our dashboard loader to fetch everything concurrently:

func loadDashboard() async throws -> Dashboard {
    async let accounts = fetchAccounts()
    async let balances = fetchBalances()
    async let cards = fetchCards()

    return try await Dashboard(
        accounts: accounts,
        balances: balances,
        cards: cards
    )
}

That small change does something powerful:

  1. All three network requests start at the same moment
  2. They run concurrently, each on its own task
  3. The await at the return statement waits for all of them to complete
  4. Total wait time becomes the duration of the slowest request, not the sum of all three

If each request takes 300ms, your dashboard now loads in roughly 300ms total instead of 900ms. That’s a 3x improvement with minimal code change.

Understanding the Mechanics

When you write async let, Swift creates a child task that begins executing right away. The actual suspension—where your function pauses—happens only when you await the result. This means you can do other synchronous work between declaring the async let and awaiting it:

func loadUserProfile() async throws -> UserProfile {
    async let avatar = downloadAvatar(for: userId)
    async let stats = fetchUserStats(userId)

    // This runs immediately while downloads happen in background
    let cachedName = cache.userName(for: userId)
    let formattedDate = dateFormatter.string(from: Date())

    // Now we wait for the network calls
    return try await UserProfile(
        name: cachedName,
        avatar: avatar,
        stats: stats,
        lastUpdated: formattedDate
    )
}

Handling Errors Gracefully

When any of your parallel tasks throws an error, Swift automatically cancels the sibling tasks. This prevents wasted work and resources. Here’s how to handle partial failures when you want to show whatever data you can load:

func loadDashboard() async -> Dashboard {
    async let accountsResult = Result { try await fetchAccounts() }
    async let balancesResult = Result { try await fetchBalances() }
    async let cardsResult = Result { try await fetchCards() }

    let accounts = await accountsResult
    let balances = await balancesResult
    let cards = await cardsResult

    return Dashboard(
        accounts: (try? accounts.get()) ?? [],
        balances: (try? balances.get()) ?? [],
        cards: (try? cards.get()) ?? []
    )
}

By wrapping each call in a Result, we capture success or failure individually. The dashboard displays whatever data arrived successfully, showing empty states for anything that failed.

When to Use async let vs TaskGroup

The async let pattern shines when you know exactly how many concurrent operations you need at compile time. For dynamic collections where you need to process an unknown number of items concurrently, reach for TaskGroup instead:

// Use async let for fixed, known operations
async let user = fetchUser()
async let settings = fetchSettings()

// Use TaskGroup for dynamic collections
func fetchAllImages(urls: [URL]) async throws -> [UIImage] {
    try await withThrowingTaskGroup(of: UIImage.self) { group in
        for url in urls {
            group.addTask { try await downloadImage(from: url) }
        }

        var images: [UIImage] = []
        for try await image in group {
            images.append(image)
        }
        return images
    }
}
Written by

I’m open source contributor, writer, speaker and product maker.

Start the conversation