Swift Cancellation Handling: Cancel Tasks Safely and Cleanly

Swift concurrency lets tasks run asynchronously, but those tasks also need a way to stop when the work is no longer needed. Cancellation handling is the part of Swift that helps you detect cancellation, exit early, and clean up resources without wasting time or producing stale results.

Quick answer: In Swift, cancellation is cooperative, not forced. Calling cancel() marks a task as cancelled, and your code must check Task.isCancelled or call Task.checkCancellation() and stop work itself.

Difficulty: Intermediate

You'll understand this better if you know: basic async/await syntax, how Swift tasks run, and the difference between synchronous and asynchronous work.

1. What Is Cancellation Handling?

Cancellation handling is the pattern of making asynchronous Swift code stop when it is no longer useful. A cancelled task does not automatically terminate all of its work at once; instead, Swift marks the task as cancelled and gives your code a chance to notice and respond.

In Swift concurrency, this usually involves Task.cancel(), Task.isCancelled, and Task.checkCancellation(). Some APIs also throw CancellationError when they notice cancellation.

2. Why Cancellation Matters

Cancellation matters because async work often becomes irrelevant before it finishes. A search request may be replaced by a newer query, a download may no longer be needed, or a view may disappear before its task completes.

Without cancellation handling, your app can waste CPU, keep network connections open, and update state after the user has already moved on. That can create bugs that are hard to reproduce, especially in UI code and concurrent pipelines.

Cancellation also improves correctness. If a task should no longer deliver a result, stopping it early keeps your app from saving stale data or overwriting newer work.

3. Basic Syntax or Core Idea

The core idea is simple: one task requests cancellation, and the running task checks that request at safe points.

Minimal cancellation check

The most direct way to check cancellation is with Task.isCancelled. This returns true when the current task has been cancelled.

func performWork() async throws {
    if Task.isCancelled {
        return
    }

    // Continue doing work only if the task is still active.
}

For throwing contexts, Task.checkCancellation() is often better because it throws CancellationError immediately when the task has been cancelled.

func loadData() async throws {
    try Task.checkCancellation()
    // Continue with the expensive work.
}

Use cancel() from the outside to request cancellation.

let task = Task {
    try await loadData()
}

task.cancel()

This pattern does not forcibly kill the task. It only sets the cancellation flag, and your code decides when to stop.

4. Step-by-Step Examples

Example 1: Checking cancellation inside a loop

Long loops should check cancellation regularly so they can stop before doing unnecessary work. This matters for image processing, parsing, and other iterative tasks.

func processItems(_ items: [Int]) async throws {
    for item in items {
        try Task.checkCancellation()
        print(item)
    }
}

This version stops promptly if the task is cancelled between iterations.

Example 2: Cancelling stale search work

When a user types a new query, you often want to cancel the old search task so it does not return outdated results.

var currentSearchTask: Task<Void, Never>?

A new task replaces the old one, and the old one is cancelled before starting fresh work.

currentSearchTask?cancel()

currentSearchTask = Task {
    do {
        try await searchServer()
    } catch is CancellationError {
        return
    } catch {
        print("Search failed: \(error)")
    }
}

This keeps only the newest search alive and prevents older requests from winning the race.

Example 3: Cleaning up before returning

Cancellation often happens while a task holds temporary resources, such as file handles, network streams, or in-memory caches. You should release those resources as soon as cancellation is detected.

func downloadAndSave() async throws {
    defer {
        // Close files, release locks, or reset state here.
    }

    try Task.checkCancellation()
    let data = try await fetchData()
    try Task.checkCancellation()
    try save(data)
}

Checking before and after major steps helps prevent a task from doing work that no longer matters.

Example 4: Handling suspension points that may throw cancellation

Some async operations can notice cancellation automatically, especially when they are suspension points that throw. You still need to handle the thrown error properly.

func waitForResult() async throws {
    do {
        let value = try await fetchSlowValue()
        print(value)
    } catch is CancellationError {
        print("Task was cancelled before the value arrived.")
        throw CancellationError()
    }
}

This keeps cancellation distinct from ordinary failures such as network errors or decoding problems.

5. Practical Use Cases

These use cases all share the same goal: do not keep spending resources on work that no longer matters.

6. Common Mistakes

Mistake 1: Assuming cancellation is automatic

New Swift developers often expect cancelled tasks to stop immediately without any extra checks. In Swift concurrency, cancellation is cooperative, so the task must observe the cancellation state.

Problem: This task keeps running because nothing checks whether it has been cancelled.

let task = Task {
    for number in 1...100 {
        print(number)
    }
}

Fix: Check for cancellation inside the work loop and exit when requested.

let task = Task {
    for number in 1...100 {
        if Task.isCancelled {
            return
        }
        print(number)
    }
}

The corrected version works because the task gives itself a chance to stop.

Mistake 2: Catching and ignoring CancellationError

