01 · INTRODUCTION
What Is a Child Task in Swift?
The short answer
A child task is a task created by Swift's structured concurrency features whose lifetime is bounded by the scope that created it. The surrounding task cannot finish that scope while one of its child tasks is still running.
CODE EXAMPLE
func loadLaunchPage() async throws -> LaunchPage {
async let launches = launchAPI.fetchUpcoming()
async let rockets = rocketAPI.fetchRockets()
let (loadedLaunches, loadedRockets) = try await (launches, rockets)
return LaunchPage(
launches: loadedLaunches,
rockets: loadedRockets
)
}
The two async let declarations create child tasks. They may make progress concurrently, but loadLaunchPage() cannot return until both have finished. Their results, errors, cancellation state and inherited context remain connected to the operation that created them.
The central rule
A child task may run concurrently with its parent, but it cannot outlive the structured scope that created it.
02 · TUTORIAL
Why the Word “Child” Matters
The word child does not mean “a task created somewhere inside another task.” It describes a specific relationship enforced by Swift's structured concurrency system.
That relationship gives the program a task tree:
EXECUTION DIAGRAM
Parent task: loadLaunchPage()
├─ Child task: fetchUpcoming()
└─ Child task: fetchRockets()
The parent scope cannot finish
until both child tasks finish.
Conceptual diagram: this tree shows ownership and lifetime, not threads. The parent and children are tasks managed by the concurrency runtime. They may execute on different threads over time, and concurrent progress does not guarantee parallel execution.
This shape lets you reason locally. When loadLaunchPage() returns or throws, you know that it has not left either request running behind it.
03 · TUTORIAL
How Do You Create a Child Task?
Swift's two familiar ways to create child tasks are:
• async let, for a fixed number of child operations whose results can have different types;
• withTaskGroup or withThrowingTaskGroup, for a dynamic number of child operations whose results share a type.
CODE EXAMPLE
// A fixed child task
async let launches = launchAPI.fetchUpcoming()
// A dynamic collection of child tasks
await withTaskGroup(of: LaunchSummary.self) { group in
for identifier in identifiers {
group.addTask {
await loadSummary(for: identifier)
}
}
for await summary in group {
print(summary)
}
}
Both forms introduce concurrency inside a visible scope. That scope is responsible for all of the children created within it.
Important Terminology
Structured concurrency means concurrent work is arranged into scopes and parent–child relationships whose lifetimes Swift can enforce.
04 · TUTORIAL
A Child Task Has a Bounded Lifetime
Bounded lifetime is the defining guarantee. A child can finish before its parent reaches the end of the scope, but it cannot continue running after that scope has exited.
CODE EXAMPLE
func prepareLaunch() async {
async let checklist = loadChecklist()
print("Preparing the launch")
// No explicit await appears here.
}
Swift does not simply abandon checklist when execution reaches the closing brace. Because the value was never explicitly awaited, the child is cancelled and then implicitly awaited before prepareLaunch() returns.
Cancellation remains cooperative. If loadChecklist() ignores cancellation, the parent still waits for it to finish. The structured guarantee is about lifetime; it is not a force-stop mechanism.
A task group provides the same boundary. A group scope does not return until every task added to that group has completed. If the group body returns normally while children remain, the group waits for them; it does not automatically cancel them merely because their results were not collected.
05 · TUTORIAL
Results Flow Back to the Parent
A child task performs one part of a larger operation. Its value normally flows back into the parent, where the pieces are assembled into the feature's result.
CODE EXAMPLE
func loadLaunchPage() async throws -> LaunchPage {
async let launches = launchAPI.fetchUpcoming()
async let rockets = rocketAPI.fetchRockets()
async let weather = weatherAPI.fetchCapeCanaveralForecast()
let (loadedLaunches, loadedRockets, loadedWeather) =
try await (launches, rockets, weather)
return LaunchPage(
launches: loadedLaunches,
rockets: loadedRockets,
weather: loadedWeather
)
}
Each declaration begins evaluating its initializer in a child task. The tuple await is the point where the parent requires all three values. If a value is already ready, reading it does not need to suspend; if it is not ready, the parent can suspend while the runtime allows other work to progress.
async let is especially clear here because the number and purpose of the operations are known when the function is written.
06 · TUTORIAL
Errors Are Observed at a Result Boundary
If a child throws, the error completes that child task. It does not teleport into the parent at the exact instant it is thrown. The parent observes it when it awaits that async let value or retrieves the corresponding task-group result.
CODE EXAMPLE
func loadLaunchPage() async throws -> LaunchPage {
async let launches = launchAPI.fetchUpcoming()
async let rockets = rocketAPI.fetchRockets()
// A child error is observed here.
let values = try await (launches, rockets)
return LaunchPage(launches: values.0, rockets: values.1)
}
If that error leaves the surrounding scope, outstanding child tasks are cancelled and awaited before the scope throws. This prevents a sibling from being left behind after the larger operation has failed.
A throwing task group follows the same structured boundary, but its results are consumed through group.next() or asynchronous iteration. A child failure becomes visible when its completion is retrieved. If that error is thrown out of the group body, the remaining children are cancelled and awaited.
A thrown child error and sibling cancellation are not the same event.
The error is first stored as that child's result. Remaining children are cancelled when the error is allowed to leave the structured scope, or when the group is otherwise cancelled.
07 · TUTORIAL
Cancellation Flows Down the Task Tree
When a parent task is cancelled, cancellation propagates to its child tasks. This makes it possible to cancel one feature operation and signal all of its structured suboperations together.
CODE EXAMPLE
func buildLaunchReport() async throws -> LaunchReport {
async let manifest = fetchManifest()
async let weather = fetchWeather()
try Task.checkCancellation()
return try await LaunchReport(
manifest: manifest,
weather: weather
)
}
Cancellation is a request, represented by task state. A child must reach a cancellation-aware suspension point, call Task.checkCancellation(), inspect Task.isCancelled, or use another cancellation-aware API to stop early.
The direction matters. Parent cancellation propagates down to children. A single child being cancelled does not automatically cancel its parent or all of its siblings. The parent decides how a child's result affects the larger operation.
08 · TUTORIAL
Child Tasks Inherit Useful Context
Child tasks begin with context from their parent. In particular, they inherit the parent's task priority and current task-local values. If the parent is already cancelled, a newly created child begins in the cancelled state.
CODE EXAMPLE
func refreshLaunches() async throws {
print("Parent priority:", Task.currentPriority)
async let launches: [Launch] = {
print("Child priority:", Task.currentPriority)
return try await launchAPI.fetchUpcoming()
}()
_ = try await launches
}
Priority remains scheduling information, not an execution-order guarantee. Task-local values are contextual values that can follow a structured task tree; the next article will examine them directly.
Do not turn the word inherit into “the child runs exactly where the parent runs.” Actor isolation and executor selection have their own rules. The parent–child relationship primarily gives you structured lifetime, cancellation and contextual propagation; it does not prove that the parent and child execute on the same thread or actor.
09 · TUTORIAL
Task { } Does Not Create a Child Task
This is the most important boundary in the article:
CODE EXAMPLE
func refresh() async {
Task {
await analytics.recordRefresh()
}
}
The task is written inside an async function, but Task { } creates an unstructured task. The function does not implicitly wait for it before returning. Its lifetime is not bounded by the lexical scope of refresh().
Creation form
async let
Child task?
Yes
Bounded by scope?
Yes
How results are observed
Await the binding
Creation form
group.addTask
Child task?
Yes
Bounded by scope?
Yes
How results are observed
group.next() or iteration
Creation form
Task { }
Child task?
No
Bounded by scope?
No
How results are observed
Await the returned task handle
Creation form
Task.detached { }
Child task?
No
Bounded by scope?
No
How results are observed
Await the returned task handle
Task { } can inherit actor isolation, priority and task-local values from its creation context, but that contextual inheritance does not create a structured parent–child lifetime. Context inheritance and structured lifetime are separate ideas.
10 · TUTORIAL
Use async let for a Fixed Set of Children
Choose async let when a function knows the number of concurrent operations in advance and needs their individual, possibly different, result types.
CODE EXAMPLE
struct LaunchDashboardLoader: Sendable {
let launchAPI: LaunchAPI
let rocketAPI: RocketAPI
let weatherAPI: WeatherAPI
func load() async throws -> LaunchDashboard {
async let launches = launchAPI.fetchUpcoming()
async let rockets = rocketAPI.fetchRockets()
async let weather = weatherAPI.fetchCapeCanaveralForecast()
let (launches, rockets, weather) =
try await (launches, rockets, weather)
try Task.checkCancellation()
return LaunchDashboard(
launches: launches,
rockets: rockets,
weather: weather
)
}
}
This application-shaped function has one clear operation: load the dashboard. Its three child tasks are implementation details that cannot escape the operation. If the dashboard load is cancelled, the cancellation request reaches all three children.
Use separate async let declarations when you want the operations themselves to be concurrent. A single declaration whose initializer performs several awaited calls still represents one child task, and those calls may execute sequentially inside it.
11 · TUTORIAL
Use a Task Group for Dynamic Children
When the number of operations depends on runtime data, use a task group. Each call to group.addTask creates one child task owned by the group scope.
CODE EXAMPLE
protocol LaunchSummaryProvider: Sendable {
func fetchSummary() async throws -> LaunchSummary
}
func loadSummaries(
from providers: [any LaunchSummaryProvider]
) async throws -> [LaunchSummary] {
try await withThrowingTaskGroup(of: LaunchSummary.self) { group in
for provider in providers {
group.addTask {
try await provider.fetchSummary()
}
}
var summaries: [LaunchSummary] = []
for try await summary in group {
summaries.append(summary)
}
return summaries
}
}
Group results arrive in completion order, not insertion order. If the output must match the providers' original order, include an index in each child result and reorder the collected values deliberately.
The task group itself cannot escape its scope. By the time withThrowingTaskGroup returns or throws, every added child has finished.
Child Tasks Describe Feature Architecture
Structured child tasks encourage an architectural question: What work belongs to this operation?
EXECUTION DIAGRAM
Load Launch Dashboard
├─ Fetch upcoming launches
├─ Fetch rocket catalogue
└─ Fetch local weather
Return LaunchDashboard only after
the whole operation has settled.
This is more informative than imagining three anonymous pieces of work sent to background threads. The feature operation owns a tree of tasks. Its public result is produced only after that tree has settled, and cancellation can flow through the same structure.
Not every asynchronous side effect belongs inside that tree. A long-lived observer, an application service or deliberately independent work may need an unstructured task with an explicitly managed handle and lifetime. The choice should come from ownership, not from a desire to “put work in the background.”
12 · TUTORIAL
Common Misunderstandings
“Any Task created inside another Task is a child.”
No. Task { } creates an unstructured task. Child tasks come from structured constructs such as async let and task groups.
“The parent can return and Swift cleans the child up later.”
No. The structured scope waits until the child has actually completed. Cancellation may be requested first, but cancellation is cooperative.
“If one child throws, every sibling stops immediately.”
No. The error becomes that child's result. When the error leaves the structured scope, remaining children are cancelled and awaited. They still need to cooperate with cancellation.
“Child tasks run on child threads.”
No. The relationship is between tasks, not threads. The runtime schedules task jobs through executors, and a task may execute on different system threads over its lifetime.
“If I do not use the result, the task is fire-and-forget.”
No. An unused async let remains a child task. On scope exit it is cancelled and awaited, even if its value and error are discarded.
13 · TUTORIAL
What to Remember
• A child task is created by structured concurrency and has a scope-bounded lifetime.
• async let and group.addTask create child tasks.
• Task { } and Task.detached { } create unstructured tasks, not child tasks.
• A structured scope cannot finish while one of its children is still running.
• Parent cancellation propagates downward, but cancellation remains cooperative.
• Child errors are observed when their results are awaited or collected.
• Child tasks inherit priority and task-local values; that does not mean they use the same thread.
• Use async let for a fixed set of results and task groups for a runtime-sized set.
14 · TUTORIAL
Frequently Asked Questions
Is Task { } a child task in Swift?
No. Task { } creates an unstructured task, even when it appears inside another task or async function. It may inherit context, but its lifetime is not automatically bounded by the surrounding scope.
Can a child task outlive its parent?
No. The scope that creates a child task does not finish until that child has completed. This is the central lifetime guarantee of structured concurrency.
What happens if I forget to await an async let?
When the scope exits, Swift cancels and implicitly awaits an async let that was not awaited. Its unused value or error is discarded.
Does cancelling a parent immediately stop its children?
No. Cancellation marks the children as cancelled. They must cooperate by checking cancellation or using cancellation-aware APIs before they stop.
Does a child task inherit actor isolation?
Do not use the child relationship as proof of actor isolation. Structured lifetime and isolation are different properties, and the isolation of a child operation follows the rules of the construct and declarations involved.
When should I choose async let instead of a task group?
Use async let when the number of operations is fixed and their result types may differ. Use a task group when the number of similar child operations is determined at runtime.
15 · TUTORIAL
References
16 · TUTORIAL
Continue Learning
Child tasks inherit more than cancellation and priority. The next article, What Are Task-Local Values in Swift?, explains how contextual values can flow through a structured task tree without being passed through every function parameter.
17 · TUTORIAL
Download the Xcode Playgrounds
Use Understanding Child Tasks.playground to follow the guided examples, then open Child Task Challenges.playground to practise scope boundaries, error flow and cooperative cancellation.
