A Swift Tasks timeline from VinylCrate's launch showing each task name over the label Instruments guessed for it

Phantom Lord: Naming Swift Tasks for Instruments and LLDB

Instruments doesn't show VinylCrate's unnamed tasks as numbers. It guesses, and one set of guesses hid a backfill among five identical cloud-sync rows. I named 156 task sites in one pass and recorded the same launch again.

Five rows in my Instruments trace read CloudSyncMonitor.reconcileIfReady(). Same label, all within about ten seconds of VinylCrate’s cold launch. They look like five passes of the same retry. Four of them were. The fifth was a completely different task from a different file.

None of VinylCrate’s tasks had names. Not the Task {} calls, not the Task.detached calls, and not the children of its three task-group fan-outs. I figured that meant the Swift Tasks instrument would show me a wall of numbers. In Xcode 27 it doesn’t. It guesses. Each unnamed task gets labeled with the nearest symbolicated frame from where it was created, and the guesses are good enough that you stop questioning them. That’s the problem. A wrong label you trust is worse than a number you know you have to look up.

So I named every one of them: 156 creation sites across 70 files, in one pass, with no behavior changes. Then I recorded the same 25-second cold launch in Instruments’ Swift Concurrency template before and after, on an iPhone 17 Pro simulator running iOS 27.2, and stopped in LLDB inside some of the named tasks. Everything below comes from those runs, built with Xcode 27.2 and Swift 6.4.2. (I covered the rest of 6.4 in Swift 6.4’s Quiet Wins.)

Breadfan — The SE-0469 Task Naming API

Task naming is SE-0469, and it landed in Swift 6.2. It’s one new parameter, name: String? = nil, and it comes first on Task.init, Task.detached, Task.immediate, and Task.immediateDetached. Here’s the task behind that fifth row:

monitor.onQuiesced = { [weak container] in
    guard let collection = container?.collection else { return }
    Task(name: "master-year-backfill-on-quiesce") { await collection.backfillMasterYears() }
}

Task groups are a little different. Every group flavor has addTask(name:) and addTaskUnlessCancelled(name:), but on those the name: parameter has no default. You have to pass it to get the naming version.

Availability is friendlier than I expected. The naming overloads are @_alwaysEmitIntoClient, so they compile into your binary instead of calling into a newer runtime. Task(name:) and Task.detached(name:) go all the way back to iOS 13. addTask(name:) needs iOS 18, and Task.immediate is iOS 26 to begin with. For an app on iOS 26 none of that needs a check. Reading a name back is where the OS version matters. Task.name, the static property that returns the current task’s name as a String?, needs iOS 26.0. task.name on a Task handle came in a later amendment to SE-0469, and in the iOS 27.2 SDK it’s iOS 27.0 and up.

VinylCrate deploys to iOS 26.0 with Swift 6 strict concurrency, so naming and Task.name work everywhere it runs. The instance property has to wait until I bump the deployment target to 27.

I Disappear — What Instruments Guesses for an Unnamed Task

Here’s the before and after from the same launch, VinylCrate’s process only:

Instruments’ guess (before) Task name (after)
VinylCrateApp.init() ×3 cloud-sync-remote-change, cloud-sync-events, auth-identity-load
ViewModelContainer.init(oauthService:modelContext:cloudSync:) ×2 sound-effects-setup, container-initial-load
closure #4 in ViewModelContainer.init(...) ×4 collection-load, master-year-backfill, and two async let children that stay unnamed
CloudSyncMonitor.reconcileIfReady() ×5 cloud-sync-reconcile-retry ×4, master-year-backfill-on-quiesce
thunk for @escaping @isolated(any) @callee_guaranteed @async () -> (@out A) ×9 keychain-read-pat, keychain-read-token, cloud-sync-debounce ×2, a second collection-load, a second master-year-backfill, and three Task.sleep tasks that stay thunks
CollectionViewModel.loadCollectionChunked() widget-rebuild
SoundEffectsService.configure(with:) closure audio-session-activate
closure #8 in RootView.body.getter discogs-connect-prompt
PreferencesSync.start() prefs-kvs-observe

