# Swift Structured Concurrency: Task Groups, Async Let, and Child Tasks

Source: https://devdocs10.com/articles/swift/swift-structured-concurrency  
By Murat Bo · Updated 2026-10-17 · DevDocs10 (https://devdocs10.com)

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

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.

```swift
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.

```swift
func fetchAll(ids: [Int]) async throws -> [String] {
    try await withThrowingTaskGroup(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.

```swift
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.

```swift
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.

```swift
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 result is enough and the rest should stop early. `next()` returns the first child to finish. If that child threw, `next()` rethrows its error, and throwing out of the group body cancels the remaining child tasks.

### 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.

```swift
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:** Cancelling the parent task does not cancel the detached task. The detached work keeps running, and `loadData()` keeps waiting for it, which makes the code harder to cancel, test, and reason about.

```swift
func loadData() async -> String {
    let task = Task.detached {
        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.

```swift
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 does not force you to await an `async let` binding. If you never await it, the compiler only warns that the value was never used, and when the scope ends Swift implicitly cancels the child task and waits for it, so its work is wasted.

```swift
func makeGreeting() async -> String {
    async let name = fetchName()
    return "Hello"
}
```

**Fix:** Await the child task before returning or before the scope exits.

```swift
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.

```swift
func loadNames(ids: [Int]) async throws -> [String] {
    try await withThrowingTaskGroup(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.

```swift
func loadNames(ids: [Int]) async throws -> [String] {
    try await withThrowingTaskGroup(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.

```swift
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.

```swift
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.

```swift
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.

```swift
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:**

```swift
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.
