top of page

01 · INTRODUCTION

What Is Structured Concurrency in Swift?

The short answer

Structured concurrency is Swift’s system for organising concurrent tasks into a bounded parent-and-child hierarchy. Child tasks are created inside a scope, belong to the parent task running that scope and must finish before the scope can end.

CODE EXAMPLE

func loadLaunchPage() async throws -> LaunchPage {
    async let launch = launchAPI.fetchNextLaunch()
    async let rocket = rocketAPI.fetchRocketDetails()

    return try await LaunchPage(
        launch: launch,
        rocket: rocket
    )
}

The two async let declarations create child tasks. They can make progress concurrently, but neither child becomes independent work with an unknown lifetime. Both belong to this call of loadLaunchPage().

The central idea: structured concurrency gives concurrent work an owner, a scope and a point by which it must be finished.

02 · TUTORIAL

The Problem: Concurrent Work Can Lose Its Owner

Concurrent code becomes difficult to reason about when a function starts work and returns without establishing who owns that work afterwards.

CODE EXAMPLE

func beginLoading() {
    Task {
        let launch = try await launchAPI.fetchNextLaunch()
        await store.save(launch)
    }
}

The new task is unstructured. It can continue after beginLoading() returns. The function does not wait for its result, and its caller cannot see from the return type that work remains in progress.

This design can be appropriate when the task genuinely belongs to a longer-lived feature. It also creates questions that someone must answer:

• Who keeps the task handle?

• Who cancels the task when its result is no longer needed?

• Where does an error go?

• What prevents the work from outliving the feature that started it?

Structured concurrency answers these questions through the shape of the code.

03 · TUTORIAL

Tasks Form a Tree

A task performing one operation can create child tasks for independent pieces of that operation.

EXECUTION DIAGRAM

Parent task: load launch page
  ├─ Child task: fetch launch
  └─ Child task: fetch rocket details

The children may themselves call asynchronous functions or create their own structured child tasks. The result is a task tree rather than a flat collection of unrelated background operations.

EXECUTION DIAGRAM

Refresh feature
  ├─ Fetch launch
  │    ├─ Request launch data
  │    └─ Decode launch
  └─ Fetch rocket
       ├─ Request rocket data
       └─ Decode rocket

This diagram is conceptual. Not every asynchronous call creates a new task. Calling an async function normally continues as part of the current task. A new branch appears only when code uses a child-task construct such as async let or a task group.

04 · TUTORIAL

An async Call Does Not Create a Child Task

Two ordinary awaited calls execute one after the other in the same task.

CODE EXAMPLE

let launch = try await launchAPI.fetchNextLaunch()
let rocket = try await rocketAPI.fetchRocketDetails()

The second call is not started until the first call returns. Each operation may suspend and allow unrelated tasks to run, but these two requests are sequential from this task’s point of view.

EXECUTION DIAGRAM

Current task: fetch launch ─▶ receive launch ─▶ fetch rocket ─▶ receive rocket

Structured concurrency introduces additional child tasks when the operations are independent and should be in progress together.

05 · TUTORIAL

async let Creates a Fixed Set of Child Tasks

Use async let when the number and purpose of the child operations are known while writing the function.

CODE EXAMPLE

async let launch = launchAPI.fetchNextLaunch()
async let rocket = rocketAPI.fetchRocketDetails()
async let weather = weatherAPI.fetchLaunchWeather()

Each declaration starts evaluating its initializer in a child task. The parent can continue until it needs the results.

CODE EXAMPLE

let page = try await LaunchPage(
    launch: launch,
    rocket: rocket,
    weather: weather
)

The await marks the point where the parent requires the child values. If some children have already finished, their results are available. If others are unfinished, the parent may suspend until they complete.

Child task is a task whose lifetime and concurrency context are connected to a parent task.

06 · TUTORIAL

The Child Tasks Are Concurrent, Not Necessarily Parallel

The launch, rocket and weather requests can all be in progress during the same period.

EXECUTION DIAGRAM

Launch child:  request ───────── waiting ── result
Rocket child:  request ─── waiting ───────── result
Weather child: request ────── waiting ── result

This is concurrency: the operations have overlapping lifetimes and can make progress independently. It is not a promise that three processor cores execute their instructions simultaneously.

Network operations spend much of their lifetime suspended. Swift’s runtime schedules each eligible period of work on an appropriate executor and available system thread.

07 · TUTORIAL

The Scope Owns the Children

An async let child cannot escape the scope that declared it.

CODE EXAMPLE

func loadLaunchPage() async throws -> LaunchPage {
    async let launch = launchAPI.fetchNextLaunch()
    async let rocket = rocketAPI.fetchRocketDetails()

    return try await LaunchPage(
        launch: launch,
        rocket: rocket
    )
}

