How to Implement Async Drop in Rust

Async Drop is impossible in Rust because the Drop trait requires synchronous execution for immediate resource cleanup.

The async drop trap

You are writing a database connection wrapper. You want to flush pending transactions to the server the moment the connection object goes out of scope. It feels natural to write async fn drop. You hit save. The compiler rejects you with E0407 (method 'drop' is not a member of trait drop). You try adding async to the trait implementation. Syntax error. Rust refuses to let Drop be asynchronous.

This isn't a missing feature waiting for a nightly flag. It is a hard constraint rooted in how the language manages memory, stack unwinding, and execution. You cannot implement async Drop in Rust. The Drop trait requires synchronous execution. Any cleanup that needs to wait for I/O, network calls, or other futures must use a workaround pattern.

Why Drop must be synchronous

The Drop trait is a hook the compiler calls automatically. When a value goes out of scope, the compiler generates code to call drop. This call happens inline, right where the scope ends. The generated code is synchronous.

If drop were async, the compiler would have to pause the current function, poll the drop future, and resume later. That turns every function that holds a value with an async drop into an async function. The type system would break. A synchronous function couldn't hold a value with an async drop. The boundary between sync and async would dissolve, and the compiler would need to transform the entire call stack to support a single drop.

Think of Drop like a fire alarm. When the alarm triggers, the sprinklers must activate immediately. You cannot schedule the sprinklers to turn on "eventually." The reaction has to be instant. In Rust, the scope exit is the alarm. The drop is the sprinkler. It runs now.

There is also the problem of stack unwinding. When a panic occurs, the runtime walks up the stack, calling drop on every local variable. This process is already fragile. If drop were async, the runtime would need to manage futures during unwinding. That introduces deadlocks and undefined behavior. The runtime cannot safely poll a future while the stack is being torn down.

The compiler generates the drop call. You cannot make the generator async.

The compiler's hidden drop calls

Rust's ownership model relies on deterministic cleanup. The compiler knows exactly when a value is created and when it is destroyed. It inserts drop calls at the precise point where the value leaves scope.

fn example() {
    let conn = Connection::new();
    // conn is used here
}
// Compiler inserts: drop(conn);

The inserted call is a standard function call. It executes on the current stack frame. It cannot yield. It cannot await. If the drop implementation tries to do async work, it must either block (which freezes the thread) or spawn a background task (which detaches the work from the current execution).

Blocking is dangerous. Spawning is the only viable path for async cleanup, but it requires moving data out of the value before the drop completes. This is where the implementation gets tricky.

Workaround: Spawning from Drop

The standard pattern for async cleanup is to spawn a background task from within drop. The task takes ownership of the data needed for cleanup and runs it asynchronously. The drop method returns immediately, satisfying the synchronous requirement.

This pattern requires stealing the data out of self. The drop method receives &mut self, which prevents moving fields out directly. You need ManuallyDrop to suppress the automatic drop and extract the value.

use std::mem::ManuallyDrop;
use tokio::sync::mpsc;

struct Connection {
    // ManuallyDrop prevents the inner sender from being dropped automatically.
    // We take ownership in Drop to spawn the cleanup task.
    sender: ManuallyDrop<mpsc::Sender<String>>,
}

impl Connection {
    fn new(sender: mpsc::Sender<String>) -> Self {
        Self {
            sender: ManuallyDrop::new(sender),
        }
    }
}

impl Drop for Connection {
    fn drop(&mut self) {
        // Steal the sender out of ManuallyDrop.
        // This moves the value without running its Drop impl.
        let sender = ManuallyDrop::take(&mut self.sender);
        
        // Spawn a task to perform async cleanup.
        // The task owns the sender and can await.
        tokio::spawn(async move {
            if let Err(e) = sender.send("closing".to_string()).await {
                eprintln!("Failed to send close signal: {}", e);
            }
        });
    }
}

