The user taps Cancel halfway through an export. Your task stops producing pages, the progress indicator disappears, and everyone is happy. Until the next export finds the temporary workspace still marked as busy.

Cancellation tends to expose the paths we exercise least: releasing a reservation, closing a remote session, removing a staging record, or returning a resource to a pool. Putting that work after the happy path is fragile. Putting it in a fire-and-forget task makes the timing somebody else’s problem.

Swift 6.4 gives us two useful pieces: asynchronous work inside defer, and withTaskCancellationShield. Together, they make resource ownership easier to express. They still require a decision about which work deserves to outlive a cancellation request.

Let’s work through that decision using an export reservation. The same reasoning applies to an upload session or a database transaction, although the concrete cleanup and failure policy will differ.

1. Decide What Cancel Actually Means

When somebody dismisses a screen, continuing to render a large document is usually wasteful. Releasing the reservation acquired for that document is still necessary.

Those are different responsibilities. The product wants expensive work to stop quickly; the resource owner needs to leave the system in a usable state. Start by naming both, rather than treating every statement inside an async function as equally cancellable.

For an export, I would write down these expectations:

  • Cancellation stops generating additional pages.
  • The function does not finish until it has attempted to release its reservation.
  • A failed release is visible to diagnostics or reconciliation.
  • Cleanup has a finite operational budget.

That last expectation matters. A cleanup request that retries forever is a cancellation bug wearing a different name.

If your app already has a service boundary, put resource ownership there. A view model should not need to remember which intermediate export states require teardown. The repository and use-case boundaries in Clean Architecture are useful places to establish that contract.

2. Async Defer Keeps Cleanup Beside Acquisition

Swift 6.4 allows await inside defer when its enclosing context is async. Swift awaits that work at scope exit. Multiple defers still execute in reverse declaration order. There is no new defer async spelling.

Here is a small, complete model. The actor represents a reservation service; a production implementation might call a server or coordinate a local staging store.

import Foundation

actor ExportReservations {
    private var active: Set<UUID> = []

    func acquire() -> UUID {
        let id = UUID()
        active.insert(id)
        return id
    }

    func release(_ id: UUID) throws {
        try Task.checkCancellation()
        active.remove(id)
    }

    func activeCount() -> Int { active.count }
}

func renderPages(count: Int) async throws -> Int {
    var rendered = 0
    for _ in 0..<count {
        try Task.checkCancellation()
        // Stand-in for cancellation-aware asynchronous rendering.
        try await Task.sleep(for: .milliseconds(10))
        rendered += 1
    }
    return rendered
}

func exportWithoutShield(
    using reservations: ExportReservations
) async throws -> Int {
    let id = await reservations.acquire()
    defer {
        do {
            try await reservations.release(id)
        } catch {
            print("Reservation release failed: \(error)")
        }
    }
    return try await renderPages(count: 50)
}

This removes duplicated cleanup from success and error branches. It also makes an important promise to the caller: when the function returns or throws, the deferred release attempt has finished.

But cancel this operation and release throws before removing the reservation. Async defer solves the ordering problem. It does not change cancellation behavior.

The checkCancellation in this toy actor deliberately makes that failure obvious. Real dependencies may observe cancellation several layers below your code, which is why an apparently harmless cleanup call needs scrutiny.

SE-0493 defines these scope-exit semantics. Errors still cannot escape a defer body, so cleanup needs an explicit error policy.

3. Shield the Essential Release, Not the Export

Add a shield around the release attempt:

@available(iOS 27.0, macOS 27.0, tvOS 27.0, watchOS 27.0, visionOS 27.0, *)
func export(
    using reservations: ExportReservations
) async throws -> Int {
    let id = await reservations.acquire()
    defer {
        await withTaskCancellationShield {
            do {
                try await reservations.release(id)
            } catch {
                print("Reservation release failed: \(error)")
            }
        }
    }
    return try await renderPages(count: 50)
}

Now rendering remains cancellable. During the protected release, the current task’s cancellation checks behave as though cancellation has not been requested. Once execution leaves that scope, cancellation is observable again.

The task has not been uncancelled. A caller inspecting its task handle still sees the real cancellation state. The shield changes what code executing within that scope observes.

A task receives cancellation, attempts reservation release inside a narrow protected scope, then finishes with cancellation still in effect.
Cancellation stops the main work. Deferred cleanup is awaited before scope exit; the shield does not erase cancellation or guarantee release succeeds.

Do not wrap the entire export just to make a flaky cancellation test pass. That would also protect the expensive page generation the user wanted to stop. In a code review, I want a shield small enough that I can explain every protected operation.

The distinction is specified in SE-0504. It also covers structured child tasks and cancellation handlers installed inside a shield. If your teardown starts parallel child work, review that behavior deliberately; shielding can affect more than the one checkCancellation you can see.

Check the runtime, not just the compiler

Both features are included in the Swift 6.4 release. Their deployment requirements differ.

Async defer is a compiler feature. Cancellation shields require runtime support and cannot be back-deployed. In the installed Xcode 27 SDK, withTaskCancellationShield is marked @available(anyAppleOS 27.0, *). The annotated example therefore targets iOS 27, macOS 27, and corresponding platforms.

An app supporting older iOS versions needs an availability branch or a different cleanup strategy. Updating the compiler alone does not make a new runtime API safe to call. Include this check alongside the other deployment decisions in your Swift 6.4 migration plan.

4. Cleanup Still Needs a Failure Policy

The shield removes one reason for teardown to stop. It does not make a server reachable, keep the app process alive, or turn a disk error into success.