Before this function returns, both child tasks are finished. The caller does not receive a page while an invisible child continues modifying related state somewhere else.

Even if the source code leaves the scope without explicitly awaiting an async let, Swift does not abandon the child. Unfinished children are cancelled and the scope waits for them to finish before exiting.

A structured scope does not leak unfinished child tasks.

08 · TUTORIAL

The Parent Waits Without Blocking a Thread

Waiting for child tasks does not mean synchronously blocking the system thread.

CODE EXAMPLE

let page = try await LaunchPage(
    launch: launch,
    rocket: rocket
)

If a child is unfinished, the parent task can suspend. Its thread becomes available for other eligible jobs. When the required results are ready, the parent becomes eligible to resume.

EXECUTION DIAGRAM

Parent task: create children ── suspend ───────── combine results
Thread:      run parent ─────── run other work ─ run parent

Structured concurrency controls task lifetime. It does not change suspension into thread blocking.

09 · TUTORIAL

Results Flow Up the Task Tree

Child tasks produce values that the parent collects and combines.

CODE EXAMPLE

func loadLaunchPage() async throws -> LaunchPage {
    async let launch: Launch = launchAPI.fetchNextLaunch()
    async let rocket: Rocket = rocketAPI.fetchRocketDetails()

    let (loadedLaunch, loadedRocket) = try await (launch, rocket)

    return LaunchPage(
        launch: loadedLaunch,
        rocket: loadedRocket
    )
}

The children do not need to mutate shared parent variables. Each returns a sendable value. The parent owns the final act of composing those values into the feature result.

This is a clean architectural pattern:

EXECUTION DIAGRAM

Parent divides operation
        │
        ▼
Children return values
        │
        ▼
Parent combines result

10 · TUTORIAL

Errors Also Flow Through the Parent

If a child operation throws, the error is observed when the parent awaits that child’s value.

CODE EXAMPLE

async let launch = launchAPI.fetchNextLaunch()
async let rocket = rocketAPI.fetchRocketDetails()

let page = try await LaunchPage(
    launch: launch,
    rocket: rocket
)

If either required result fails, loadLaunchPage() cannot produce a complete page and can throw to its own caller. Structured concurrency connects the child failure to the operation that created the child.

When the scope exits early because of an error, unfinished child tasks are cancelled and awaited before control leaves the scope. They do not continue as forgotten work.

This does not mean an error in one child instantaneously interrupts every sibling. Cancellation is a signal, and Swift cancellation is cooperative.

11 · TUTORIAL

Cancellation Flows Downward

When a parent task is cancelled, its structured child tasks are also marked as cancelled.

EXECUTION DIAGRAM

Cancelled parent
  ├─ cancellation signal ─▶ launch child
  ├─ cancellation signal ─▶ rocket child
  └─ cancellation signal ─▶ weather child

The children must still respond. A suspending API may throw CancellationError, or synchronous work can check explicitly:

CODE EXAMPLE

func normalise(_ launches: [Launch]) throws -> [Launch] {
    var result: [Launch] = []

    for launch in launches {
        try Task.checkCancellation()
        result.append(launch.normalised())
    }

    return result
}

Structure propagates the cancellation state. Cooperation makes the work actually stop.

12 · TUTORIAL

Children Inherit Context from Their Parent

Structured child tasks inherit important task context, including priority and task-local values. This allows related work to remain part of one operation without manually copying every piece of execution metadata.

Priority is a scheduling hint, not a guaranteed order. A child inheriting a high priority does not become a new high-priority thread, and it does not promise immediate execution.

Actor isolation also remains a separate concept. Creating child tasks does not make expensive synchronous work cheap or automatically choose background execution.

13 · TUTORIAL

Task Groups Handle a Dynamic Number of Children

async let is ideal when the shape is fixed. Sometimes the number of child operations comes from runtime data, such as a list of launch providers.

CODE EXAMPLE

