Telex logo Telex

Rust Patterns That Matter #22: Send / Sync Bounds in Async

Post 22 of 22 in Rust Patterns That Matter. Companion series: Building a Chat Server in Rust.

Previous: #21: Pin and Boxing Futures

You call tokio::spawn(my_async_fn()) and the compiler says: "future returned by my_async_fn is not Send." You have no idea what's not Send or why it matters. This is the final boss of Rust's async story, and the fix is almost always structural.

The motivation

use std::rc::Rc;

async fn do_work() {
    let data = Rc::new("hello");
    some_async_call().await;
    println!("{data}");
}

fn main() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    rt.block_on(async {
        tokio::spawn(do_work()); // ERROR
    });
}
error: future cannot be sent between threads safely
  -- within `impl Future<Output = ()>`, the trait `Send`
     is not implemented for `Rc<&str>`

Why tokio::spawn needs Send

Tokio uses a work-stealing scheduler. A task might start on thread 1, suspend at an .await, and resume on thread 3. The runtime moves the future between threads. If the future contains non-Send data, that move is unsound.

Send means "safe to transfer to another thread." Types like Rc (non-atomic reference count), RefCell (non-atomic borrow flag), raw pointers, and some mutex guards are !Send. If any of these are alive across an .await point, the future's state machine captures them, and the future becomes !Send.

Finding the culprit

The compiler error usually tells you exactly what's wrong:

note: future is not `Send` as this value is used across an await
  --> src/main.rs:4:5
   |
3  |     let data = Rc::new("hello");
   |         ---- has type `Rc<&str>` which is not `Send`
4  |     some_async_call().await;
   |     ^^^^^^^^^^^^^^^^^^^^^^^ await occurs here, with `data` maybe used afterwards

Read this carefully: it points to the variable (data), its type (Rc<&str>), and the .await point it's held across. The fix is to make data not alive at the .await.

Pattern 1: Scope the non-Send value

Drop the non-Send value before the .await:

async fn do_work() {
    {
        let data = Rc::new("hello");
        println!("{data}");
    } // data dropped here

    some_async_call().await; // no non-Send values alive — future is Send
}

By scoping data into a block that ends before the .await, it's not part of the future's state machine at the suspension point. The future becomes Send.

Pattern 2: Extract synchronous work

Move the non-Send work into a synchronous function:

fn process_locally() -> String {
    let data = Rc::new("hello");
    // ... complex work with Rc ...
    data.to_string()
}

async fn do_work() {
    let result = process_locally(); // Rc lives and dies here
    some_async_call().await;        // only String (Send) crosses the await
    println!("{result}");
}

The non-Send type lives entirely within the synchronous function. Only the Send-safe result crosses the .await.

Pattern 3: Use Send-safe alternatives

Often the simplest fix is replacing the non-Send type with its thread-safe counterpart:

use std::sync::Arc;

async fn do_work() {
    let data = Arc::new("hello"); // Arc is Send
    some_async_call().await;
    println!("{data}");             // works — future is Send
}

Pattern 4: spawn_local

If the task genuinely can't be Send (it deeply uses Rc and restructuring is impractical), use tokio::task::spawn_local:

let local = tokio::task::LocalSet::new();
local.run_until(async {
    tokio::task::spawn_local(do_work());
}).await;

spawn_local runs the task on the current thread only - no work stealing, no thread migration. The future doesn't need to be Send because it never moves between threads. The trade-off is that local tasks don't benefit from multi-threaded scheduling.

Sync briefly

Send means "safe to move to another thread." Sync means "safe to reference from another thread." Formally: T is Sync if and only if &T is Send.

Sync comes up less often in async code, but the principle is the same. If a shared reference to your type crosses an .await and ends up on another thread, the type needs to be Sync.

The key insight

"Future is not Send" errors feel like type-system puzzles, but the fix is almost always structural:

  1. Find the non-Send value (the compiler tells you)
  2. Find the .await it's held across (the compiler tells you this too)
  3. Make the value not alive at the .await - scope it, extract it into a sync function, or replace it with a Send alternative

The fix is restructuring, not fighting the type system.

Series wrap-up

This is the twenty-second and final pattern. The series started with the first wall (interior mutability) and ends with the last one (Send/Sync in async). Each pattern exists because Rust's ownership model forces you to express things that other languages leave implicit. The patterns aren't workarounds - they're the vocabulary of productive Rust.

See it in practice: Building a Chat Server #6: Going Async uses this pattern for tokio::spawn compatibility.