Async cleanup in defer with Swift 6.4


Greetings, traveler!

Swift developers have used defer for years to guarantee cleanup when a scope exits. It works well for releasing resources, unlocking locks, or resetting state.

One limitation remained: asynchronous cleanup could not live inside a defer block. If a cleanup operation required await, developers had to duplicate cleanup code across multiple exit paths or move it into separate helper functions. Swift 6.4 removes that limitation.

The problem before Swift 6.4

Consider a function that opens a session and must always close it before returning:

func processRequest() async throws {
    await openSession()

    do {
        try await performWork()
        await closeSession()
    } catch {
        await closeSession()
        throw error
    }
}

The cleanup logic appears multiple times. As the function grows, it becomes easier to miss an exit path.

Many developers naturally tried to use defer:

func processRequest() async throws {
    await openSession()

    defer {
        await closeSession()
    }

    try await performWork()
}

Before Swift 6.4, this did not compile because await was not allowed inside a defer body.

What changed in Swift 6.4

Swift 6.4 adds support for asynchronous calls inside defer.

When the scope exits, Swift executes the deferred cleanup and waits for it to complete before returning from the function.

The behavior of defer itself has not changed. Deferred blocks still run when the scope exits and still execute in reverse order.

Example

Network operations often need setup and teardown logic.

With Swift 6.4, that logic can stay in one place:

func fetchUserProfile() async throws -> User {
    await analytics.beginTracking("profile")

    defer {
        await analytics.endTracking("profile")
    }

    return try await apiClient.loadProfile()
}

The tracking session ends whether the request succeeds or throws an error.

Multiple deferred operations

Deferred blocks continue to execute in reverse order:

func performOperation() async {
    defer {
        await log("Third")
    }

    defer {
        await log("Second")
    }

    defer {
        await log("First")
    }

    await doWork()
}

Output:

First
Second
Third

This matches the existing behavior of synchronous defer.

Handling throwing cleanup

The new feature allows asynchronous calls, but it does not introduce a throwing version of defer.

If cleanup can fail, the error must still be handled inside the deferred block:

func synchronize() async throws {
    defer {
        do {
            try await cleanup()
        } catch {
            logger.error("Cleanup failed: \(error)")
        }
    }

    try await performSynchronization()
}

Errors from cleanup are not automatically propagated out of the enclosing function.

Async context is still required

The surrounding context must already be asynchronous.

This works:

func loadData() async {
    defer {
        await cleanup()
    }

    await fetchData()
}

This does not:

func loadData() {
    defer {
        await cleanup()
    }
}

The compiler still requires an async context before await can be used.

Final thoughts

This is a relatively small addition to the language, but it removes a long-standing friction point in Swift Concurrency.

defer has always been the natural place for cleanup logic. Swift 6.4 finally allows asynchronous cleanup to live there as well, making async code easier to read and reducing duplicated teardown code.