Tutorials › Launch Lab Notes › Chapter 10

Who Keeps Working: .task Cancels, onAppear + Task Does Not

LabChapter 10 of the Launch Lab Notes22 minAugust 19, 2026Intermediate

You tapped Back. Did the work stop?

Chapter 9 was about what a screen draws before data arrives. This one is about what keeps running after the screen is gone. The answer is not in the network layer. It is in how the Task was started.

Six frames of host/away cycles: both counters start together, then after leave the rose lane keeps climbing while mint freezes; by visit 4 the rose counter is at 268 with four orphans and mint is stopped at 76.

Same loop, two lifetimes

Both lanes do the same thing: add one to a counter every 100 ms. The only difference is how the work is attached to the view.

// Mint — structured. SwiftUI owns this task.
.task(id: visit) {
  while !Task.isCancelled {
    ticks += 1
    try await Task.sleep(nanoseconds: 100_000_000)
  }
}
 
// Rose — unstructured. You own this task, and this bench refuses to cancel it.
.onAppear {
  workers += 1
  Task {
    while true {
      ticks += 1
      try? await Task.sleep(nanoseconds: 100_000_000)
    }
  }
}

The parent toggles hosted on a timer so capture does not need a Back tap. When hosted becomes false, both lanes leave the hierarchy together. That is the experiment.

HOSTED visit 1: both lanes show WORKING and 19 ticks. Footer orphans 1.

At visit 1, while still hosted, both counters read 19. They really are the same loop. The footer already says orphans 1 — the rose worker is counted the moment it starts, because nothing will ever tear it down.

Leave once

AWAY visit 1: left panel 41 still ticking, right panel 25 stopped. Footer orphans 1.

One away beat later:

LaneTicksLabel
onAppear + Task41still ticking
.task25stopped

Mint froze at the leave. Rose kept the 100 ms metronome going in the dark — 16 extra ticks with nobody watching. That gap is the whole chapter.

.task is not sugar for onAppear { Task { } }

The modifiers look interchangeable in a code review. They are not. .task ties the unstructured unit of work to the view's lifetime and cancels at every suspension point when the view goes away. onAppear { Task { } } starts work the hierarchy no longer knows about. Back does not mean stop unless you wire that yourself.

Come back — and double the damage

The filmstrip is where the control gets mean. Each revisit starts another rose Task without cancelling the old ones:

FramePhaseRoseMintOrphans
1HOSTED visit 116161
2AWAY visit 138 (still ticking)31 (stopped)1
3HOSTED visit 273 · WORKING ×2452
4HOSTED visit 3121 · WORKING ×3513
5AWAY visit 3190 (still ticking)61 (stopped)3
6AWAY visit 4268 (still ticking)76 (stopped)4

Four orphans. Rose is ticking four times as fast as a single loop. Mint still has exactly one lifetime per visit — cancel, then a fresh .task(id: visit) on the way back.

Cancellation is cooperative

Task.cancel() does not yank the thread. It sets a flag. The mint loop notices because Task.sleep throws CancellationError and because the while !Task.isCancelled guard runs between ticks. A while true that never checks — the rose lane — will run until the process dies. Structured concurrency only helps if your code cooperates at suspension points.

Why the sources keep saying "structured"

The Modern Concurrency corpus hammers this for a reason: when work starts inside .task, child suspensions inherit that parent, and navigating away cancels the tree. When work starts as a free-floating Task from onAppear, you have opted into the old world — manual cancel(), or leaks that look like "the battery got worse after we shipped the feed."

This bench does not print to the Xcode console. It prints the leak on the screen, in the same frame as the well-behaved lane.

Mini-exercise

In LeakyLane.onAppear, store the Task in a @State property and call task?.cancel() from .onDisappear. Re-run the away shot. Rose should freeze near mint. Then delete the onDisappear again and watch 41 vs 25 return — that single missing line is the entire bug class.

Challenges

  1. Replace the tick loop with a real URLSession.bytes stream. Confirm .task stops the console noise on leave and the unstructured Task keeps downloading (then fix it).
  2. Use Task.checkCancellation() instead of Task.isCancelled and show that a throwing fetch maps cancellation into the same .failed phase from chapter 9.
  3. Start the leaky work with Task.detached and photograph whether priority/cancellation inheritance changed anything visible on this bench (spoiler: the orphan count still climbs).
  4. Add a third lane that uses .task but ignores cancellation inside a tight CPU loop with no await — prove that cooperative cancellation needs a suspension point.
  5. Wire an XCUITest that fails if orphans is ever greater than visit after an away beat (the invariant this control violates on purpose).

Key Points

Next up: actors — a counter hammered from many tasks, a plain class racing to the wrong total next to an actor that reaches the right one. The wrong number is the demo.

SwiftUI
SwiftUI tutorials for building native app screens, layouts, navigation, and state-driven interfaces.
Swift
Swift fundamentals for app developers who want to understand the language behind real iOS and macOS apps.
Ship iOS
Shipping workflows for iOS apps.
📚 Go deeper with LIPAI WANG’s hands-on Udemy bootcampsBrowse all courses →
← Ch 9: Before the Data ArrivesCh 11: The Wrong Total→
SwiftUIUltimate SwiftUI SeriesSwiftUI tutorials for building native app screens, layouts, navigation, and state-driven interfaces.SwiftUltimate Swift SeriesSwift fundamentals for app developers who want to understand the language behind real iOS and macOS apps.Ship iOSShip iOS Apps SeriesShipping workflows for iOS apps: signing, TestFlight, App Store Connect, CI, and release hygiene.

Ship your apps faster

When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.

Try Simple App Shipper
5 free articles remainingSubscribe for unlimited access