← All topics/Concurrency

Question 9

Task.detached: when it's justified

When is Task.detached the right tool, and what guarantees do you lose compared to structured Task { }?

Answer outline

Task { } inherits the actor context, priority, and task-local values of the code that creates it. Task.detached inherits none of them and starts fresh on the cooperative thread pool. Both are unstructured, so neither is canceled automatically when the task that created it is canceled.

Reach for Task.detached when the calling code is @MainActor and Task { } would bind the work to the main thread. A detached task never hops back to the main actor on its own, so CPU-heavy work stays off the main thread.

Detaching means you handle three things yourself:

  1. 1.Priority: nothing is inherited, so set it explicitly with priority: .userInitiated or .utility.
  2. 2.Actor isolation: hop back to the main actor explicitly with await MainActor.run { } for any UI update.
  3. 3.Lifetime: no parent cancels it for you, so store the handle and call cancel() in deinit or viewWillDisappear.

By default, prefer a non-main actor or a non-isolated async helper to move work off the main thread. Reach for Task.detached only when the inheritance of Task { } would cause the wrong behavior.

Principles

  • Task.detached is a scalpel: use it when Task { } would inherit the wrong actor, never as a general run-in-background shortcut.
  • Always store the handle and cancel it in deinit, because otherwise a detached task silently outlives its owner.
  • Cancellation is still cooperative inside a detached body, so call Task.checkCancellation() at each checkpoint.
  • Task { } is also unstructured, so the difference is inherited context, and neither one is canceled by a parent.
Task { } vs Task.detached: what each inherits
@MainActor
class ViewModel {
    func load() {
        Task {
            heavyCPUWork() // Problem: still on the main actor because isolation is inherited
        }

        Task.detached(priority: .userInitiated) {
            heavyCPUWork() // Fix: off the main thread, since nothing is inherited
            await MainActor.run { self.updateUI() } // hop back explicitly
        }
    }
}
Full pattern: store the handle, cancel on teardown
final class ThumbnailLoader {
    private var task: Task<Void, Error>?

    func load(url: URL, into imageView: UIImageView) {
        task?.cancel()
        task = Task.detached(priority: .userInitiated) { [weak imageView] in
            guard !Task.isCancelled else { return }
            let (data, _) = try await URLSession.shared.data(from: url)
            let img = UIImage(data: data)?.preparingThumbnail(of: CGSize(width: 64, height: 64))
            guard !Task.isCancelled else { return }
            await MainActor.run { imageView?.image = img }
        }
    }

    deinit { task?.cancel() }
}

Follow-up angles

  • When the inherited context is right but you want a different priority, use Task(priority: .utility) instead of detaching. You keep the actor context and task-local values without the extra bookkeeping.