BS
BleepingSwift
Published on
5 min read
Intermediate

> Async defer in Swift 6.4

Share:

defer has always been the cleanest way to express "do this on the way out, no matter how we leave." It runs whether the function returns normally, throws, or hits an early return from the middle of a loop. Until Swift 6.4, though, there was a frustrating gap: you could not await inside a defer body. Cleanup that needed to do anything async (release a pooled connection, send a final shutdown message, flush a queued upload) had to be expressed some other way, usually less cleanly.

Swift 6.4 closes that gap with SE-0493: Async Calls in Defer Bodies. There is no new syntax. You just write await inside defer.

The Old Workaround

Before SE-0493, a typical async cleanup pattern looked like this:

Swift
func processOrder(_ order: Order) async throws {
    let connection = try await pool.checkout()

    defer {
        Task.detached {
            await pool.return(connection)
        }
    }

    try await connection.write(order)
}

It compiles, but it has problems. The detached task runs whenever the runtime gets around to it, which means the connection might not be back in the pool by the time the next caller asks for one. You also lose any error from the cleanup, because the detached task is fire-and-forget. And reasoning about lifetimes gets harder, because the cleanup is no longer tied to the function's stack frame.

The New Way

Inside an async function, you can now put await in a defer block directly:

Swift
func processOrder(_ order: Order) async throws {
    let connection = try await pool.checkout()
    defer { await pool.return(connection) }

    try await connection.write(order)
}

That is the entire feature. The defer body runs synchronously with respect to the function's exit: control does not return to the caller until pool.return(connection) finishes. The connection is back in the pool before the next checkout has a chance to ask for it.

try await works the same way when the cleanup itself can throw:

Swift
func writeFile() async throws {
    let handle = try await openHandle()
    defer {
        try? await handle.flushAndClose()
    }

    try await handle.write(payload)
}

The same rule that has always applied to defer applies here too: you usually want try? inside the defer so a cleanup failure does not silently replace whatever error caused you to leave the function in the first place. That has nothing to do with async, but it shows up more often once your defer bodies start doing fallible async work.

The Async Requirement

You can only use await in a defer body when the enclosing function is itself async. Trying to do so in a synchronous function gives the same error you would get anywhere else await is misused:

Swift
func sync() {
    // error: 'async' call in a function that does not support concurrency
    defer { await teardown() }
}

Closures follow the usual inference rules. If a closure body contains await, the closure becomes async automatically, which means defer { await ... } works inside an async closure as well.

Isolation Stays Put

The proposal is explicit that the body of a defer inherits the isolation of its enclosing scope. If you are inside a @MainActor function, the defer runs on the main actor. If you are inside an actor method, it runs on that actor. An await inside the defer still introduces a suspension point when the called function lives somewhere else, but the defer itself does not add an extra hop to a different executor.

Swift
@MainActor
final class UploadQueue {
    func flush() async throws {
        let token = try await beginFlush()
        defer { await endFlush(token) }   // still on the main actor
        try await uploadPending()
    }
}

If you have multiple defers, they still run in reverse declaration order. The async ones await at each step before unwinding to the next.

The Cancellation Gotcha

This is the part that surprises people. The body of an async defer still observes cancellation. If your task gets cancelled mid-function, control falls through to the defer, and any await inside it is now running on a cancelled task. Anything that checks Task.isCancelled or Task.checkCancellation() will short-circuit. The proposal makes this explicit: cancellation is not suppressed inside defer, on purpose, because that would be a surprising semantic change for code that was previously synchronous.

For cleanup that absolutely must run to completion regardless of cancellation, pair the defer with withTaskCancellationShield:

Swift
func processOrder(_ order: Order) async throws {
    let txn = try await database.beginTransaction()

    defer {
        await withTaskCancellationShield {
            await database.commit(txn)
        }
    }

    try await database.write(order, in: txn)
}

Now the commit runs to completion even if the task was cancelled before the defer fired. The shield is a separate tool from async defer, and the two compose naturally.

A Better Cleanup Story

Before this proposal, async cleanup was the corner of Swift concurrency that always felt slightly bolted on. You either spawned a detached task and lost the lifetime guarantees you wanted, or you wrote your own do/catch boilerplate to manually call cleanup before every return path. Async defer makes the natural pattern actually work. Open the resource, write a one-line defer, get on with the function. The cleanup runs in the right place, in the right isolation, on the way out.

subscribe.sh

// Stay Updated

Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.

>

By subscribing, you agree to our Privacy Policy.