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::MutexGuardwhile calling.await. - Wrong Primitives: Using single-threaded types like
RcorRefCellinside 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 checkfrequently 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
MutexorRwLock. - Verify custom traits: If your task involves a custom trait, ensure it has the
+ Sendbound so Tokio can move it between workers.

