What Is the Cost of Dynamic Dispatch (dyn Trait) in Rust?

The cost of dynamic dispatch using `dyn Trait` is a single pointer indirection per method call, which prevents the compiler from inlining the function. Unlike static dispatch, the compiler cannot determine the concrete type at compile time, so it must look up the method in a virtual table (vtable) a

What Is the Cost of Dynamic Dispatch (dyn Trait) in Rust?

You are building a game engine. You have Goblin, Orc, and Dragon structs. They all implement Enemy. You want to store a squad in a Vec<Enemy>. The compiler screams. You wrap them in Box<dyn Enemy>. It compiles. You run a benchmark. The frame rate drops by 2%. You stare at the code. Is dyn killing your performance? Or is the profiler lying?

The cost of dynamic dispatch is real, but it is rarely the bottleneck you fear. The overhead is a single pointer indirection per method call, which prevents the compiler from inlining the function. In tight loops, that indirection can hurt CPU cache locality. In most applications, the cost is negligible compared to I/O or complex logic. Understanding exactly what happens under the hood helps you decide when to pay the cost and when to avoid it.

The switchboard analogy

Static dispatch is like having a direct phone line to every function. The compiler knows the exact memory address of the function before the program runs. It bakes that address into the binary. When you call the function, the CPU jumps straight there. No lookup. No delay.

Dynamic dispatch is like calling a switchboard operator. You tell the operator "I want to call do_something." The operator looks up the number in a directory, finds the right line, and connects you. The directory is the vtable. The lookup takes time. The operator cannot predict which line you will call, so they cannot prepare the connection in advance.

In Rust, static dispatch happens with generics and impl Trait. The compiler generates a separate copy of the code for every concrete type. Dynamic dispatch happens with dyn Trait. The compiler generates one copy of the code that works for any type implementing the trait, but it must look up the actual function at runtime.

Minimal example

trait Sound {
    fn make_sound(&self);
}

struct Dog;
struct Cat;

impl Sound for Dog {
    fn make_sound(&self) {
        // Concrete implementation for Dog.
        println!("Woof");
    }
}

impl Sound for Cat {
    fn make_sound(&self) {
        // Concrete implementation for Cat.
        println!("Meow");
    }
}

// Static dispatch: the compiler knows the type is Dog.
// It can inline make_sound directly.
fn static_bark(dog: &Dog) {
    dog.make_sound();
}

// Dynamic dispatch: the compiler only knows this is a Sound.
// It must look up the function in the vtable at runtime.
fn dynamic_sound(animal: &dyn Sound) {
    animal.make_sound();
}

fn main() {
    let dog = Dog;
    static_bark(&dog);      // Direct jump. Fast.
    dynamic_sound(&dog);    // Vtable lookup. Slightly slower.
}

The static_bark function receives a reference to Dog. The compiler sees Dog::make_sound and replaces the call with the body of the function. This is inlining. It eliminates call overhead and enables further optimizations.

The dynamic_sound function receives a &dyn Sound. The compiler does not know if this is a Dog, a Cat, or something else. It generates code that loads the vtable pointer, finds the make_sound slot, and jumps to that address. The function body cannot be inlined because the target is unknown.

How the vtable works

When you use dyn Trait, you are not passing a single pointer. You are passing a fat pointer. A fat pointer contains two usize values. The first value is the address of the data. The second value is the address of the vtable.

The vtable is a structure generated by the compiler for each trait. It contains function pointers for every method in the trait. It also contains metadata for dropping the value and converting between trait objects.

// Conceptual layout of a vtable for Sound.
// This is not valid Rust code; it illustrates the structure.
struct SoundVTable {
    make_sound: fn(*const ()),
    drop_in_place: fn(*mut ()),
    // ... other metadata ...
}

When you call animal.make_sound() on a &dyn Sound, the CPU does the following:

  1. Load the vtable pointer from the fat pointer.
  2. Load the make_sound function pointer from the vtable.
  3. Jump to that function pointer.

This is three memory accesses instead of one. Modern CPUs handle this efficiently. The vtable usually sits in the read-only data section and stays in the L1 cache. The cost is often a few nanoseconds.

The inlining loss is the bigger issue. Inlining allows the compiler to optimize across function boundaries. It can eliminate dead code, constant propagate values, and vectorize loops. Without inlining, the compiler must assume the function might do anything. It cannot optimize the call site as aggressively.

The vtable lookup is a single memory access. The inlining loss is the real tax.

Realistic example: plugin system

Dynamic dispatch shines when you need to store heterogeneous types in a collection. Generics require all elements to have the same type. dyn Trait lets you mix types.

trait Plugin {
    fn run(&self);
}

struct Logger;
struct Analytics;

impl Plugin for Logger {
    fn run(&self) {
        // Log to file or console.
        println!("[LOG] Event occurred");
    }
}