In the sample, release only touches actor-owned memory, so there is little to fail once cancellation is shielded. A remote reservation is different. Its release endpoint should be idempotent: sending the same reservation identifier twice should not corrupt state. The server should also have an expiry policy for clients that disappear without sending anything.

For a real service, replace print with structured diagnostics containing the operation identifier and cleanup phase. Avoid logging document contents or authentication tokens. If the failure needs a retry after relaunch, persist a reconciliation record before relying on that retry.

Decide which error belongs to the caller. Suppose rendering fails and releasing the reservation also fails. The sample preserves the rendering error and reports the secondary failure separately. That can be a sensible UI contract, but it is a policy, not an automatic property of cleanup.

If release failure must make an otherwise successful export fail, write explicit success/error control flow outside defer. A defer body cannot replace the enclosing function’s result by throwing. Avoid try? unless silently ignoring failure is truly part of the API contract.

Bound cleanup at the dependency boundary

Set a finite request timeout and cap retries in the release client. Keep bulk synchronization, analytics flushing, and unrelated refresh work outside the shield.

Be careful with timeout helpers built from task groups. A group waits for its children before leaving scope; cancelling the losing child does not force uncooperative work to terminate. A timeout race is not a hard deadline if the underlying operation cannot stop.

Measure cleanup latency separately from the main operation. A cancelled export that takes thirty seconds to settle may be technically correct and still feel broken to the user.

5. A Cancellation Handler Is a Different Tool

withTaskCancellationHandler is useful when cancellation needs to signal an underlying operation immediately—for example, asking a callback-based request to stop.

Its onCancel closure is synchronous. It is not a place to await an actor method or a network release. It can also race with the operation it is cancelling, so anything shared between the two paths needs appropriate synchronization.

Use the handler to deliver the cancellation signal. Use awaited cleanup at scope exit to finish the teardown contract. Combining them is reasonable when adapting a legacy API, but they should have distinct jobs.

For a callback-backed event sequence, AsyncStream buffering and producer cancellation shows how explicit ownership and a race-safe cancellation gate stop the subscription when consumption ends.

Also remember that shields do not relocate work to a background executor. A synchronous, expensive file scan inside protected cleanup can still stall the main actor. The same executor reasoning from why async code still freezes the UI applies here.

6. Test the Cancelled Path Deterministically

A sleep followed by task.cancel() is a weak test: it depends on scheduling and may cancel before acquisition. For the essential guarantee, cancel the task from inside the operation after the reservation exists.

Add this test to a Swift Testing target alongside ExportReservations:

import Testing

@available(iOS 27.0, macOS 27.0, tvOS 27.0, watchOS 27.0, visionOS 27.0, *)
@Test
func cancelledOperationReleasesReservation() async {
    let reservations = ExportReservations()
    let operation = Task {
        let id = await reservations.acquire()
        defer {
            await withTaskCancellationShield {
                do {
                    try await reservations.release(id)
                } catch {
                    Issue.record("Release failed: \(error)")
                }
            }
        }

        withUnsafeCurrentTask { $0?.cancel() }
        #expect(Task.isCancelled)
        try Task.checkCancellation()
    }

    do {
        try await operation.value
        Issue.record("Expected cancellation to escape the operation")
    } catch is CancellationError {
        // Awaiting value also waits for the deferred cleanup.
    } catch {
        Issue.record("Unexpected error: \(error)")
    }

    #expect(await reservations.activeCount() == 0)
    #expect(operation.isCancelled)
}

The test proves two things together: the operation remains cancelled, and its reservation is released before the caller resumes. It uses a separate task so the test’s own task is not cancelled.

Add success and ordinary-error cases too. For a production release client, inject a failure and assert that diagnostics or reconciliation receive it. Then add a coordination-based integration test that cancels while real work is suspended. An actor-backed latch or continuation handshake is better than guessing a delay.

The broader Swift Testing guide covers organizing these scenarios without turning every test into a timing experiment.

7. Supporting Older Operating Systems

If your release method intentionally ignores cancellation and only performs a short local state change, awaiting it directly may already be enough. Do not add a shield simply because cleanup exists.

For cancellation-sensitive teardown on older runtimes, an awaited unstructured task is a possible bridge:

func releaseUsingSeparateTask(
    _ id: UUID,
    from reservations: ExportReservations
) async {
    let cleanup = Task {
        do {
            try await reservations.release(id)
        } catch {
            print("Reservation release failed: \(error)")
        }
    }
    await cleanup.value
}

An unstructured task does not automatically inherit the caller’s cancellation. Awaiting its value preserves completion ordering. It does introduce a separate scheduled task and its own lifetime, so keep ownership visible. This helper is not a drop-in implementation of every shield semantic.

When multiple callers share an unstructured task, actor-based request coalescing and cancellation ownership separates a cancelled waiter’s lifetime from the shared download and guards against stale completions.

With a Swift 6.4 compiler, it can be awaited from async defer on an older deployment target. With older compilers, use explicit success/error cleanup paths that await the helper. Do not quietly replace awaited teardown with defer { Task { ... } }; that allows the enclosing operation to finish before cleanup does.

Key Takeaways

  • Put teardown beside successful acquisition, so new return paths cannot bypass it.
  • Async defer waits for cleanup; cancellation shielding changes cancellation observation.
  • Protect the minimum essential work and give it a bounded failure policy.
  • Check runtime availability when adopting cancellation shields.
  • Test resource state after awaiting the cancelled operation, not after an arbitrary delay.

The useful design question is simple: when this function finishes, what can its caller safely assume? Once that answer includes resource release, make the ordering explicit, test cancellation, and leave enough diagnostics to explain the failures you cannot prevent.