The guess comes from the nearest symbolicated frame where the task was created. Most of the time that’s a decent guess. It breaks in three ways, and this trace hit all three.

Several tasks created in one function share a label. VinylCrateApp.init() creates three tasks, and all three rows say VinylCrateApp.init(). With names they’re cloud-sync-remote-change, cloud-sync-events, and auth-identity-load. Three different jobs that just happen to start in the same initializer. ViewModelContainer.init(oauthService:modelContext:cloudSync:) does the same thing with two.

Tasks created inside other tasks often get a thunk. When Instruments can’t symbolicate the frame that created a task, the nearest frame it can read is the thunk the runtime wraps around every task body. Nine rows read thunk for @escaping @isolated(any) @callee_guaranteed @async () -> (@out A), and nine identical rows don’t tell you which closure is which. After naming, six of them became keychain-read-pat, keychain-read-token, cloud-sync-debounce (twice), collection-load, and master-year-backfill. The other three are Task.sleep tasks, which I’ll get to below.

The guess names the caller, not where the code lives. widget-rebuild is a detached task in a widget snapshot helper, and its row said CollectionViewModel.loadCollectionChunked(), because that’s what calls the helper. collection-load showed up once as closure #4 in ViewModelContainer.init(...), the closure that kicked off the load, alongside the two async let children that really do live in that closure. The worst case is the five reconcileIfReady() rows. Four were cloud-sync-reconcile-retry, created in scheduleReconcileRetry(). The fifth, a 6 ms row, was master-year-backfill-on-quiesce. That task is created in the onQuiesced closure in VinylCrateApp.swift, and reconcileIfReady() is what calls that closure. So Instruments labeled it after the function that called it, and the backfill looked exactly like the retries.

The first two are annoying but honest. A duplicate label or a thunk tells you to click in and read the creation backtrace. The third one doesn’t tell you anything. Five rows with the same label look like five of the same thing, and nothing on screen says otherwise.

All Nightmare Long — Four Retries, Then the Backfill

With names, you can actually read what launch is doing. cloud-sync-reconcile-retry runs four times, two seconds apart, and only after that does master-year-backfill-on-quiesce run.

The two seconds are CloudSyncPolicy.debounce. Here’s where both kinds of pass get scheduled in CloudSyncMonitor:

private func scheduleDebouncedPass() {
    passTask?.cancel()
    passTask = Task(name: "cloud-sync-debounce") { [weak self] in
        try? await Task.sleep(for: .seconds(CloudSyncPolicy.debounce))
        guard !Task.isCancelled, let self else { return }
        self.publishRefresh()
        await self.reconcileIfReady()
    }
}

private func scheduleReconcileRetry() {
    passTask?.cancel()
    passTask = Task(name: "cloud-sync-reconcile-retry") { [weak self] in
        try? await Task.sleep(for: .seconds(CloudSyncPolicy.debounce))
        guard !Task.isCancelled, let self else { return }
        await self.reconcileIfReady()
    }
}

reconcileIfReady() waits for CloudKit to go quiet. Until it does, it schedules another retry, which is why all four retry rows carried its name before. Once sync settles, it calls onQuiesced, and that closure starts the backfill.

That was always happening. I just couldn’t see it. Before naming it was five rows with one label, and the one doing real work looked just like the four that were waiting. Whether four retries is the right number on a cold launch is a separate question, but at least now I know to ask it.

The Small Hours — Task Names in LLDB’s task info and task tree

Names show up in LLDB before you type anything. Here’s a breakpoint inside container-initial-load, followed by language swift task info:

* thread #20, name = 'container-initial-load (Task 6)', queue = 'com.apple.main-thread', stop reason = breakpoint 2.1
(lldb) language swift task info
(UnsafeCurrentTask) current_task = "container-initial-load" id:6 flags:running {
  address = 0x101c79540
  id = 6
  enqueuePriority = .high
  parent = nil
  children = {}
}

LLDB puts the task name in the thread name, right in the stop header. The id matches the number Instruments shows in that task’s row label, so you can go from a trace row to a debugger session by ID. parent = nil is what you’d expect from an unstructured Task.

language swift task tree is the one I’ll keep reaching for:

(lldb) language swift task tree
├╴ Task 4 ('auth-identity-load'), addr = 0x101c78b40 [awaiting Task 40] [suspended]
│    frame #0: Task.value.getter
│    frame #1: OAuthService.loadSavedToken() + 20 at OAuthService.swift:322:11
│    frame #2: closure #1 in OAuthService.init(seedingForScreenshots:) + 20 at OAuthService.swift:53:25
├╴ Task 5 ('sound-effects-setup'), addr = 0x101c79400 [awaiting Task 41] [suspended]
│    frame #0: Task.value.getter
│    frame #1: closure #1 in SoundEffectsService.configure(with:) + 20 at SoundEffectsService.swift:164:15
├╴ Task 6 ('container-initial-load'), addr = 0x101c79540 [running]
│    frame #0: closure #4 in ViewModelContainer.init(oauthService:modelContext:cloudSync:) + 56 at ViewModelContainer.swift:191:16
├╴ Task 40 ('keychain-read-pat'), addr = 0x101c8dcc0 [running]
│    frame #0: mach_msg2_trap + 8
│    ...
└╴ Task 41 ('audio-session-activate'), addr = 0x101c8d900 [running]
     ...
     frame #6: SoundEffectsService.setupAudioSession() + 204 at SoundEffectsService.swift:284:27

That’s who’s waiting on whom at startup, by name. auth-identity-load is suspended, awaiting keychain-read-pat. sound-effects-setup is suspended, awaiting audio-session-activate. Each suspended task shows the frame it’s parked at, down to the file and line. container-initial-load is the one I’m stopped in.

Same thing from inside one of the retries:

* thread #27, name = 'cloud-sync-reconcile-retry (Task 59)', queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
(lldb) language swift task info
(UnsafeCurrentTask) current_task = "cloud-sync-reconcile-retry" id:59 flags:running {
  ...
}

Bleeding Me — Naming Task Group Children, with Signposts

VinylCrate’s wantlist price-drop alerts come from a background price check. It’s the concurrent code in the app I most wanted to see in a trace, and it’s the one I don’t have a trace of. BGTaskScheduler doesn’t run in the Simulator, and I didn’t have a device available when I recorded these. So there’s no price-check trace in this post and no LLDB session from inside it. What I can show is what the names and signposts look like now, and why this code is the reason I bothered.

On July 30 I fixed two bugs in it, and both were hard to see.

A BGAppRefreshTask gets roughly 30 seconds. DiscogsRateLimiter paces requests against a limit the server reports, somewhere between 25 and 60 a minute. So a run only gets through a couple dozen lookups before iOS expires it. Expiration cancels the work, and Task.isCancelled stays true for the rest of that task’s life. The compare-and-persist loop opened with if Task.isCancelled { break }. After an expiration it broke on its first iteration and threw away every result the run had already paid for. No baseline ever got recorded, so any wantlist too big for one window could never alert.

The second bug was quieter. The fan-out got the whole wantlist in list order. Everything past the first couple dozen items was never reached, and it was the same items every run.

For the first one, the local apply loop now always runs to the end, and cancellation only stops the network fan-out.

For the second, the check now starts with whatever hasn’t been checked in the longest time, and items that have never been checked go first.

I didn’t have task names in July, and names didn’t find either bug. But both bugs come down to which work ran and which results got thrown away. When I do record this on a device, a price:<id> row per lookup should show exactly which releases a run reached. That’s why I wanted names here first.

Here’s the launch handler:

BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in
    Task(name: "price-check-launch") { @MainActor in
        await handleBackgroundTask(task as? BGAppRefreshTask)
    }
}

The handler does the real work in its own named task, wired to the expiration handler:

let service = PriceAlertService(client: client, modelContext: context)
let wantlistItems = Self.wantlistProvider?() ?? []
let work = Task(name: "price-check") { @MainActor in
    await service.checkPriceDrops(wantlistItems: wantlistItems)
}
task.expirationHandler = { work.cancel() }
_ = await work.value
task.setTaskCompleted(success: true)

Expiration cancels price-check. That’s the cancellation the July 30 bug was about.