Cancellation should usually propagate unless you have a very specific reason to continue. Swallowing CancellationError can make a task appear successful when it was actually cancelled.

Problem: This code hides cancellation and continues as if the operation finished normally.

do {
    try await loadProfile()
} catch {
    print("Ignoring all errors")
}

Fix: Handle cancellation separately and rethrow it or return early.

do {
    try await loadProfile()
} catch is CancellationError {
    return
} catch {
    print("Profile load failed: \(error)")
}

The corrected version preserves the meaning of cancellation and keeps real failures separate.

Mistake 3: Checking cancellation too late

If a task does expensive work before checking for cancellation, it can waste time after the result is already obsolete. This is common in data transformation or file processing code.

Problem: The task performs heavy work before noticing that it should stop.

func rebuildCache() async throws {
    let data = try await fetchLargeDataSet()
    let result = expensiveTransform(data)
    try Task.checkCancellation()
    try writeCache(result)
}

Fix: Check before and after major work units so cancellation can stop the task earlier.

func rebuildCache() async throws {
    try Task.checkCancellation()
    let data = try await fetchLargeDataSet()
    try Task.checkCancellation()
    let result = expensiveTransform(data)
    try Task.checkCancellation()
    try writeCache(result)
}

The corrected version avoids wasting work at multiple stages of the pipeline.

7. Best Practices

Practice 1: Check cancellation at natural boundaries

Cancellation checks work best before and after expensive operations such as network calls, parsing steps, or iteration batches. This keeps the code responsive without littering every line with checks.

func syncRecords() async throws {
    try Task.checkCancellation()
    let records = try await fetchRecords()
    try Task.checkCancellation()
    try saveRecords(records)
}

This makes cancellation timely while keeping the code readable.

Practice 2: Treat cancellation differently from failure

Cancelled work is not the same as a network outage or a decoding bug. Handle those paths separately so logs, metrics, and UI behavior stay accurate.

do {
    try await refreshFeed()
} catch is CancellationError {
    return
} catch {
    print("Refresh failed: \(error)")
}

This separation makes it easier to debug real problems without treating cancellations as bugs.

Practice 3: Cancel obsolete tasks early

If new work replaces old work, cancel the old task immediately rather than waiting for it to finish. This is especially useful in search, autocomplete, and live preview features.

var currentTask: Task<Void, Never>?

func startLatestWork() {
    currentTask?cancel()
    currentTask = Task {
        try? await refreshSuggestions()
    }
}

This avoids race conditions where an older task finishes after a newer one and overwrites the result.

8. Limitations and Edge Cases

A task that is already cancelled may still perform cleanup in defer blocks or finally-style exit paths, which is often exactly what you want.

9. Practical Mini Project

Here is a small command-line style example that starts a task, cancels it quickly, and handles cancellation cleanly. This pattern mirrors how you would stop obsolete work in a real app.

import Foundation

func slowCount() async throws {
    for number in 1...10 {
        try Task.checkCancellation()
        print("Count: \(number)")
        try await Task.sleep(nanoseconds: 300_000_000)
    }
}

let task = Task {
    do {
        try await slowCount()
    } catch is CancellationError {
        print("Counting was cancelled.")
    } catch {
        print("Unexpected error: \(error)")
    }
}

Task {
    try await Task.sleep(nanoseconds: 700_000_000)
    task.cancel()
}

This example shows a task doing work in small steps and stopping when cancellation is requested. The cancellation is observed inside slowCount(), and the outer task handles the CancellationError cleanly.

10. Key Points

11. Practice Exercise

Expected output: The task should print only the first few chunks, then stop and report that it was cancelled.

Hint: Use Task.checkCancellation() inside the loop and wrap the call in a do/catch block.

import Foundation

func downloadChunks() async throws {
    for chunk in 1...5 {
        try Task.checkCancellation()
        print("Downloading chunk \(chunk)")
        try await Task.sleep(nanoseconds: 200_000_000)
    }
    print("Download completed")
}

let task = Task {
    do {
        try await downloadChunks()
    } catch is CancellationError {
        print("Download cancelled")
    }
}

Task {
    try await Task.sleep(nanoseconds: 450_000_000)
    task.cancel()
}

12. Final Summary

Swift cancellation handling is about writing async code that can stop when the work is no longer needed. The key idea is cooperative cancellation: another task requests cancellation, and your task checks for that request and exits at sensible points.

Use Task.isCancelled when you need a simple Boolean check, and use Task.checkCancellation() when your function can throw and should stop immediately. Separate cancellation from ordinary errors so your code stays predictable and your logs stay meaningful.

Once you start checking cancellation inside loops, before expensive steps, and around long-running operations, your Swift concurrency code becomes more responsive, more efficient, and easier to reason about. A good next step is to apply these patterns to task groups, search flows, or any async function that can be interrupted by newer work.