func loadLaunches(
    from providers: [any LaunchProviding]
async throws -> [Launch] {
    try await withThrowingTaskGroup(of: [Launch].self) { group in
        for provider in providers {
            group.addTask {
                try await provider.fetchLaunches()
            }
        }

        var launches: [Launch] = []

        for try await providerLaunches in group {
            launches += providerLaunches
        }

        return launches
    }
}

The task group creates one structured child for each provider. The group’s scope owns every child and cannot return until the group is empty.

Task-group results arrive in completion order rather than provider-submission order. The complete task-group API, result collection and partial-failure design deserve the next dedicated article.

14 · TUTORIAL

Structured and Unstructured Tasks Are Different

Task { } creates an unstructured task, not a child task whose lifetime is bounded by the current function.

CODE EXAMPLE

let refreshTask = Task {
    try await launchAPI.fetchNextLaunch()
}

The task returns a handle. Code can await its value or cancel it, but the current lexical scope does not automatically enforce those responsibilities.

Task.detached { } is even more independent because it does not inherit actor isolation, priority and task-local context in the same way.

The distinction is not “good tasks” versus “bad tasks.” It is ownership:

• Use structured children when work belongs to the current operation.

• Use an unstructured task when work belongs to a longer-lived owner that will retain and manage its handle.

• Use detached work only when independence from the surrounding task context is genuinely required.

15 · TUTORIAL

A Complete Launch Feature

CODE EXAMPLE

struct LaunchPage: Sendable {
    let launch: Launch
    let rocket: Rocket
    let weather: LaunchWeather
}

struct LaunchPageLoader {
    let launchAPI: LaunchAPI
    let rocketAPI: RocketAPI
    let weatherAPI: WeatherAPI

    func load() async throws -> LaunchPage {
        async let launch = launchAPI.fetchNextLaunch()
        async let rocket = rocketAPI.fetchRocketDetails()
        async let weather = weatherAPI.fetchLaunchWeather()

        return try await LaunchPage(
            launch: launch,
            rocket: rocket,
            weather: weather
        )
    }
}

@MainActor
final class LaunchFeature: ObservableObject {
    @Published private(set) var page: LaunchPage?
    @Published private(set) var isLoading = false

    private let loader: LaunchPageLoader

    init(loader: LaunchPageLoader) {
        self.loader = loader
    }

    func refresh() async throws {
        isLoading = true
        defer { isLoading = false }

        page = try await loader.load()
    }
}

The feature owns visible state. The loader owns one bounded loading operation. Its three child tasks fetch independent values concurrently, and the loader combines them before returning one sendable page.

No child can outlive load(). An error remains connected to the refresh operation. Cancellation of the refresh flows into the loading tree.


16 · TUTORIAL

The Complete Mental Model

EXECUTION DIAGRAM

Parent task enters structured scope
                 │
                 ▼
          creates child tasks
          ┌──────┼──────┐
          ▼      ▼      ▼
        child  child  child
          │      │      │
          └──────┼──────┘
                 ▼
       values or errors flow up
                 │
                 ▼
scope exits only after children finish

Context and cancellation flow from parent to children. Values and errors flow from children to the parent. The lexical scope bounds every child’s lifetime.

This structure makes concurrent code locally understandable: reading one function reveals the work it starts and the point by which that work must end.


17 · TUTORIAL

What to Remember

• Structured concurrency organises tasks into a parent-and-child hierarchy.

• A structured child cannot outlive the scope that created it.

• An ordinary async call remains in the current task.

async let creates a fixed, known set of child tasks.

• Task groups create a dynamic number of child tasks.

• The parent can suspend while waiting without blocking a thread.

• Values and errors flow from children back to the parent.

• Cancellation flows down the task tree but remains cooperative.

• Structured concurrency permits concurrency; it does not guarantee parallel execution.

Task { } and Task.detached { } create unstructured work with different ownership requirements.


18 · TUTORIAL

Frequently Asked Questions

Does calling an async function create a child task?

No. An ordinary asynchronous call continues as part of the current task. Use async let or a task group to create structured child tasks.

Can an async let child outlive its function?

No. The child must finish before its declaring scope exits. An unfinished child is cancelled and awaited when the scope leaves without consuming it.

Does async let guarantee parallelism?

No. It creates concurrent child tasks. Actual simultaneous execution depends on eligibility, executors and available system resources.

What happens when a child throws?

The error is observed when the parent awaits that child’s result. If the scope exits by throwing, unfinished sibling work is cancelled and awaited.

Does cancelling a parent immediately stop its children?

No. The children are marked as cancelled. They stop when suspending APIs respond to cancellation or their code checks and cooperates.

Is Task { } structured concurrency?

No. It creates an unstructured task. Its lifetime is represented by a handle and is not automatically bounded by the lexical scope that created it.


20 · TUTORIAL

Continue Learning

async let handles a fixed set of child operations. The next article, What Is a Task Group in Swift?, will explain how to create a runtime-sized collection of child tasks, consume results as they complete and design failure and cancellation behaviour for multiple launch providers.

21 · TUTORIAL

Download the Xcode Playground

Use the accompanying playground to load launch, rocket and weather data sequentially, then convert the operation to three async let children. Observe the task tree, introduce one failure, cancel the parent and finally compare the bounded operation with an unstructured Task.

bottom of page