The ManuallyDrop::take call is the key. It moves the value out and leaves a placeholder in its place. The drop method finishes, the scope ends, and the compiler calls drop on the placeholder, which does nothing. The spawned task now owns the data and can run the async cleanup.

Convention aside: The community expects ManuallyDrop when you need to move data out of a Drop impl. Using ptr::read works but is less safe and harder to audit. Stick to ManuallyDrop::take.

Spawning a task in Drop is a fire-and-forget promise. The value is gone; the cleanup is on its own.

Workaround: Explicit close

If you can control the API, the cleanest solution is to require the user to close the resource explicitly. You provide an async fn close method. The user calls it and awaits the result. The Drop impl serves as a fallback for cases where the user forgets.

struct Connection {
    sender: Option<mpsc::Sender<String>>,
}

impl Connection {
    async fn close(mut self) {
        if let Some(sender) = self.sender.take() {
            let _ = sender.send("closing".to_string()).await;
        }
    }
}

impl Drop for Connection {
    fn drop(&mut self) {
        // Fallback: spawn cleanup if close was not called.
        if let Some(sender) = self.sender.take() {
            tokio::spawn(async move {
                let _ = sender.send("closing".to_string()).await;
            });
        }
    }
}

This pattern gives the caller control. They can handle errors from the close operation. They know exactly when the resource is released. The Drop impl only handles the error case where the user drops the value without closing.

Use an explicit close method when you can enforce the call site. It avoids the complexity of background tasks and gives the user visibility into cleanup errors.

Pitfalls and errors

The spawn-from-drop pattern has sharp edges. The most common error is trying to move data out of self without ManuallyDrop.

struct Bad {
    data: String,
}

impl Drop for Bad {
    fn drop(&mut self) {
        // Error: cannot move out of `self.data` which is behind a mutable reference
        let data = self.data; 
    }
}

The compiler rejects this with E0507 (cannot move out of borrowed content). The drop method borrows self mutably. You cannot move fields out of a mutable borrow. You must use ManuallyDrop or std::mem::replace to extract the value.

Another pitfall is blocking on a future inside Drop. Some developers try to use tokio::runtime::Handle::block_on to run async code synchronously.

impl Drop for Connection {
    fn drop(&mut self) {
        // DANGER: This can deadlock.
        tokio::runtime::Handle::current().block_on(async {
            // async cleanup
        });
    }
}

This is a recipe for deadlock. If the drop happens on a thread that the executor needs, or if the executor is single-threaded, the block_on call will wait forever. The executor cannot make progress because the thread is blocked waiting for the executor. Never block on a future in Drop.

If you try to spawn a task that references self, you will hit lifetime errors. The spawned task must own all data it uses. The task runs after drop returns, so self is already destroyed. You must move the data into the task closure.

The compiler will catch these mistakes with E0382 (use of moved value) or lifetime errors. Trust the borrow checker. It usually has a point.

Decision: Handling cleanup

Choose the cleanup strategy based on your control over the call site and the criticality of the cleanup.

Use an explicit async fn close() method when you can control the call site and want guaranteed cleanup. The caller awaits the close, ensuring resources are released before the value is dropped. This is the preferred pattern for libraries where the user can be expected to follow the API.

Use a Drop impl that spawns a background task when you need automatic cleanup but can tolerate the cleanup happening asynchronously. Move the necessary data out using ManuallyDrop before spawning. This works for fire-and-forget cleanup where errors can be logged but not propagated.

Use a synchronous Drop with a blocking call only when the cleanup is extremely fast and you are certain the runtime supports blocking. This is rare and usually indicates a design mismatch. Avoid this pattern unless you have measured that the sync cost is negligible.

Reach for the async-drop crate when you are building a library that requires async Drop semantics and can accept the macro-based overhead. The crate uses macros to transform async fn drop into the spawn pattern automatically. It adds complexity to the build but simplifies the API for users.

Don't fight the sync constraint. Design your API so the user closes explicitly, or accept the background task.

Where to go next