iosNudge

How does task cancellation work in Swift concurrency?

MediumConcurrency 2 min read

TL;DR

Cancelling a task does not stop it right away. It only sets a flag. Your code must check the flag: Task.isCancelled or try Task.checkCancellation(). Many APIs like Task.sleep and URLSession check it for you. Cancellation flows down to child tasks (async let, task groups). It does not reach unstructured Task { } you created inside.

Cancelling a task

let task = Task {
    try await loadFeed()
}

// later, e.g. when the user leaves the screen
task.cancel()

Checking for cancellation

Task.isCancelled — check and decide

func processAll(_ items: [Item]) async -> [Result] {
    var results: [Result] = []
    for item in items {
        if Task.isCancelled { break }     // stop early, return partial results
        results.append(await process(item))
    }
    return results
}

try Task.checkCancellation() — throw

func importPhotos(_ photos: [Photo]) async throws {
    for photo in photos {
        try Task.checkCancellation()      // throws CancellationError
        try await upload(photo)
    }
}

Use this when cancellation should end the whole operation as an error.

APIs that check for you

try await Task.sleep(for: .seconds(2))           // throws CancellationError
let (data, _) = try await URLSession.shared.data(from: url)  // throws URLError.cancelled

Long CPU loops do not check. You must add checks yourself.

Structured tasks get cancelled automatically

func loadScreen() async throws {
    async let profile = fetchProfile()
    async let feed = fetchFeed()
    _ = try await (profile, feed)
}

let t = Task { try await loadScreen() }
t.cancel()   // profile and feed child tasks are cancelled too

Same for task groups. If one child throws in a throwing group and you rethrow, the other children are cancelled.

Unstructured tasks do not inherit cancellation

let parent = Task {
    Task {
        // this inner task is NOT cancelled when parent is cancelled
        await syncEverything()
    }
}
parent.cancel()

Task { } and Task.detached { } are independent. Keep a handle and cancel them yourself.

Cancellation handlers

For APIs that are not async-aware, react to cancellation immediately:

func download(_ url: URL) async throws -> Data {
    let loader = LegacyLoader()
    return try await withTaskCancellationHandler {
        try await loader.start(url)
    } onCancel: {
        loader.stop()     // runs right away, on any thread
    }
}

The onCancel closure can run at the same time as the operation. It must be thread-safe.

SwiftUI does it for you

struct SearchView: View {
    @State private var query = ""
    @State private var results: [String] = []

    var body: some View {
        List(results, id: \.self) { Text($0) }
            .searchable(text: $query)
            .task(id: query) {
                try? await Task.sleep(for: .milliseconds(300))   // debounce
                guard !Task.isCancelled else { return }
                results = await search(query)
            }
    }
}

.task cancels its task when the view disappears. With id:, it cancels and restarts whenever query changes. This gives a simple debounce.

UIKit: cancel on disappear

final class FeedViewController: UIViewController {
    private var loadTask: Task<Void, Never>?

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        loadTask = Task { await viewModel.load() }
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        loadTask?.cancel()
    }
}

Common mistakes

  • Thinking cancel() kills the task. It does not.
  • Catching CancellationError and showing an error alert. Cancellation is usually not an error for the user.
  • Writing try? await Task.sleep(...) and then continuing. try? swallows the cancellation. Check Task.isCancelled after it.

Common follow-up questions

  • Can a cancelled task still return a value? Yes. It can return partial results or a default. Cancellation is just a request.
  • Can you un-cancel a task? No. Once cancelled, it stays cancelled.
  • Does cancelling a task release its memory? Only after the task actually finishes running.
  • Does a cancelled Task.sleep wait the full time? No. It wakes up right away and throws CancellationError.
  • How do you test cancellation? Start a task, cancel it, then await task.value or task.result and check you got CancellationError or partial results.