To make a Swift calculation cancellable, retain its task handle, call cancel() when requested, and check for cancellation inside the loop. This macOS example also keeps the interface responsive and prevents a cancelled run from displaying a late result.

What does cancelling a Swift task actually do?

Swift uses cooperative cancellation: requesting cancellation marks a task, while the running code decides when to stop. A CPU loop that never checks cancellation can continue to completion. Calling Task.checkCancellation() throws CancellationError when cancellation is observed. Apple’s cancellation documentation, checkCancellation().

The reader task here is specific: add working Start and Cancel buttons to a small Mac app that calculates a synthetic sum. Every input is generated locally. The calculation adds the square roots of consecutive positive integers, producing one Double only when all requested terms have been processed.

Choose the result contract before implementing cancellation. This example discards an unfinished sum and reports cancellation separately. Returning a partial sum would require a different contract that identifies how many terms were included; otherwise, a caller could mistake incomplete work for the requested answer. Swift’s language guide documents throwing, returning an empty result, and returning partial work as possible cancellation responses. Concurrency.

There are two responsibilities to implement: stop unnecessary computation, and decide whether a completed value still belongs in the interface. The example handles both, including cancellation requested after the worker’s final check but before its result is displayed.

Put cancellation checks inside the calculation

Create a macOS app project in Xcode using SwiftUI and Swift. Use Swift 6 language mode and a macOS 13 or later deployment target for this example. Add the following complete file, named SyntheticCalculation.swift. It has no dependency on the view.

// SyntheticCalculation.swift
import Swift

enum SyntheticCalculation {
    nonisolated static func sumOfSquareRoots(
        terms: Int,
        checkEvery: Int = 4_096
    ) throws -> Double {
        precondition(terms >= 0, "terms must be nonnegative")
        precondition(checkEvery > 0, "checkEvery must be positive")

        try Task.checkCancellation()

        var completed = 0
        var total = 0.0

        while completed < terms {
            try Task.checkCancellation()

            let count = min(checkEvery, terms - completed)
            let end = completed + count

            while completed < end {
                completed += 1
                total += Double(completed).squareRoot()
            }
        }

        try Task.checkCancellation()
        return total
    }
}

The function checks cancellation before starting, at each chunk boundary, and before returning. These are deliberate stopping points: a cancellation request during a chunk is observed at the next check. Task.checkCancellation() is synchronous, so the calculation can remain an ordinary throwing function; it does not need an await merely to inspect cancellation. checkCancellation().

The chunk calculation also preserves clear input boundaries. Zero terms produces zero on an uncancelled task. Negative terms and a nonpositive chunk size are programmer errors enforced by preconditions. Computing each chunk from the remaining count avoids advancing its endpoint beyond terms.

The accumulator stays local to the worker. A thrown cancellation error leaves the function without returning that accumulator, which implements the chosen complete-result contract. Because this is floating-point addition, the completed value is an approximation of the mathematical sum.

Keep the Cancel button responsive

Cancellation checks cannot help a button action that has no opportunity to run. Apple explains that placing synchronous heavy work inside a plain Task created from a SwiftUI view can still occupy the main actor, because that task inherits the surrounding actor context. Apple documents a detached task as a supported way to execute synchronous work outside that inherited context. Improving app responsiveness.

Here, the detached worker captures only an immutable integer and calls an explicitly nonisolated synchronous function. All interface state stays on the main actor. A separate main-actor task awaits the worker’s value and updates the display.

Replace the generated app entry file with the following complete file, and remove the generated ContentView.swift if it remains. Keep exactly one @main declaration in the target, as required by SwiftUI’s app lifecycle. Together, these two files form the example app. App.

// CancellationDemoApp.swift
import Foundation
import SwiftUI

@main
struct CancellationDemoApp: App {
    var body: some Scene {
        WindowGroup {
            CalculationView()
        }
    }
}

@MainActor
struct CalculationView: View {
    @State private var worker: Task<Double, Error>?
    @State private var runID: UUID?
    @State private var status = "Ready"

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Synthetic square-root sum")
                .font(.headline)
            Text(status)
                .textSelection(.enabled)