Inside, the fan-out names each child and gives it a matching signpost:

let signposter = self.signposter
let fanOut = signposter.beginInterval("price-check", id: signposter.makeSignpostID())
let priced: [PriceResult] = await withTaskGroup(
    of: PriceResult?.self,
    returning: [PriceResult].self
) { group in
    var iterator = probes.makeIterator()
    var inFlight = 0

    func enqueueNext() {
        guard let probe = iterator.next() else { return }
        group.addTask(name: "price:\(probe.releaseID)") {
            let fetch = signposter.beginInterval(
                "price",
                id: signposter.makeSignpostID(),
                "price:\(probe.releaseID)"
            )
            defer { signposter.endInterval("price", fetch) }
            return await fetchPriceResult(for: probe)
        }
        inFlight += 1
    }

    for _ in 0..<min(Self.fetchConcurrency, probes.count) {
        enqueueNext()
    }

    var collected: [PriceResult] = []
    while inFlight > 0 {
        if Task.isCancelled {
            group.cancelAll()
            break
        }
        guard let next = await group.next() else { break }
        inFlight -= 1
        if let result = next { collected.append(result) }
        enqueueNext()
    }
    return collected
}
signposter.endInterval("price-check", fanOut)

let alertsFired = await apply(priced)

The signposter property is OSSignposter(subsystem: "com.vinylcrate.app", category: "PriceAlertService").

The fan-out is capped at fetchConcurrency, which is 6, and the shared DiscogsRateLimiter does the actual pacing. The group just keeps up to six lookups in flight. Each child gets price:<releaseID> as its task name and opens its own signpost interval with its own signpost ID, because the intervals overlap. The interval starts inside the child, so time spent waiting to be scheduled doesn’t count as fetch time.

OSSignposter interval names are StaticString, so price:\(releaseID) can’t be the interval name. It goes in the signpost message instead. The interval name stays "price", and the message carries the same string as the task name. That way the task row and the signpost interval say the same thing. If signposts are new to you, I went through them in Beyond Firebase: MetricKit and os_signpost.

Now look at where the cancellation check is. The Task.isCancelled check inside the loop cancels the group and stops pulling results. That’s the network fan-out stopping. apply(priced) runs after the group with whatever came back. That’s the July 30 fix in code.

Helpless — Where Names Don’t Reach

async let children can’t be named. SE-0469 says so directly and suggests a task group if you need names. container-initial-load has two:

Task(name: "container-initial-load") { @MainActor in
    if modelContext != nil {
        collection.loadCollection()
    }
    await oauthService?.identityTask?.value

    async let remoteConfigDone = remoteConfig.fetchAndActivate()
    async let wantlistDone: Void = wantlist.fetchWantlist()
    _ = await remoteConfigDone
    await wantlistDone
    // ...pricing, pending deletes, master-year backfill
}

In the trace, the Remote Config fetch and the wantlist fetch both show up as closure #4 in ViewModelContainer.init(...). The relationship table does list them as children of container-initial-load, so you can find them. You just can’t tell them apart by label. If I ever need to, the fix is a task group.

Every Task.sleep gets a twin. In this trace, each Task.sleep shows up as its own short task, created inside Task._sleep(until:tolerance:clock:) and labeled with the closure that called sleep. So each named cloud-sync-reconcile-retry row has an unnamed twin, partial apply for closure #1 in CloudSyncMonitor.scheduleReconcileRetry(), lasting the same 2.00 s. The relationship table doesn’t connect them as parent and child. They aren’t a leak. I saw this on the iOS 27.2 simulator runtime and haven’t looked anywhere else, so I wouldn’t assume it holds everywhere.

.task modifiers name themselves on iOS 26.4 and later. An unnamed .task gets an automatic name, and the SDK’s inlined code is literally name ?? "View.task @ \(file):\(line)". In the trace that looks like View.task @ VinylCrate/RootView.swift:127 and View.task @ VinylCrate/VinylCrateApp.swift:167, with no work from me. Apple’s own TipKit shows up as View.task @ TipKit/PopoverTip (iOS visionOS).swift:88. The creation backtraces show SwiftUI starting that work with Task.immediate(name:priority:executorPreference:operation:). You can pass your own name with .task(name:) or .task(id:name:), but a file and line is already a good label, so I left mine alone. Below 26.4, .task falls back to the old modifier with no name. VinylCrate deploys to 26.0, so on 26.0 through 26.3 those rows won’t get a name.

