Swift Structured Concurrency: Task Groups, Async Let, and Child Tasks
Swift structured concurrency gives you a clear way to run asynchronous work while keeping related tasks connected to the code that started them. It helps you write concurrent code that is easier to read, cancel, debug, and reason about.
Quick answer: Structured concurrency means async work stays in a well-defined parent-child relationship. In Swift, the main tools are async let for a small, fixed number of child tasks and task groups for dynamic sets of child tasks.
Difficulty: Intermediate
You'll understand this better if you know: basic Swift functions, closures, optionals, and the basics of async and await.
1. What Is Swift Structured Concurrency?
Structured concurrency is Swift’s model for organizing asynchronous work so that tasks are created, awaited, and finished within a predictable scope. Instead of launching work that may outlive the function that started it, Swift keeps child tasks tied to their parent.
- It makes concurrent code follow the same structure as normal control flow.
- It keeps related tasks grouped together.
- It automatically waits for child tasks before leaving scope.
- It makes cancellation and error handling more consistent.
In practice, structured concurrency is mainly expressed with async let and task groups. Both create child tasks that are tied to the current task and do not escape their scope.
2. Why Structured Concurrency Matters
Without structure, asynchronous code can become hard to manage quickly. Tasks may be forgotten, errors may be ignored, and cancellations may not propagate the way you expect. Structured concurrency solves these problems by making the lifetime of work obvious.
It matters most when you need to:
- run multiple network requests at the same time,
- wait for several independent calculations,
- cancel all related work when a screen disappears, or
- ensure that no task keeps running after the parent operation ends.
It is especially useful in app code, where a user action often starts a group of related async operations that should end together.
3. Basic Syntax or Core Idea
At the core, structured concurrency means that a parent async function creates child tasks and then waits for them before returning. The simplest form is async let.
Using async let
Use async let when you know ahead of time how many child tasks you need. Each bound value starts running immediately, and you await it later.
func loadProfile() async throws -> (String, String) {
async let name = fetchName()
async let city = fetchCity()
return try await (name, city)
}This example starts two child tasks at the same time. The function does not return until both values are ready, and any thrown error is propagated to the caller.
Using a task group
Use a task group when the number of child tasks is not fixed or when you need to process results as they complete.
func fetchAll(ids: [Int]) async throws -> [String] {
try await withTaskGroup(of: String.self) { group in
for id in ids {
group.addTask {
try await fetchItem(id: id)
}
}
var results: [String] = []
for try await item in group {
results.append(item)
}
return results
}
}This version is flexible: you can add any number of child tasks, and the parent waits for the whole group to complete.
4. Step-by-Step Examples
Example 1: Fetch two independent values
When two pieces of work do not depend on each other, async let is usually the cleanest choice. Both operations run in parallel while the function stays readable.
func loadDashboard() async throws -> String {
async let weather = fetchWeatherSummary()
async let news = fetchNewsHeadline()
let combined = try await weather + " | " + news
return combined
}Both child tasks begin before the first await. That is the main performance benefit of structured concurrency in this case.
Example 2: Start a dynamic number of tasks
If you have a list of work items, a task group lets you create one child task per item. This is common for loading multiple resources from the network.
func loadThumbnails(urls: [URL]) async throws -> [Data] {
try await withThrowingTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask {
try await URLSession.shared.data(from: url).0
}
}
var images: [Data] = []
for try await image in group {
images.append(image)
}
return images
}
}This example shows the dynamic case: the number of tasks depends on the array length, so a task group is the right tool.
Example 3: Cancel remaining child tasks on error
In a throwing task group, if one child throws and you do not handle it differently, the group exits with that error. Remaining work is canceled automatically as the group unwinds.
func firstValidResponse(urls: [URL]) async throws -> Data {
try await withThrowingTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask {
try await download(url)
}
}
if let data = try await group.next() {
group.cancelAll()
return data
}
throw URLError.badServerResponse
}
}This pattern is useful when the first successful result is enough and the rest should stop early.
Example 4: Keep work inside the parent scope
Structured concurrency is not just about speed. It is also about making sure child tasks cannot outlive the function that created them.
func refreshScreen() async {
async let profile = fetchProfile()
async let settings = fetchSettings()
do {
let profileValue = try await profile
let settingsValue = try await settings
render(profileValue, settingsValue)
} catch {
showError(error)
}
}Because the child tasks belong to refreshScreen(), they are naturally bounded by its lifetime.
5. Practical Use Cases
- Loading several API endpoints for one screen at the same time.
- Fetching a user profile, avatar, and settings together.
- Running multiple file reads or database queries in parallel.
- Transforming a collection of inputs into outputs with a task group.
- Starting background work that must still finish before the parent async function returns.
These situations all benefit from a clear parent task that owns the child tasks and collects their results.
6. Common Mistakes
Mistake 1: Using a detached task when you need structure
A detached task is not a child of the current task, so it does not automatically inherit the same cancellation and lifetime rules. That breaks the main benefit of structured concurrency.
Problem: The work can continue after the parent function ends, which makes it harder to cancel, test, and reason about.
func loadData() async -> String {
let task = Task {
return await fetchText()
}
return await task.value
}Fix: Use async let or a task group so the task remains part of the current async scope.
func loadData() async -> String {
async let text = fetchText()
return await text
}The corrected version keeps the work tied to the current task, which is the structured concurrency model Swift is designed for.
Mistake 2: Forgetting to await async let values
An async let binding creates a child task immediately, but you still must await the value before the scope ends if you need it.
Problem: Swift requires each async let binding to be awaited, or you will get a compile-time warning or error about a value that is never awaited.
func makeGreeting() async -> String {
async let name = fetchName()
return "Hello"
}Fix: Await the child task before returning or before the scope exits.
func makeGreeting() async -> String {
async let name = fetchName()
return "Hello, " + await name
}The fixed version consumes the child task result correctly and keeps the function logically complete.
Mistake 3: Assuming task group results arrive in input order
Task groups return child results as tasks complete, not in the order they were added. This surprises many developers the first time they use them.
Problem: If you append results directly, the output order may change from run to run, which can break code that expects stable ordering.
func loadNames(ids: [Int]) async throws -> [String] {
try await withTaskGroup(of: String.self) { group in
for id in ids {
group.addTask {
return try await fetchName(id: id)
}
}
var names: [String] = []
for try await name in group {
names.append(name)
}
return names
}
}Fix: Store the original index with each task result if order matters.
func loadNames(ids: [Int]) async throws -> [String] {
try await withTaskGroup(of: (Int, String).self) { group in
for (index, id) in ids.enumerated() {
group.addTask {
return (index, try await fetchName(id: id))
}
}
var names = Array(repeating: "", count: ids.count)
for try await (index, name) in group {
names[index] = name
}
return names
}
}The corrected version preserves order explicitly instead of relying on completion timing.
7. Best Practices
Practice 1: Use async let for a small fixed number of tasks
If you know you need exactly two or three independent tasks, async let is usually clearer than a task group.
func loadHomeScreen() async throws -> HomeData {
async let profile = fetchProfile()
async let feed = fetchFeed()
async let messages = fetchMessages()
return try await HomeData(profile: profile, feed: feed, messages: messages)
}This keeps the code close to sequential Swift while still allowing parallel execution.
Practice 2: Use task groups for variable-sized work
When the number of items comes from user input, a network response, or a collection, a task group models the problem naturally.
func parseAll(strings: [String]) async throws -> [Int] {
try await withThrowingTaskGroup(of: Int.self) { group in
for string in strings {
group.addTask {
guard let value = Int(string) else {
throw ParseError.invalidNumber
}
return value
}
}
var values: [Int] = []
for try await value in group {
values.append(value)
}
return values
}
}Using the right abstraction makes the code easier to maintain and reduces accidental complexity.
Practice 3: Let cancellation flow naturally
Structured concurrency works best when you do not fight cancellation. If the parent task is canceled, child tasks should stop as soon as possible.
func loadLargeReport() async throws -> Report {
async let stats = fetchStats()
async let charts = fetchCharts()
return try await Report(stats: stats, charts: charts)
}If the caller cancels this operation, both child tasks are canceled too, which prevents unnecessary work and wasted battery or bandwidth.
8. Limitations and Edge Cases
- async let is best for a small, known number of tasks. It is not a replacement for dynamic task groups.
- Task groups do not guarantee result order. If order matters, you must preserve indexes yourself.
- Child tasks inherit cancellation, priority, and task-local values from the parent, which is usually helpful but can surprise developers expecting isolation.
- Structured concurrency only covers tasks created inside the structure. A detached task is outside that model.
- Long-running child tasks still need cooperative cancellation checks if they perform loops or CPU-heavy work.
- It is possible to make code more complex than necessary by using task groups for simple sequential work.
Note: If an async child task ignores cancellation, the parent can request cancellation, but the child must still reach a suspension point or check for cancellation to stop promptly.
9. Practical Mini Project
In this mini project, we build a simple article loader that fetches a headline, author name, and tag list at the same time, then combines them into a single summary string.
struct ArticleSummary {
let headline: String
let author: String
let tags: [String]
}
func loadArticleSummary() async throws -> ArticleSummary {
async let headline = fetchHeadline()
async let author = fetchAuthor()
async let tags = fetchTags()
return try await ArticleSummary(
headline: headline,
author: author,
tags: tags
)
}
func fetchHeadline() async throws -> String {
return "Concurrency in Swift"
}
func fetchAuthor() async throws -> String {
return "DevDocs10"
}
func fetchTags() async throws -> [String] {
return ["Swift", "Concurrency", "Async Await"]
}This example works because the three child tasks are independent, so Swift can run them together and then wait for all results before constructing the final value.
10. Key Points
- Structured concurrency keeps related async work inside a clear parent-child scope.
- async let is ideal for a small fixed set of child tasks.
- Task groups are ideal for dynamic or collection-based concurrency.
- Child tasks are automatically tied to the parent’s lifetime and cancellation.
- Task group results arrive as tasks complete, not in input order.
11. Practice Exercise
Try this exercise to check your understanding of structured concurrency.
- Create a function that fetches three independent strings in parallel.
- Combine the strings into one sentence.
- Use async let rather than a task group.
- Make the function async and throws.
Expected output: A single combined sentence built from three concurrently loaded values.
Hint: Start all three child tasks before you await any of them.
Solution:
func buildSentence() async throws -> String {
async let part1 = fetchPart("Swift")
async let part2 = fetchPart("structured")
async let part3 = fetchPart("concurrency")
return try await [part1, part2, part3].joined(separator: " ")
}
func fetchPart(_ text: String) async throws -> String {
return text
}12. Final Summary
Swift structured concurrency gives asynchronous code a predictable shape. Instead of spawning unrelated tasks that can outlive the work that created them, you write parent tasks that own and await their children. That makes cancellation, error handling, and reasoning about lifetimes much easier.
Use async let when you have a small number of independent operations, and use task groups when the number of tasks is dynamic or when you need to handle results as they arrive. If you keep child tasks inside their parent scope, you will usually end up with code that is simpler, safer, and easier to maintain.
As a next step, learn how Swift actor isolation and task cancellation work together, because those features build on the same concurrency model and will help you write more robust async code.