How to Use the Actor Model in Rust (actix, xtra)

Use actix or xtra crates to define actor structs and spawn them in a runtime to handle messages asynchronously.

When shared state becomes a bottleneck

You are building a multiplayer game server. Player A jumps. Player B shoots. Player C chats. If you store the entire game world in one big struct protected by a Mutex, every action waits for the previous one to finish. The server chokes under contention. You need a way to let each player update their own slice of the world in parallel, without stepping on each other's toes.

That is where the actor model comes in. Instead of sharing memory, you split the world into independent units called actors. Each actor owns its state and communicates only by sending messages. This eliminates race conditions by design and scales naturally across threads.

The actor model in plain words

An actor is a lightweight process that encapsulates state and behavior. It has three rules:

  1. Actors keep their state private. No other code can read or write it directly.
  2. Actors communicate by sending messages to each other.
  3. Actors process messages one at a time, sequentially.

Think of actors like independent workers in a factory. Each worker has their own desk and tools. If Worker A needs something from Worker B, A does not walk over and grab it. A writes a note and drops it in B's mailbox. B reads the note when they have time and reacts.

This pattern removes the need for locks. Since only one actor touches its own data at a time, there is no concurrent access to protect. The compiler can verify this isolation, and the runtime handles the threading.

Actors fit Rust's ownership perfectly

Rust's ownership model aligns with the actor model in a way that other languages struggle to match. When you spawn an actor, you move the initial state into it. The compiler guarantees that no other code holds a reference to that data. The actor boundary becomes an ownership boundary.

In languages without ownership, you can accidentally share a reference between actors and break isolation. In Rust, the borrow checker forces you to respect the boundary. If you try to hold a reference to actor state from outside, the compiler rejects you with E0382 (use of moved value). You must send a message.

This makes Rust actors safer than actors in dynamic languages. The compiler enforces the isolation rules at compile time, not just by convention.

Minimal example with actix

The actix crate is the most popular actor framework for Rust. It provides a runtime, mailbox management, and lifecycle hooks. Here is a minimal actor that counts messages.

use actix::prelude::*;

/// An actor that counts how many messages it has received.
struct Counter {
    count: usize,
}

/// The Actor trait defines the lifecycle and context type.
impl Actor for Counter {
    /// Context<Self> provides a simple mailbox and address management.
    type Context = Context<Self>;
}

/// Message type to increment the counter.
struct Increment;

/// Handler defines how the actor reacts to a specific message.
impl Handler<Increment> for Counter {
    /// The Result type is the reply sent back to the sender.
    type Result = usize;

    /// handle runs when a message arrives.
    /// &mut self guarantees exclusive access to actor state.
    fn handle(&mut self, _msg: Increment, _ctx: &mut Self::Context) -> Self::Result {
        self.count += 1;
        self.count
    }
}

fn main() {
    // Create the actix system runtime.
    let sys = System::new();

    // Spawn the actor. start() moves the data into the actor.
    let addr = Counter { count: 0 }.start();

    // Send a message. do_send is fire-and-forget.
    addr.do_send(Increment);

    // Run the system until all actors stop.
    sys.run().unwrap();
}

The Counter struct holds the state. The Actor implementation tells the runtime how to manage the actor. The Handler implementation defines the reaction to Increment messages. When you call addr.do_send(Increment), the message goes into the actor's mailbox. The actor loop pops the message and calls handle.

Notice the &mut self in handle. This is the key to safety. The actor runtime guarantees that handle runs sequentially. You get mutable access without locks. The compiler sees &mut self and knows no other code can touch the state while this handler runs.

Convention aside: use do_send for commands where you do not need a reply. Use send when you need to wait for a result. send returns a future that resolves to the Result type. Mixing these up is a common source of confusion. Stick to do_send for simple actions.

Trust the sequential guarantee. If handle has &mut self, you do not need Mutex inside the actor.

Realistic example: Async actors

Real applications often need to do asynchronous work inside actors, like database queries or network requests. For that, you use AsyncContext instead of Context. AsyncContext integrates with tokio and lets you spawn async tasks that run inside the actor's mailbox loop.