            HStack {
                Button("Start") {
                    start()
                }
                .disabled(worker != nil)

                Button("Cancel") {
                    guard let worker else { return }
                    worker.cancel()
                    status = "Cancellation requested"
                }
                .disabled(worker == nil)
            }
        }
        .padding(24)
        .frame(minWidth: 420, minHeight: 160)
        .onDisappear {
            worker?.cancel()
            runID = nil
            worker = nil
            status = "Ready"
        }
    }

    private func start() {
        guard worker == nil else { return }

        let id = UUID()
        let terms = 500_000_000
        runID = id
        status = "Calculating"

        let run = Task.detached(priority: .userInitiated) {
            try SyntheticCalculation.sumOfSquareRoots(terms: terms)
        }
        worker = run

        Task { @MainActor in
            do {
                let value = try await run.value
                guard runID == id else { return }

                if run.isCancelled {
                    status = "Cancelled"
                } else {
                    status = "Result: \(value)"
                }
            } catch is CancellationError {
                guard runID == id else { return }
                status = "Cancelled"
            } catch {
                guard runID == id else { return }
                status = "Failed: \(error)"
            }

            guard runID == id else { return }
            worker = nil
            runID = nil
        }
    }
}

Retaining worker is essential. Dropping a task handle does not cancel the task; it removes your ability to cancel it through that handle. Awaiting the throwing worker’s value also observes its error. Detached tasks require explicit lifecycle management, so the button and disappearance handler cancel the worker directly. Cancelling only the task awaiting its result would not establish cancellation propagation to this detached worker. Detached task documentation.

Foundation’s UUID provides an identifier for each run. The identifier comparison is an application-level rule: once disappearance invalidates a run, its eventual completion cannot update this view’s current status. The identifier does not stop computation; worker.cancel() and the loop’s checks handle that. UUID.

The Start button remains disabled until the worker finishes or throws. Pressing Cancel first shows Cancellation requested; the completion observer changes that to Cancelled when it handles the outcome. This keeps a cancellation request distinct from confirmation that the worker has ended.

How should a late result be handled?

Consider the sequence where the worker passes its final check, finishes, and then the user presses Cancel before the main actor accepts its value. Apple explicitly notes that cancellation after the last possible check may have no observable effect on the worker. A final check narrows that interval but cannot eliminate it. cancel().

The example therefore checks run.isCancelled after awaiting a successful value. That is a check of the worker’s handle. Using the static Task.isCancelled there would inspect the observer task instead. Swift’s language guide distinguishes checking the current task through the type property from checking another task through its instance property. Concurrency.

This establishes a concrete acceptance rule: cancellation processed before result acceptance wins. After acceptance has already finished and cleared the worker, the disabled Cancel button has no completed result to retract. The rule follows the order of main-actor actions rather than promising that cancellation reverses completed work.

Choose a useful cancellation-check interval

The default chunk size of 4,096 terms is an illustrative starting point. Smaller chunks provide more frequent opportunities to observe cancellation and execute more checks. Larger chunks reduce check frequency while allowing more computation between stopping points.

For a rough planning calculation, suppose a chunk contains 4,096 terms and each term takes an illustrative two microseconds. That chunk represents about 8.2 milliseconds of calculation. These are invented inputs to demonstrate the arithmetic, not measurements of Swift or a particular Mac. Scheduling and interface updates can add further delay.

Observation What to inspect or change
The interface stops reacting during calculation Check that the loop runs in the detached worker and remains outside main-actor isolation.
The interface reacts, but cancellation takes too long Reduce checkEvery and measure the longest chunk.
A chunk later gains one expensive operation Add stopping points inside that operation if its implementation supports them.
A cancelled run displays a result Check the worker handle at acceptance time and retain the run-identifier guard.

Do not add a cancellation handler merely to set another flag for this loop. The task already exposes cancellation state, and Swift’s language guide explains that installing a handler still leaves stopping cooperative. For this local calculation, explicit checks provide the necessary stopping mechanism. Concurrency.

Verify completion, cancellation, and repeated runs

A Swift compiler and macOS runtime were unavailable in the research environment, so this example was inspected but not compiled or executed. Build both files in Xcode, then verify these distinct behaviors:

  1. Small completed result: temporarily change terms to 4. The displayed result should be approximately 6.146264369941973, allowing for floating-point rounding.
  2. Empty input: change terms to 0. An uncancelled run should display zero.
  3. Cancellation during work: restore 500_000_000, press Start, then press Cancel while it is running. Expect Cancellation requested, followed by Cancelled without a partial result.
  4. Another run: after cancellation completes, press Start again. The new run should begin normally.
  5. View disappearance: close a calculating window. The disappearance handler should request cancellation and invalidate that run’s interface updates.

The large term count is intended to leave time for interaction, but its duration depends on the Mac and build configuration. If it completes before you can cancel, increase the count within the supported integer range. Validate completed arithmetic with the small input rather than waiting for the large demonstration to finish.

The finished design has a clear stopping path and a clear acceptance rule: the button cancels the retained worker, chunk boundaries let the calculation throw, and the main actor accepts only an uncancelled result from the current run. Those three decisions make cancellation useful for an ordinary Mac calculation.

Sources