← All writing
Swift · Concurrency · 8-minute read

What Swift actually waits for

A loop, three uploads and one useful question: when the code says “done,” what has actually finished?

Swift playground, automated check and source code · ZIP

Watch the 47-second visual guide. It is silent; every point is shown on screen and explained below.
01 / In sequencefor-in + await

The next item waits for the current call.

02 / Independent tasksforEach + saved Task handles

You decide when to await each task.

03 / Structured childrenwithTaskGroup

The group scope waits for its children.

01 / Control flow

Start with for-in and forEach

Both visit an array. A for-in loop gives you break, continue and an await point. forEach calls a synchronous closure for each item.

for-in stops at Banana
for item in cart {
    if item == "Banana" { break }
    print(item)
}
Prints Apple.
forEach visits Mango next
cart.forEach { item in
    if item == "Banana" { return }
    print(item)
}
Prints Apple, Mango.

break exits the loop. return exits only Banana’s closure call; forEach continues with Mango. That is why return inside forEach is not a way to stop the entire iteration.

02 / In sequence

When order matters, await in the loop

for item in cart {
    _ = await upload(item)
}

Apple’s call returns before Banana starts, and Banana’s returns before Mango starts. Standard forEach cannot put await upload(item) directly in its synchronous closure.

03 / Independent tasks

Save handles when you want control later

var handles: [Task<UploadOutcome, Never>] = []

cart.forEach { item in
    handles.append(Task {
        await upload(item, shouldFail: item == "Banana")
    })
}

print("caller continued")

var handleOutcomes: [UploadOutcome] = []
for handle in handles {
    handleOutcomes.append(await handle.value)
}

forEach returns after creating the tasks. The caller moves on while uploads may still be running. Each saved handle lets you wait for that task’s result. Awaiting every handle tells you when all three have ended.

The last for loop does not make uploads sequential. All three tasks were created before it began. You are waiting for already-running work.

04 / Structured children

Let a task group own the batch

var groupOutcomes: [UploadOutcome] = []

await withTaskGroup(of: UploadOutcome.self) { group in
    cart.forEach { item in
        group.addTask {
            await upload(item, shouldFail: item == "Banana")
        }
    }

    for await outcome in group {
        groupOutcomes.append(outcome)
    }
}

print("group returned")

These uploads also overlap. The for await loop collects results as children finish; their order may differ from the cart’s order. The group scope waits for every child before returning, even if you do not collect results.

Both concurrent approaches know when all uploads end.With saved handles, you store and await the tasks. With a task group, those tasks belong to one scope, which waits for them.
05 / Cancellation

Ask tasks to stop, then await their response

With saved handles, cancel the tasks you own individually:

handles.forEach { $0.cancel() }
for handle in handles {
    _ = await handle.value
}

Inside a group, group.cancelAll() requests cancellation of its remaining children. The playground calls it after the first upload finishes. From outside, save a handle to the parent Task, then call parent.cancel(); cancellation propagates to its group children.

_ = await group.next()  // one child finished
group.cancelAll()       // ask the others to stop

Cancellation is cooperative. It is a request, not an instant stop. The playground’s simulated upload waits with Task.sleep, which responds to cancellation; real upload code must do the same. A group still waits for children that have not ended.

06 / Result

“All ended” does not mean “all succeeded”

Banana deliberately fails in both concurrent playground examples. Both approaches report the same result: 2 succeeded, 1 failed. Waiting tells you the work ended; the returned UploadOutcome tells you what happened.

A real upload should return a result or throw. Do not show “Upload complete” simply because every task has stopped.

07 / One more connection

Why forEach is a higher-order function

forEach accepts a function—the closure you write—as input. It owns the traversal; you supply the action. That is the purpose of a higher-order function: reuse the repeated procedure while changing the rule or action.

The playground also defines countWhere. It visits every element and counts those matching a rule you pass in:

let longNames = cart.countWhere { $0.count > 5 }
// 1: Banana

Run the same examples

The ZIP contains the Xcode playground, source, and an automated check that executes the playground page. Start with the for-in example, then compare the two ways of waiting for all uploads.

Download Swift Upload Lab