use actix::prelude::*;
use std::time::Duration;

/// Message to trigger a delayed greeting.
struct DelayedGreet {
    name: String,
}

/// Actor that handles delayed tasks.
struct Greeter;

impl Actor for Greeter {
    /// AsyncContext enables async operations and timers.
    type Context = AsyncContext<Self>;
}

/// Handler for the initial request.
impl Handler<DelayedGreet> for Greeter {
    type Result = ();

    fn handle(&mut self, msg: DelayedGreet, ctx: &mut Self::Context) {
        // Schedule a task to run after 1 second.
        // The task will be processed by the same actor loop.
        ctx.notify_after(GreetTask(msg.name), Duration::from_secs(1));
    }
}

/// Internal task that runs after the delay.
struct GreetTask(String);

impl Handler<GreetTask> for Greeter {
    type Result = ();

    fn handle(&mut self, msg: GreetTask, _ctx: &mut Self::Context) {
        println!("Hello, {}!", msg.0);
    }
}

fn main() {
    let sys = System::new();
    let greeter = Greeter.start();
    greeter.do_send(DelayedGreet { name: "Rustacean".to_string() });
    sys.run().unwrap();
}

AsyncContext adds methods like notify_after and wait. These let you schedule future work without blocking the actor. The GreetTask is just another message type. The actor processes it when the timer fires.

Convention aside: keep AsyncContext for actors that do I/O. Use Context for CPU-bound logic or actors that only forward messages. AsyncContext has slightly more overhead because it integrates with the async runtime. Pick the context type that matches your workload.

Actors scale by isolation, not by locking. If you find yourself adding Mutex inside an actor, you are fighting the model. Split the state into smaller actors instead.

Pitfalls and compiler errors

Actors solve many concurrency problems, but they introduce new ones. Here are the common traps.

Message cycles cause deadlocks. If Actor A sends a message to Actor B, and Actor B waits for a reply from Actor A before processing, the system deadlocks. Actors process messages sequentially. If B is waiting for A, and A is waiting for B, neither moves. Design your message flow as a directed acyclic graph. Avoid circular dependencies.

Mailboxes overflow silently. Actors have finite mailboxes. If you send messages faster than the actor can process them, the mailbox fills up. do_send drops the message when the mailbox is full. You lose data without an error. Check the return value of do_send if message loss is unacceptable, or use send to get backpressure.

Messages must be Send. Actors often run on different threads. Messages crossing thread boundaries must implement Send. If you try to send a message with non-Send data, the compiler rejects you with E0277 (trait bound not satisfied). This usually happens when you include Rc<T> or &str in a message. Use Arc<T> and String instead.

Address cloning is cheap, but actors are not. Cloning an actor address is just copying a pointer. Spawning an actor allocates a mailbox and schedules work. Do not spawn actors in tight loops. Reuse addresses or batch messages.

Handle panics kill the actor. If handle panics, the actor stops. The runtime does not restart it automatically. You lose the state and the address becomes invalid. Use catch_unwind inside handlers if you need resilience, or implement supervision with a parent actor that monitors children.

Treat the mailbox as a contract. If you send a message, the actor must handle it. If the actor dies, the sender needs to know.

Decision: when to use actors

The actor model is powerful, but it is not the only tool. Choose the right abstraction for your problem.

Use actix when you need a full-featured actor system with supervision, lifecycle management, and integration with the actix-web ecosystem. Use xtra when you want a lightweight, zero-dependency actor layer on top of tokio without the overhead of a separate runtime. Use tokio::sync::mpsc channels when your logic is a simple pipeline and you do not need named addresses or complex message routing. Use Arc<Mutex<T>> when you have read-heavy shared state and contention is low; actors add indirection that can hurt performance for simple data access.

Counter-intuitive but true: the more you use actors, the harder debugging becomes. Distributed logic is harder to trace than centralized state. Use actors for isolation and scalability, not just because they are trendy.

Where to go next