System framework tasks are still raw addresses. A lot of what CloudKit’s sync engine and TipKit do internally shows up as rows like 0x1e43329a8 (14). Naming your own code doesn’t change that. It does make those rows easier to skip past.

Crash reports. I haven’t seen task names in one, so I’m not counting on them there.

My World — A Task Naming Convention

156 names need some rules, or you end up with 156 different styles. Here’s what I went with:

  • Lowercase kebab-case purpose with a stable, greppable prefix. price-check, collection-sync-launch, widget-rebuild, cloud-sync-debounce.
  • Identity after a colon for per-item work. price:\(releaseID), enrich:\(releaseID), rating-sync:\(releaseID), track-match:\(albumID):\(trackPosition). The prefix stays fixed, so searching for it still works.
  • Views get a screen prefix, then the action. album-detail-play-track, crate-builder-regenerate, settings-price-alerts-toggle.
  • Every name is unique in the codebase. Copy the string out of a trace row, search for it, and you land on the one creation site.
  • No PII. No usernames, emails, tokens, album titles, crate names, URLs, or barcodes. Discogs numeric IDs only. Names end up in traces and debugger output that get shared.
  • No timestamps or UUIDs. They make the same code path look different from one recording to the next.
  • Short. They’re row labels.

No Remorse — Cancellation Shields (SE-0504)

The July 30 bug comes down to one rule. Some local work has to finish even when the task it runs in got cancelled. VinylCrate handles that with the order of the code. The fan-out checks for cancellation, and the apply step after it always runs.

SE-0504 turns that rule into an API. withTaskCancellationShield { } is in the iOS 27 SDK, available on iOS 27.0 and later, and it’s made for exactly this case. VinylCrate doesn’t use it. My fix came first, and the app deploys to 26.0. On code that only has to run on 27, it’s what I’d reach for now.

Astronomy — Task Identity Is Next (SE-0553)

Names are labels. SE-0553 adds identity, and it’s been accepted. It isn’t in the iOS 27.2 SDK yet, and its deployment floor hasn’t been decided, so none of this is in VinylCrate.

It adds Task.currentID, a static Task.ID? that the proposal says costs a few nanoseconds to read and doesn’t allocate. Task.ID is a typealias for a top-level @frozen struct TaskID: Sendable, Hashable with a rawValue: UInt64 and no public initializer. There’s also task.id on a handle and UnsafeCurrentTask.id. IDs are unique within the process and never reused.

The part I like most is how the proposal splits the two jobs. Logging and tracing tools should use the ID to match things up, and only look up the name when a person needs to read it. The proposal compares it to how “an OS thread has both a numeric tid and an optional pthread name.” That lines up with what I ran into. My names are unique per creation site, not per task. Four cloud-sync-reconcile-retry tasks share one name, and that’s fine for reading a trace. It’s not fine for keying a log line or a side table. An ID is.

The proposal is just as clear about what IDs aren’t for. They’re for tagging logs, traces, and lookup tables, not for branching your logic on which task is running. And IDs are never reused, so if you keep a dictionary keyed by task ID, cleaning it up is on you. The implementation is in swiftlang/swift#89237.

Low Man’s Lyric — A Label That Can’t Lie

The launch didn’t change. It did the same work in the same order, four retries and then a backfill. The code didn’t change either, apart from a string at every place a task gets created. What changed is that I can read it.

Instruments’ guesses are good enough to trust, and that’s what makes them risky. A wrong guess looks exactly like a right one. A name I wrote can be a bad name, but it’s never some other task’s name.

The price check is still the one I want to see most. That needs a device and a real background refresh window. The names and signposts are already waiting for it.

If you want to poke at the app these traces came from, VinylCrate is on the App Store.

Keep shipping.

Stay in the loop

Deep dives on Swift, SwiftUI, and building real apps — from the code to the App Store. No fluff, no toy projects.