Async defer in Swift 6.4: What SE-0493 Actually Changes

Async defer in Swift 6.4: What SE-0493 Actually Changes

Cleanup code that needs to await something — releasing a pooled connection, closing a socket, flushing a buffer to disk — has never had a clean home in Swift. defer was built exactly for scope-based cleanup, but until now it couldn't do asynchronous work, so that cleanup ended up duplicated at every return path or shipped off to a detached Task and hoped for.

Swift 6.4 fixes this with SE-0493: Support async calls in defer bodies. It's a small change with a real payoff: defer bodies can now call async functions directly, and the compiler generates an implicit await at scope exit so the cleanup finishes before the function actually returns.

The problem defer couldn't solve

Before Swift 6.4, this was a compiler error, even inside an async function:

func f() async {
    await setUp()
    // error: 'async' call cannot occur in a defer body
    defer { await performAsyncTeardown() }

    try doSomething()
}

The usual workaround was a detached Task:

defer {
    Task { await performAsyncTeardown() }
}

which compiles, but gives you no guarantee the cleanup runs before the function returns — the caller could observe a "cleaned up" connection pool that hasn't actually been checked back in yet. The other workaround was manually repeating the cleanup call at every return and every throw site, which works until someone adds a new exit path and forgets it.

What SE-0493 actually adds

With Swift 6.4, the same cleanup can just live in defer:

actor ConnectionPool {
    private var idle: [Connection] = []

    func checkout() -> Connection? { idle.popLast() }
    func checkin(_ connection: Connection) { idle.append(connection) }
}

func loadProfile(id: User.ID, from pool: ConnectionPool) async throws -> Profile {
    guard let connection = await pool.checkout() else { throw PoolError.exhausted }
    defer { await pool.checkin(connection) }  // Swift 6.4: await is allowed here

    let user = try await connection.fetchUser(id)
    let posts = try await connection.fetchPosts(for: user)
    return Profile(user: user, posts: posts)
}

Whether loadProfile returns normally or throws from fetchUser/fetchPosts, the defer runs, await pool.checkin(connection) is awaited by the runtime, and only then does the function actually hand control back to the caller. One line replaces every duplicated exit-path cleanup.

Two constraints worth knowing:

The enclosing scope must already be async. A defer with await inside a synchronous function is still a compiler error — SE-0493 doesn't make functions implicitly async, it just permits suspension where one was already possible. In a closure literal, though, an await inside defer is enough for the compiler to infer the closure's type as async, the same way it would if the await were anywhere else in the body.

A deferred body inherits the isolation of its enclosing scope. It doesn't hop actors or introduce a new suspension point on its own — any suspension comes from the await calls you write inside it, same as anywhere else in the function.

Why not defer async?

During review, one alternative was requiring an explicit defer async { ... } marker, on the theory that it would make suspension points easier to spot at a glance. The proposal rejected it: the enclosing function is already required to be async, and the await keyword on the actual call already marks the suspension point — the same way await inside an if or while body needs no extra annotation on the if or while itself. Adding async to defer too would be redundant ceremony for something that's already visible in the source.

Edge cases and gotchas

Cancellation isn't suspended for your cleanup. SE-0493 explicitly declined to "un-cancel" a task on entry to a defer body. An async defer observes the same cancellation state a synchronous one always has — if your cleanup calls a cancellation-aware API, it can still throw or bail early exactly as it would outside the defer. If you need cleanup that must run to completion regardless of cancellation, that's a job for a general mechanism like withCancellationIgnored, not something async defer gives you automatically.

Multiple defers still unwind in reverse declaration order. Stacking several defer blocks with async work in them doesn't change Swift's existing LIFO ordering — the last defer you write is still the first one to run.

You still can't throw from a defer body. SE-0493 only lifted the async restriction, not the throwing one. Review discussion confirmed this doesn't block a future throwing-defer proposal, but it's deliberately out of scope here — largely because it's unclear which error should win if the function body is already propagating one when the defer's cleanup also fails. Until that's resolved, an async cleanup step that can fail needs to handle its own errors internally (log them, swallow them, report them some other way) rather than letting them escape the defer.

Summary

SE-0493 doesn't add new concurrency primitives — it removes an arbitrary restriction that made defer unusable for a huge, common class of cleanup work. If your teardown logic needs to await, it can now live in exactly the place defer was always meant for, with cancellation behaving the way you'd expect and ordering unchanged from the code you already know.

Subscribe to Swiftloop

Sign up now to get access to the library of members-only issues.
Jamie Larson
Subscribe