How to use Async Let to perform concurrent methods in Swift | Modern Swift Concurrency #5
When you await each async call one after another, they run sequentially and their times add up — if you have three independent network requests that each take one second, you wait three seconds. async let starts all of them simultaneously and waits for all of them at once, so the total wait is only as long as the slowest one.
What You'll Learn
- How
async letlaunches a child task immediately without suspending the current task, enabling true concurrent execution - Why you must
awaiteveryasync letbinding before the enclosing scope exits — and what happens if a binding throws - When to use
async letvs sequentialawaitvsTaskGroup, and how to choose based on what you know at compile time
Mental Model
Imagine you're ordering food at a restaurant that lets you place multiple dishes at once. Sequential await is like ordering soup, waiting for it to arrive, eating it, then ordering your main course — one at a time. async let is like ordering soup, salad, and main course all at the beginning of the meal. The kitchen works on all three concurrently. When you're ready for each one (await), it's either already on the table or arrives shortly after — you don't wait for three separate courses sequentially.
The key constraint: you must decide what to order at the beginning (at compile time). async let works when you know the exact number and type of operations upfront. If you're iterating over a dynamic list of URLs, you need TaskGroup (lesson 6) instead.
Detailed Explanation
async let is structured concurrency's syntax for compile-time-known concurrent child tasks. When you write async let result = someAsyncFunction(), Swift immediately starts someAsyncFunction() as a child task and continues executing the current task without waiting. The binding result is a placeholder — its type is the return type of someAsyncFunction(), but its value is not yet available. When you finally write await result, the parent task suspends until the child task completes and the value is ready.
This is fundamentally different from two sequential await calls. Compare:
// Sequential: total time = time(A) + time(B)
let a = try await fetchA()
let b = try await fetchB()
// Concurrent: total time = max(time(A), time(B))
async let a = fetchA()
async let b = fetchB()
let (resultA, resultB) = try await (a, b)The commented-out sequential block in the sample makes this concrete: four separate try await fetchImage() calls take roughly 4x the time of one, because each waits for the previous to complete. The async let block fires all four simultaneously and collects results in a single await expression.
Child tasks created by async let participate in structured concurrency. This has two important implications. First, if the parent task is cancelled, all async let child tasks are automatically cancelled too — you don't need to cancel them individually. Second, child tasks are scoped to the enclosing block: if you exit the scope (by hitting an error, returning, or reaching the end of the block) before awaiting an async let binding, Swift automatically cancels any unfinished child tasks. This prevents resource leaks.
Mixing throwing and non-throwing async let in the same await expression requires attention to try placement. In the sample, fetchImage1 can throw (it calls URLSession.data) but fetchTitle1 cannot throw (it just returns a string literal). The await expression is await (try fetchImage1, fetchTitle1) — try applies to fetchImage1 only. If fetchImage1 throws, the catch block runs and fetchTitle1 (which may still be in-flight) is automatically cancelled.
The constraint of async let being compile-time-known means it cannot replace TaskGroup when the number of operations is dynamic. You cannot write async let results = urls.map { fetchImage(url: $0) } — there is no language construct for an array of async let bindings. For a dynamic number of operations, TaskGroup (lesson 6) is the right tool.
Code Structure
AsyncLetBootcamp.swift is a self-contained SwiftUI view that manages its own @State. The onAppear block contains the async let usage, alongside commented-out versions showing (a) a four-image concurrent fetch and (b) sequential fetches for direct comparison. fetchImage() is a throwing async function that downloads from a fixed URL. fetchTitle() is a non-throwing async function that returns a constant.
Complete Code
AsyncLetBootcamp.swift
import SwiftUI
struct AsyncLetBootcamp: View {
@State private var images: [UIImage] = []
let columns = [GridItem(.flexible()), GridItem(.flexible())]
let url = URL(string: "https://picsum.photos/300")! // hardcoded URL; force-unwrap is safe for known-valid string literals
var body: some View {
NavigationView {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(images, id: \.self) { image in
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(height: 150)
}
}
}
.navigationTitle("Async Let 🥳")
.onAppear {
Task {
do {
async let fetchImage1 = fetchImage() // child task starts immediately — fetchImage1 is already downloading
async let fetchTitle1 = fetchTitle() // child task starts immediately, concurrently with fetchImage1
let (image, title) = await (try fetchImage1, fetchTitle1) // suspends until BOTH complete; try applies only to the throwing fetchImage1
// async let fetchImage2 = fetchImage() // all four start simultaneously at these four lines
// async let fetchImage3 = fetchImage()
// async let fetchImage4 = fetchImage()
//
// let (image1, image2, image3, image4) = await (try fetchImage1, try fetchImage2, try fetchImage3, try fetchImage4)
// self.images.append(contentsOf: [image1, image2, image3, image4])
// let image1 = try await fetchImage() // sequential: waits for image1 before starting image2
// self.images.append(image1)
//
// let image2 = try await fetchImage() // only starts after image1 fully downloads
// self.images.append(image2)
//
// let image3 = try await fetchImage()
// self.images.append(image3)
//
// let image4 = try await fetchImage() // total time ≈ 4x a single download
// self.images.append(image4)
} catch {
// URLError from fetchImage() is caught here; fetchTitle1 is auto-cancelled if still in-flight
}
}
}
}
}
// Non-throwing: just returns a string; used to show that async let works with both throwing and non-throwing functions
func fetchTitle() async -> String {
return "NEW TITLE"
}
func fetchImage() async throws -> UIImage {
do {
let (data, _) = try await URLSession.shared.data(from: url, delegate: nil) // suspends; other async let tasks run during this wait
if let image = UIImage(data: data) {
return image
} else {
throw URLError(.badURL) // treat non-image data as a real error rather than returning nil
}
} catch {
throw error // re-throw so the do-catch in the caller handles it
}
}
}
struct AsyncLetBootcamp_Previews: PreviewProvider {
static var previews: some View {
AsyncLetBootcamp()
}
}Code Walkthrough
async let fetchImage1 = fetchImage()— At this exact line, Swift spawns a child task and begins downloading immediately. The current task does not suspend here. Execution continues to the next line. This is the entire point ofasync let: starting work without waiting for it.async let fetchTitle1 = fetchTitle()— A second child task starts, also immediately. BothfetchImage1andfetchTitle1are now in-flight concurrently. IffetchTitle1finishes quickly (it returns immediately in this case), its result is held ready for when the parent task eventually awaits it.await (try fetchImage1, fetchTitle1)— This is the join point: the parent task suspends here until all named bindings are resolved. The tuple destructuring pattern is idiomatic for awaiting multipleasync letbindings simultaneously.tryis inside the tuple because onlyfetchImage1can throw —fetchTitle1cannot, so notryis needed for it.The commented-out four-image fetch — Uncomment that block and comment out the two-image block above. You'll see all four images appear nearly simultaneously rather than one-by-one. The total wall-clock time is approximately equal to one download, not four — all four
URLSession.data(from:)calls run in parallel on the cooperative thread pool.The commented-out sequential block — This is the direct comparison: four sequential
try await fetchImage()calls where each one must complete before the next starts. Uncomment this and time the difference against theasync letversion. The visual difference in the UI is dramatic for slow connections.Error propagation and automatic cancellation — If
fetchImage()throws (e.g., no network), thecatchblock runs immediately. At that point,fetchTitle1may still be awaiting its result. Swift automatically cancels it because it is a child task scoped to thedoblock. No explicit cleanup is needed.
Common Mistakes
Mistake: Using async let for a dynamic list of URLsasync let requires you to write one binding per concurrent operation. You cannot write async let images = urls.map { fetchImage($0) } — the language doesn't support it. Attempting to work around this with arrays of Task values and manual await calls defeats the structured concurrency guarantees. Use withTaskGroup or withThrowingTaskGroup whenever the count comes from a collection.
Mistake: Starting async let tasks that depend on each otherasync let means "start this now and wait for it later." If operation B requires the result of operation A, you must await A first, then start B. Writing async let b = computeUsingResult(a) where a is another async let binding would try to use a before it's available, which is a compile error. Only independent operations should use async let.
Mistake: Not awaiting an async let binding before exiting scope If an async let binding goes out of scope without being awaited, the child task is automatically cancelled. This is safe (no leak), but it means you've done work for nothing. If you start four downloads with async let but only need three, cancel the fourth explicitly or restructure with TaskGroup and a limit on how many results you accept.
Key Takeaways
async letstarts a child task immediately on the declaration line — the parent task does not suspend until itawaits the binding, making independent operations run concurrently- Child tasks from
async letare automatically cancelled if the parent task is cancelled or if the scope is exited due to an error, preventing resource leaks - Use
async letwhen the number of concurrent operations is fixed at compile time; useTaskGroup(lesson 6) when the operations come from a dynamic collection
Last updated: June 27, 2026