Your own actor library in 150 lines of Rust
Actors get a reputation for being heavyweight — Erlang VMs, supervision trees, distributed clusters. Strip that away and the core is tiny: an isolated piece of state, a mailbox, and a loop that pulls messages and runs handlers. In this mini-course we build exactly that on top of tokio, and end up with typed send, typed request/response, and graceful shutdown — all type-checked by the compiler. We'll grow it one small piece at a time.
The idea, in one sentence
An actor is some state that the outside world can only touch by mailing it a message; a runtime owns the state and processes the mailbox one message at a time. Because only one task ever holds the state, there are no locks — concurrency safety comes from the channel, not from Mutex.
The whole design rests on two declarations. The first is the trait that users implement:
#[async_trait]
trait Actor: Send + 'static {
async fn event(
&mut self,
ctx: &mut Context<Self>,
) -> Result<()>;
}
This is the contract every actor signs: "give me a turn, and I'll process one event." Result is the catch-all from the anyhow crate, so a handler can ? any failure upward. The #[async_trait] attribute lets the trait carry an async fn, which stable Rust still can't do natively.
The second declaration is the engine we write:
struct ActorRuntime<A: Actor> {
actor: A,
context: Context<A>,
}
The runtime bundles the user's actor with a Context and drives it. The split between Actor (what users write) and ActorRuntime (what runs it) is the whole point — users describe behavior, we own the loop.
A mailbox is just a channel
The mailbox is an mpsc channel — multi-producer, single-consumer. That shape is the actor model in one type: many Address clones can push, exactly one runtime pulls. We use the unbounded variant so senders never block waiting for the actor to catch up.
struct Context<A: ?Sized> {
active: bool,
msg_rx: UnboundedReceiver<Envelope<A>>,
status_tx: watch::Sender<Status>,
}
active is the flag the loop checks each turn — flip it false and the actor exits. msg_rx is the receiving half of the mailbox (the UnboundedReceiver comes from tokio's mpsc). status_tx is a watch sender we'll use later to announce that the actor has finished.
Now the loop. Start with the signature and the condition:
impl<A: Actor> ActorRuntime<A> {
async fn entrypoint(mut self) {
while self.context.active {
// ... process one event ...
}
}
}
entrypoint takes self by value because it owns the actor for its whole life — nobody else can reach the state. The while self.context.active loop runs until something sets the flag to false, at which point the actor is done.
Inside the loop, hand control to the actor for exactly one event:
let result =
self.actor.event(&mut self.context).await;
if let Err(err) = result {
log::error!("event failed: {err}");
}
Each turn calls the user's event method and awaits it. If the handler returns Err, we log it and keep looping — one bad message doesn't kill the actor, which is exactly the resilience you want from a long-lived service.
When the loop finally exits, announce it:
let _ =
self.context.status_tx.send(Status::Done);
This pushes Status::Done onto the watch channel so anyone waiting on the actor learns it has stopped. We ignore the send result with let _ because if every receiver is already gone, there's simply no one left to notify — and that's fine.
Erasing the message type with an envelope
Here's the central trick. A channel carries one type, but an actor must accept many message types. So we don't send messages — we send boxed handlers. The trait that makes this work:
#[async_trait]
trait MessageFor<A: Actor + ?Sized>: Send {
async fn handle(
self: Box<Self>,
actor: &mut A,
ctx: &mut Context<A>,
) -> Result<()>;
}
A MessageFor<A> is a message that knows how to apply itself to an actor of type A. Note self: Box<Self> — the handler consumes its own boxed allocation as it runs, since a message is used exactly once. That single method is the whole interface between a message and the actor it targets.
With that trait in hand, the mailbox's element type collapses to one thing:
type Envelope<A> = Box<dyn MessageFor<A>>;
Every concrete message type erases into the same Box<dyn MessageFor<A>>, so a single-type channel can now carry arbitrarily many message types. The runtime never needs to know what's inside the box — only that it can be asked to run.
So the actor's event method just pulls the next envelope and tells it to dispatch itself:
if let Some(envelope) =
ctx.next_envelope().await
{
envelope.handle(self, ctx).await?;
} else {
ctx.stop();
}
next_envelope awaits the next item from the mailbox. When one arrives, handle runs it against the actor and ? propagates any error up into the loop's logging. A None instead means the channel is closed — every Address has been dropped, so no more mail can ever arrive — so we stop, which flips active false and lets the loop wind down. The message dispatches itself; the runtime is just a courier, and this is the move that turns a single-type channel into a polymorphic actor.
Spawning, and the Address you get back
To start an actor, wire up the channels, drop them into a Context, hand it to a runtime, and tokio::spawn the loop. We expose this through a blanket trait so every Actor gets .spawn() for free:
trait Standalone {
fn spawn(self) -> Address<Self>;
}
The trait has one method that consumes the actor and returns an Address — the only handle the outside world will keep. Because it's a blanket trait (implemented for all actors at once), nobody has to write spawn themselves.
The implementation starts by creating the mailbox:
impl<A: Actor> Standalone for A {
fn spawn(self) -> Address<Self> {
let (msg_tx, msg_rx) =
mpsc::unbounded_channel();
// … status channel, context, spawn …
}
}
unbounded_channel hands back a sender/receiver pair. The sender (msg_tx) will live inside the Address so callers can post mail; the receiver (msg_rx) goes into the runtime so it can pull mail. One channel, split across the two sides.
Next, the status channel for shutdown signaling:
let (status_tx, status_rx) =
watch::channel(Status::Active);
A watch channel always holds a current value, starting here at Status::Active. The runtime keeps status_tx to flip it to Done on exit; the Address keeps status_rx so a caller can wait for that flip. We'll use this for join later.
Now assemble the context and the runtime around them:
let ctx = Context {
active: true,
msg_rx,
status_tx,
};
let runtime =
ActorRuntime { actor: self, context: ctx };
The Context bundles the receiving half of the mailbox with the status sender and an active flag set true. The ActorRuntime then wraps the user's actor (self) together with that context — everything the loop needs is now in one value.
Finally, launch the loop and return the handle:
tokio::spawn(runtime.entrypoint());
Address { msg_tx, status_rx }
tokio::spawn moves the runtime onto the executor, where entrypoint runs independently of the caller. The state is now gone into that task; you can only reach it through the returned Address, which holds the sending half of the mailbox and the status receiver. That's enforced isolation, for free.
Typed messages on top of the envelope
The MessageFor machinery is plumbing users shouldn't see. We hide it behind a friendly trait: implement OnEvent<E> for the event types you care about.
#[async_trait]
trait OnEvent<E>: Actor {
async fn handle(
&mut self,
event: E,
ctx: &mut Context<Self>,
) -> Result<()>;
}
OnEvent<E> says "this actor knows how to handle events of type E." An actor can implement it many times for many event types, and each handle receives a concrete E — no boxing, no downcasting in user code. This is the API authors actually touch.
To connect this friendly trait back to the envelope machinery, we need a carrier type and one blanket impl. Here's the bridge:
#[async_trait]
impl<A, E> MessageFor<A> for Event<E>
where
A: OnEvent<E>,
E: Send + 'static,
{
// … fn handle …
}
This says: an Event<E> is a valid MessageFor<A>, but only where A: OnEvent<E> — only when the actor actually knows how to handle that event. The constraint is checked at compile time, so a message the actor can't handle won't even compile.
The body of that impl just unwraps and forwards:
async fn handle(
self: Box<Self>,
actor: &mut A,
ctx: &mut Context<A>,
) -> Result<()> {
actor.handle(self.event, ctx).await
}
When the runtime asks this envelope to run, it pulls the typed event out of the box and calls the actor's own handle. The polymorphic MessageFor::handle and the typed OnEvent::handle line up here — this method is the seam between them.
Now usage reads like a normal API. A user implements OnEvent for their event type:
impl OnEvent<String> for TestActor {
async fn handle(
&mut self,
name: String,
_ctx: &mut Context<Self>,
) -> Result<()> {
println!("Hello, {name}!");
Ok(())
}
}
This actor handles String events by greeting them — plain Rust, no envelopes in sight. The _ctx is there if the handler needs to send more messages or stop itself, but this one ignores it.
And sending is two lines:
let addr = TestActor.spawn();
addr.send(Event::new("Actor".to_string()))?;
spawn (from the blanket trait earlier) starts the actor and gives back its Address; send wraps the value in an Event and posts it. Try to send an event the actor doesn't implement OnEvent for, and it's a type error at the call site — not a runtime panic.
Fire-and-forget vs. ask: the oneshot reply
send is fire-and-forget. But sometimes you need an answer — "what's the sum?" The pattern is a request bundled with a private return channel: a oneshot (single-use) sender goes with the message, and the caller keeps the receiver.
First, mark which types are requests and what they reply with:
trait Request: Send + 'static {
type Response: Send;
}
Request ties each request type to its Response type, so the compiler knows that asking a Sum gives back a number and not, say, a string. This is what makes the round trip type-safe end to end.
The request travels alongside its private reply channel:
struct Interaction<T: Request> {
request: T,
tx: oneshot::Sender<Result<T::Response>>,
}
An Interaction packs the request together with tx, the sending half of a oneshot channel. The actor will eventually push a Result<T::Response> into tx — wrapped in Result so a handler error can travel back to the caller too.
The caller's side, interact, builds the channel, ships the request, and keeps the receiver:
fn interact(
&self,
request: T,
) -> Result<Responder<T>> {
let (tx, rx) = oneshot::channel();
self.send(Interaction { request, tx })?;
Ok(Responder { rx })
}
It mints a fresh oneshot, sends the Interaction (carrying tx) into the mailbox, and hands back a Responder wrapping rx. The Responder is a small future: await it to get the reply once the actor produces one.
So the whole round trip from the caller's view is one line:
let sum = calc.interact(Sum(16, 4))?.await??;
assert_eq!(sum, 20);
Two ?s after the await, on purpose: the first unwraps "did the reply channel survive?", the second unwraps "did the handler itself succeed?". Request/response, but every message is still just an envelope underneath.
Stopping cleanly
Two distinct ideas, easy to confuse. stop flips active = false so the loop exits after the current message. shutdown closes the receiver so no new mail arrives, but the queue drains first. Interrupt uses shutdown — a polite "finish what you have, then quit":
async fn interrupt(
&mut self,
ctx: &mut Context<Self>,
) -> Result<()> {
ctx.shutdown();
Ok(())
}
shutdown closes the mailbox so senders can no longer enqueue, but anything already queued still gets processed before the loop sees the channel empty and stops. That's the difference from a hard stop: in-flight work isn't thrown away.
And how does a caller wait for an actor to finish? Through that watch channel from spawn:
async fn join(&mut self) -> Result<()> {
self.status_rx
.wait_for(Status::is_done)
.await?;
Ok(())
}
wait_for parks until the watched Status satisfies is_done, which becomes true the moment the runtime sends Status::Done on exit. Because a watch channel always remembers its latest value, calling join after the actor already died still observes Done — no race, no missed signal.
Where to go next / in production
This is real, but a production runtime adds:
- Supervision — a parent that restarts crashed children, the part Erlang is famous for.
- Bounded mailboxes — unbounded channels can grow without limit; back-pressure protects memory.
- Timeouts on
interact— wrap theResponderin atokiotimeout so a stuck actor can't hang callers forever. - Streams as messages — let an actor subscribe to a stream by feeding each item in as an event.
Why build it
Async Rust's borrow rules make shared mutable state genuinely awkward — and the actor model sidesteps the whole problem by not sharing it. Build this once and you'll see that "no data races" isn't enforced by locks here at all; it's enforced by the fact that one task owns the state and the type system guarantees every message it receives is one it knows how to handle. That's a lot of safety for 150 lines.