January 4, 2026
Sending Trait Objects Between Threads in Rust: The “dyn Trait + Send” Playbook (with optimal…
You hit Run, spawned a thread, and Rust yelled:
By Ajay Kumar
5 min read
error[E0277]: dyn Bar cannot be sent between threads safely
If you're new to Rust, this feels mysterious. You boxed your trait object, you used a channel, so… why the fuss?
Good news: it's absolutely possible to send trait objects between threads — you just need to teach the compiler that the erased type behind the trait object is safe to move across threads.
Below is a clear, production-ready explanation (with code you can paste), plus when to choose trait objects vs generics vs shared references, and what I recommend as the "optimal" design depending on your use case.
Tokio Explained: https://tobiweissmann.gumroad.com/l/xezgk
Summary: Fix
Add the Send bound to your trait object:
let foo = Box::new(Foo { foo: 1 }) as Box<dyn Bar + Send>;
let (tx, rx): (Sender<Box<dyn Bar + Send>>, Receiver<Box<dyn Bar + Send>>) = channel();let foo = Box::new(Foo { foo: 1 }) as Box<dyn Bar + Send>;
let (tx, rx): (Sender<Box<dyn Bar + Send>>, Receiver<Box<dyn Bar + Send>>) = channel();Or — if you control the trait and you always need cross-thread movement — bake it right into the trait:
trait Bar: Send {
fn bar(&self);
}trait Bar: Send {
fn bar(&self);
}Then Box<dyn Bar> is sendable by default.
Why the error happens (in plain English)
Sendis a marker (auto) trait that says "this value can be safely moved to another thread."- A trait object like
dyn Barerases the concrete type at compile time. The compiler can't auto-inferSendfordyn Barunless you require it. - Therefore, you must write
dyn Bar + Send(or declaretrait Bar: Send) to promise the underlying type implementsSend.
Think of + Send as adding a safety passport to your trait object so it can cross thread borders.
A Minimal, Working Example
Option A — Add Send to the trait itself (cleanest if you control the trait)
use std::{
sync::mpsc::{channel, Sender, Receiver},
thread,
};
trait Bar: Send {
fn bar(&self);
}
struct Foo { foo: i32 }
impl Bar for Foo {
fn bar(&self) {
println!("foo: {}", self.foo);
}
}
fn main() {
let foo: Box<dyn Bar> = Box::new(Foo { foo: 1 });
let (tx, rx): (Sender<Box<dyn Bar>>, Receiver<Box<dyn Bar>>) = channel();
let h = thread::spawn(move || {
tx.send(foo).unwrap();
});
let sent = rx.recv().unwrap();
sent.bar();
h.join().unwrap();
}use std::{
sync::mpsc::{channel, Sender, Receiver},
thread,
};
trait Bar: Send {
fn bar(&self);
}
struct Foo { foo: i32 }
impl Bar for Foo {
fn bar(&self) {
println!("foo: {}", self.foo);
}
}
fn main() {
let foo: Box<dyn Bar> = Box::new(Foo { foo: 1 });
let (tx, rx): (Sender<Box<dyn Bar>>, Receiver<Box<dyn Bar>>) = channel();
let h = thread::spawn(move || {
tx.send(foo).unwrap();
});
let sent = rx.recv().unwrap();
sent.bar();
h.join().unwrap();
}Option B — Keep the trait unchanged; add + Send where needed
use std::{
sync::mpsc::{channel, Sender, Receiver},
thread,
};
trait Bar {
fn bar(&self);
}
struct Foo { foo: i32 }
impl Bar for Foo {
fn bar(&self) {
println!("foo: {}", self.foo);
}
}
fn main() {
// Note the + Send (+ 'static is often useful with threads)
let foo = Box::new(Foo { foo: 1 }) as Box<dyn Bar + Send + 'static>;
let (tx, rx): (
Sender<Box<dyn Bar + Send>>,
Receiver<Box<dyn Bar + Send>>
) = channel();
thread::spawn(move || {
tx.send(foo).unwrap();
});
let sent = rx.recv().unwrap();
sent.bar();
}use std::{
sync::mpsc::{channel, Sender, Receiver},
thread,
};
trait Bar {
fn bar(&self);
}
struct Foo { foo: i32 }
impl Bar for Foo {
fn bar(&self) {
println!("foo: {}", self.foo);
}
}
fn main() {
// Note the + Send (+ 'static is often useful with threads)
let foo = Box::new(Foo { foo: 1 }) as Box<dyn Bar + Send + 'static>;
let (tx, rx): (
Sender<Box<dyn Bar + Send>>,
Receiver<Box<dyn Bar + Send>>
) = channel();
thread::spawn(move || {
tx.send(foo).unwrap();
});
let sent = rx.recv().unwrap();
sent.bar();
}Tip: The
'staticbound is commonly required when values enter a spawned thread, because the thread can outlive the current stack frame. If you don't borrow non-static data, your value is'staticby default.
When you also need Sync (and maybe Arc)
Send = can be moved to another thread.
Sync = can be referenced from multiple threads at the same time.
If you share the same trait object across threads (not move it), wrap it in Arc and add Sync:
use std::sync::Arc;
use std::thread;
trait Bar: Send + Sync {
fn bar(&self);
}
struct Foo { foo: i32 }
impl Bar for Foo {
fn bar(&self) { println!("foo: {}", self.foo); }
}
fn main() {
let shared: Arc<dyn Bar + Send + Sync> = Arc::new(Foo { foo: 1 });
let a = Arc::clone(&shared);
let b = Arc::clone(&shared);
let t1 = thread::spawn(move || a.bar());
let t2 = thread::spawn(move || b.bar());
t1.join().unwrap();
t2.join().unwrap();
}use std::sync::Arc;
use std::thread;
trait Bar: Send + Sync {
fn bar(&self);
}
struct Foo { foo: i32 }
impl Bar for Foo {
fn bar(&self) { println!("foo: {}", self.foo); }
}
fn main() {
let shared: Arc<dyn Bar + Send + Sync> = Arc::new(Foo { foo: 1 });
let a = Arc::clone(&shared);
let b = Arc::clone(&shared);
let t1 = thread::spawn(move || a.bar());
let t2 = thread::spawn(move || b.bar());
t1.join().unwrap();
t2.join().unwrap();
}If you need mutation, add a lock (e.g., Mutex) and keep + Send + Sync:
use std::sync::{Arc, Mutex};
type SharedBar = Arc<Mutex<dyn Bar + Send>>;
// Call with `lock()` inside threads before using.use std::sync::{Arc, Mutex};
type SharedBar = Arc<Mutex<dyn Bar + Send>>;
// Call with `lock()` inside threads before using.Optimal Patterns: Which one should you use?
1) Generics (fastest & simplest when types are uniform)
If all senders/receivers use the same concrete type (or you can parametrize it), skip trait objects:
fn hand_off<T: Bar + Send + 'static>(tx: Sender<T>, value: T) {
std::thread::spawn(move || { tx.send(value).unwrap(); });
}fn hand_off<T: Bar + Send + 'static>(tx: Sender<T>, value: T) {
std::thread::spawn(move || { tx.send(value).unwrap(); });
}Pros: zero virtual dispatch, better inlining/optimizations, clearer types. Cons: can't mix heterogenous types without enums.
Use when: You control the types and don't need heterogeneity.
2) Enums (heterogenous set, known at compile time)
If you have a small set of variants, model them as an enum instead of a trait object:
enum Job {
Foo(Foo),
Bar(BarType), // …
}
impl Job {
fn run(&self) { /* match &self and act */ }
}enum Job {
Foo(Foo),
Bar(BarType), // …
}
impl Job {
fn run(&self) { /* match &self and act */ }
}Pros: static dispatch; still heterogenous. Cons: needs editing when you add new variants.
Use when: Variants are few and relatively stable.
3) Trait Objects (heterogenous & open-ended)
When callers can plug in any type implementing your trait, trait objects are perfect:
type DynBar = Box<dyn Bar + Send + 'static>;
let (tx, rx): (Sender<DynBar>, Receiver<DynBar>) = channel();type DynBar = Box<dyn Bar + Send + 'static>;
let (tx, rx): (Sender<DynBar>, Receiver<DynBar>) = channel();Pros: extensible, plugin-friendly.
Cons: dynamic dispatch (slight overhead), you must add + Send/+ Sync explicitly.
Use when: You need open-ended polymorphism across threads.
Common Pitfalls & How to Avoid Them
- Forgetting
+ Send: If you see "dyn Traitcannot be sent between threads safely," add+ Sendto the trait object or declaretrait Trait: Send. - Needing
'static:thread::spawnoften requires'staticbecause the thread can outlive the scope. Don't capture short-lived borrows. - Mutation across threads: Use
Arc<Mutex<...>>orArc<RwLock<...>>. You'll also need+ Send + Sync. - Object safety: Trait methods used through trait objects must be object-safe. For example, methods can't be generic over
Self, and methods takingselfby value must ensure object safety. Yourfn bar(&self)is object-safe—good! - Channel choice:
std::sync::mpscis fine for many cases. For heavier workloads or multi-consumer needs, considercrossbeam-channelorflume.
A Clean, Idiomatic Solution I Recommend
If you're designing an API where implementations will be sent or shared across threads, encode those guarantees into the trait. It reduces friction for everyone:
use std::sync::mpsc::{channel, Sender, Receiver};
use std::thread;
trait Bar: Send { // Bake in Send
fn bar(&self);
}
struct Foo { foo: i32 }
impl Bar for Foo {
fn bar(&self) { println!("foo: {}", self.foo); }
}
type DynBar = Box<dyn Bar + 'static>; // Send is implied by the supertrait
fn main() {
let foo: DynBar = Box::new(Foo { foo: 1 });
let (tx, rx): (Sender<DynBar>, Receiver<DynBar>) = channel();
let h = thread::spawn(move || {
tx.send(foo).unwrap();
});
let sent = rx.recv().unwrap();
sent.bar();
h.join().unwrap();
}use std::sync::mpsc::{channel, Sender, Receiver};
use std::thread;
trait Bar: Send { // Bake in Send
fn bar(&self);
}
struct Foo { foo: i32 }
impl Bar for Foo {
fn bar(&self) { println!("foo: {}", self.foo); }
}
type DynBar = Box<dyn Bar + 'static>; // Send is implied by the supertrait
fn main() {
let foo: DynBar = Box::new(Foo { foo: 1 });
let (tx, rx): (Sender<DynBar>, Receiver<DynBar>) = channel();
let h = thread::spawn(move || {
tx.send(foo).unwrap();
});
let sent = rx.recv().unwrap();
sent.bar();
h.join().unwrap();
}If you also need to share the same value across threads concurrently, make it:
type SharedBar = std::sync::Arc<dyn Bar + Send + Sync + 'static>;type SharedBar = std::sync::Arc<dyn Bar + Send + Sync + 'static>;…and you're set.
Performance Notes
- Generics > Enums > Trait Objects is a good rule of thumb for performance (due to static vs dynamic dispatch). But trait object overhead is usually tiny compared to I/O or real work.
- If your workload is channel-heavy with many producers/consumers, benchmark
crossbeam-channelorflume.
Takeaway
- Yes, you can send trait objects between threads.
- The fix is simple: add
Sendeither to the object type (dyn Bar + Send) or as a supertrait (trait Bar: Send). - Choose the optimal pattern:
- Generics when you can (fastest, simplest).
- Enums for small, known variant sets.
- Trait objects for open-ended plugins — just remember
+ Send(and+ Sync+Arcif sharing).
If you paste any of the examples above into a fresh project, they'll compile and run. Happy threading — and may your trait objects travel safely!
Tokio Explained: https://tobiweissmann.gumroad.com/l/xezgk