impl Plugin for Analytics {
    fn run(&self) {
        // Track metrics.
        println!("[ANALYTICS] Tracking event");
    }
}

fn main() {
    // Heterogeneous collection requires dynamic dispatch.
    // Each Box holds a different concrete type.
    let plugins: Vec<Box<dyn Plugin>> = vec![
        Box::new(Logger),
        Box::new(Analytics),
    ];

    // Loop over plugins.
    // Each call goes through the vtable.
    for plugin in &plugins {
        plugin.run();
    }
}

The Vec<Box<dyn Plugin>> stores boxes of different types. Each box contains a fat pointer to the data and vtable. When you iterate, you call run on each plugin. The compiler generates one loop body that works for all plugins. The vtable lookup dispatches to the correct implementation.

This pattern is essential for plugin systems, UI frameworks, and game engines. You cannot write a generic function that accepts a Vec<T> where T can be Logger or Analytics. You need dyn Plugin to erase the type differences.

The hidden cost: cache locality

The vtable lookup is cheap. The real performance killer is often cache locality.

A Vec<T> stores elements contiguously in memory. The CPU prefetcher predicts the next access and loads it into the cache before you need it. This makes iteration extremely fast.

A Vec<Box<dyn T>> stores pointers to scattered allocations. Each box points to a different heap allocation. The data is not contiguous. The CPU cannot prefetch effectively. Every access may cause a cache miss. A cache miss costs hundreds of cycles. A vtable lookup costs a few cycles.

If you iterate over a large collection of dyn Trait objects in a tight loop, the cache misses will dominate the runtime. The dynamic dispatch overhead becomes invisible next to the memory latency.

fn process_static(items: &[ConcreteType]) {
    // Contiguous memory. Prefetcher works well.
    // Inlining allows vectorization.
    for item in items {
        item.compute();
    }
}

fn process_dynamic(items: &[Box<dyn Trait>]) {
    // Scattered memory. Prefetcher fails.
    // No inlining. Vtable lookup per item.
    for item in items {
        item.compute();
    }
}

The process_static function can run orders of magnitude faster than process_dynamic on large datasets. The difference comes from memory access patterns, not the dispatch mechanism.

Pointer chasing kills performance faster than virtual calls. Keep your data contiguous when speed matters.

Pitfalls and compiler errors

Dynamic dispatch introduces constraints that static dispatch does not. The compiler enforces these rules to keep memory safe.

The Sized bound

Traits have an implicit bound called Sized. It means the type has a known size at compile time. dyn Trait is !Sized. It does not have a known size because the concrete type can vary.

You cannot put a dyn Trait directly in a struct or return it from a function without a pointer. The compiler needs to know how much memory to allocate.

struct Container {
    // Error: the trait `MyTrait` has no size known at compile time.
    // E0277: the trait bound `MyTrait: Sized` is not satisfied.
    item: MyTrait,
}

struct FixedContainer {
    // Correct: Box provides a pointer to the dyn trait.
    // The Box itself has a known size.
    item: Box<dyn MyTrait>,
}

The error E0277 appears when you try to use a dyn Trait where a Sized type is required. The fix is to wrap it in a pointer type like Box, Rc, or &.

Convention aside

The community convention is to use Box<dyn Trait> for owned trait objects and &dyn Trait for borrowed trait objects. Avoid Rc<dyn Trait> unless you need shared ownership. Rc adds another layer of indirection and reference counting overhead. If you only need one owner, Box is simpler and faster.

Drop glue

When a dyn Trait object is dropped, Rust must call the destructor for the concrete type. The vtable contains a "drop glue" function pointer. This allows Rust to clean up the value correctly even though the concrete type is unknown.

This means dropping a dyn Trait object also involves a vtable lookup. If you drop many trait objects in a loop, the drop overhead adds up.

The compiler forces you to be explicit about size. Respect the Sized bound.

Decision matrix

Choosing between dynamic and static dispatch depends on your data shape and performance requirements. Use the right tool for the job.

Use dyn Trait when you need to store heterogeneous types in a collection. Use dyn Trait when the set of types is open-ended, such as plugins loaded at runtime. Use dyn Trait when you want to reduce binary size by avoiding monomorphization for many types.

Use impl Trait when you want static dispatch but the caller chooses the type. Use impl Trait in function arguments to accept any type implementing a trait without exposing the generic parameter. Use impl Trait in return types when you control the concrete type and want to hide it from the caller.

Use generics when you want static dispatch and the type is known at the call site. Use generics when you need maximum performance and can tolerate larger binary sizes. Use generics when you want the compiler to inline all trait methods.

Use an enum when the set of types is closed and small. Use an enum when you want pattern matching and exhaustive checks. Use an enum when you need to store different data for each variant without heap allocation.

Pick the tool that matches your data shape. Heterogeneous data demands dynamic dispatch. Homogeneous data demands static dispatch.

Where to go next