Fix: Rust 'future cannot be sent between threads safely' in tokio::spawn

intermediate🦀 Rust2026-04-22| Rust (all versions), Tokio runtime, Multi-threaded executor

Error Message

error[E0277]: future cannot be sent between threads safely, the trait `Send` is not implemented
#rust#async#tokio#Send#Future#threads

The Error Message

Rust’s compiler is famously helpful, but error E0277 can still feel like a brick wall when you first hit it. You’re likely trying to spawn a background task, only to be met with a wall of red text telling you a future is not Send. It usually looks like this:

error[E0277]: future cannot be sent between threads safely
   --> src/main.rs:10:5
    |
10  |     tokio::spawn(async move {
    |     ^^^^^^^^^^^^ future returned by `async` block is not `Send` 
    |
    = help: the trait `Send` is not implemented for `std::sync::MutexGuard<i32>`
    = note: future is not `Send` as this value is used across an await

Why This Happens

Tokio’s default scheduler is a multi-threaded work-stealer. To keep your CPU cores busy, it moves tasks between a pool of worker threads—typically matching your total core count. A task might start on Thread 1, hit an .await point, and then resume on Thread 4 when the data is ready.

This "thread hopping" is high-performance, but it creates a strict requirement: every piece of data held across that .await must implement the Send trait. If you hold a non-thread-safe type while the task is paused, the compiler stops you from creating a data race.

Most developers hit this wall for two reasons:

  • Lock Contention: Holding a standard std::sync::MutexGuard while calling .await.
  • Wrong Primitives: Using single-threaded types like Rc or RefCell inside an async block.

Solution 1: Drop Your Guards Early

The std::sync::MutexGuard is not Send because it relies on OS-level thread IDs that cannot be transferred between threads. If your logic only needs the lock for a quick update, ensure the guard is destroyed before you reach an .await.

Incorrect Code:

use std::sync::Mutex;

async fn problematic_task(data: Arc<Mutex<i32>>) {
    tokio::spawn(async move {
        let mut guard = data.lock().unwrap();
        *guard += 1;
        
        // This await keeps the guard alive across threads!
        some_async_function().await; 
        
        println!("Value: {}", *guard);
    });
}

Fixed Code:

use std::sync::Mutex;

async fn fixed_task(data: Arc<Mutex<i32>>) {
    tokio::spawn(async move {
        {
            let mut guard = data.lock().unwrap();
            *guard += 1;
        } // The guard drops here, freeing the task to move threads

        some_async_function().await;
    });
}

Enclosing the mutex logic in a simple block { ... } ensures the MutexGuard goes out of scope. This is the most efficient fix because it avoids the overhead of more complex async locks.

Solution 2: Swap to Tokio’s Mutex

Sometimes you actually need to hold a lock while waiting for an API response or a database query. In these cases, replace the standard library Mutex with tokio::sync::Mutex. Its guard is specifically designed to be Send.

Implementation:

use tokio::sync::Mutex;
use std::sync::Arc;

async fn holding_lock_across_await(data: Arc<Mutex<i32>>) {
    tokio::spawn(async move {
        // Note: .lock() is now an async call
        let mut guard = data.lock().await; 
        
        // This is safe because tokio::sync::MutexGuard implements Send
        some_async_function().await;
        
        *guard += 1;
    });
}

Use this sparingly. Holding a lock across an .await can bottle-neck your application if that async function takes a long time to return.

Solution 3: Trade Rc for Arc

If your error message mentions std::rc::Rc, you are using a reference counter that isn't atomic. Rc is faster for single-threaded work but lacks the internal synchronization needed for thread pools. You must use Arc (Atomic Reference Counted) instead.

Incorrect:

let shared_val = Rc::new(10);
tokio::spawn(async move {
    println!("{}", shared_val); // Rc is not Send
});

Fixed:

let shared_val = Arc::new(10);
tokio::spawn(async move {
    println!("{}", shared_val); // Arc is thread-safe and Send
});

Verification Steps

Run a quick check to see if the compiler is satisfied:

cargo check

If the error remains, look closely at the "note" section of the output. It often pinpoints the exact line where a non-Send variable was created. Look for phrases like "this value is used across an await" to find the culprit.

Quick Best Practices

  • Check early: Run cargo check frequently instead of waiting until you have hundreds of lines of code.
  • Keep sections small: Limit your critical sections to just a few lines of code whenever possible.
  • Avoid RefCell: If you need interior mutability across threads, reach for a Mutex or RwLock.
  • Verify custom traits: If your task involves a custom trait, ensure it has the + Send bound so Tokio can move it between workers.

Related Error Notes