Closure Performance

How Rust closures compile to zero-cost abstractions, when they introduce overhead, and how to reason about their runtime efficiency

The phrase "zero-cost abstraction" gets used a lot in Rust circles, and closures are one of the places where it actually holds. A closure that calls |x| x + 1 inside an iterator chain compiles to the same machine code you would get from writing the addition inline. There is no hidden function call, no heap allocation, no runtime bookkeeping just because the code happens to live inside a closure.

That guarantee has limits, though. Understanding where the compiler can erase the closure entirely and where it has to leave a real function pointer behind is what separates a mental model that serves you from one that surprises you. This section covers the mechanics that make closures fast, the design decisions that can slow them down, and how to tell the difference.

How the Compiler Sees a Closure

When you write a closure, the compiler does not produce a general-purpose "function object." It creates a new, anonymous struct type whose only job is to hold whatever the closure captures and provide a method to call it.

let factor = 3;
let multiply = |x: i32| x * factor;
// The compiler conceptually generates something similar to:
// struct AnonymousClosure { factor: i32 }
// impl AnonymousClosure {
//     fn call(&self, x: i32) -> i32 { x * self.factor }
// }

The struct has exactly one field per captured variable — no extra padding, no vtable pointer. The call method contains the closure body. If you never take a reference to the closure that erases its type (more on that in a moment), the compiler always knows the concrete struct. That means it can inline the call method at every call site, exactly as it would with any other method call on a known type.

Once inlined, the captured field accesses become local variable accesses. The entire closure construct collapses into straight-line code. There is no runtime remnant of the fact that a closure was ever involved.

Static Dispatch and Monomorphization

Every closure has a unique, compiler-generated type. When you write a generic function that accepts a closure parameter, Rust monomorphizes it: it stamps out a separate copy of the function for each distinct closure type passed to it.

fn apply_twice<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 {
    f(f(x))
}
let add_one = |x| x + 1;
let double = |x| x * 2;
apply_twice(add_one, 5);
apply_twice(double, 5);

After monomorphization, the program contains two distinct versions of apply_twice. In one, the call f(f(x)) is inlined with add_one's body. In the other, it is inlined with double's body. Both versions are as fast as if you had written the arithmetic directly. The compiler also has the freedom to optimize across the closure boundary — it can constant-propagate, eliminate dead code, and vectorize just as aggressively as it would with a non-closure call.

This is the mechanism behind the "zero-cost" claim. The abstraction is paid for entirely at compile time, through type generation and monomorphization. At runtime, the closure does not exist as a separate entity.

Generic closures are the fast path:

When you pass a closure as a generic parameter F: Fn(...), the compiler sees the concrete type and inlines the body. This is the default pattern with iterator adapters like .map(), .filter(), and .fold(), and it is the reason those chains produce tight assembly.

Dynamic Dispatch and Trait Objects

Sometimes you cannot know the concrete closure type at compile time — for example, when you need to store different closures in the same collection, return a choice of closures from a function, or swap out behavior at runtime. In those cases, you use a trait object: Box<dyn Fn(i32) -> i32> or &dyn FnMut().

A trait object introduces two costs that the generic path avoids:

  1. Heap allocation. The Box puts the closure struct on the heap rather than on the stack.
  2. Indirect call through a vtable. The call site does not know which function it is calling until runtime; it must follow a pointer to the vtable and then jump to the correct implementation. This prevents inlining and forces the CPU to speculate through an indirect branch.
fn build_operation(choice: &str) -> Box<dyn Fn(i32) -> i32> {
    match choice {
        "add" => Box::new(|x| x + 1),
        "mul" => Box::new(|x| x * 2),
        _     => Box::new(|x| x),
    }
}
let op = build_operation("add");
let result = op(10); // indirect call, no inlining across this boundary

For a single call, the vtable overhead is small — often a few nanoseconds. The real cost is the optimization barrier. The compiler cannot fold the closure body into the caller, which means all subsequent optimizations that depend on seeing through the call are lost. When the trait object sits inside a hot loop, the cumulative effect can be substantial.

Reserve trait objects for genuine runtime polymorphism:

Trait objects are not "closures with a performance cost." They are a specific tool for type erasure and runtime dispatch. If you are writing a function that only ever receives one closure type at a time, a generic F: Fn(...) will always outperform a Box<dyn Fn(...)> in the compiled output.

Capture Modes and Their Runtime Cost

How a closure captures its environment — by shared reference, mutable reference, or ownership — determines the size of the closure struct and what operations happen at the call site. It does not, however, affect whether the closure can be inlined; that is determined solely by whether the concrete type is known.

Immutable borrow (Fn). The closure stores a single pointer-sized reference for each captured value. The struct is small, and the call method dereferences the pointer to access the data. Once inlined, those dereferences often vanish because the compiler can see that the original data lives in a nearby stack frame.

Mutable borrow (FnMut). Identical in memory representation to an immutable borrow — still a pointer. The difference is a logical one: the compiler tracks that the closure may mutate the pointed-to data and that only one mutable borrow can exist at a time. No additional runtime cost.

Ownership (move). The captured value is moved into the closure struct. If the captured value is a String or a Vec, the closure struct is larger, because it contains the entire allocation rather than just a reference. Passing such a closure by value to a function can involve copying that data — though note that even a move closure does not allocate unless the captured value itself is an allocating type. A move closure that captures a few integers stays on the stack and costs the same as any other struct pass.

Move does not mean heap allocate:

The move keyword transfers ownership of captured variables into the closure. It does not, by itself, trigger a heap allocation. If the captured variables are stack-allocated types like integers, booleans, or references, the closure lives entirely on the stack.

A common subtlety: when a move closure captures a large Vec, creating the closure copies the vector's three-word control block (pointer, length, capacity) into the closure struct. That is a shallow copy, not a clone of the entire buffer. If you then pass that closure by value to several functions, each pass performs another shallow copy. The cost scales with the number of passes, but each individual copy is still just a handful of words — not the full buffer. This is typically not a problem, but in extremely hot paths where a closure is created and moved millions of times, the cumulative word-copy cost can add up. The fix in such cases is to capture the data by reference instead.

Example: Why an Iterator Collect Hurts More Than a Closure

A recurring source of confusion in performance bug reports is blaming the closure when the real culprit is a nearby allocation. Consider this simplified version of a real-world slowdown, adapted from user discussions about unexpected closure performance regressions:

fn process_blob(blob: &[f64], stuff: impl Iterator<Item = (usize, f64)>) -> f64 {
    stuff
        .map(|(index, weight)| weight * blob[index])
        .sum()
}

That closure is exactly the kind the compiler handles perfectly: the captured blob is a reference, the body is a single arithmetic expression, and the whole thing inlines into a tight loop. There is no closure overhead.

Now suppose a refactoring introduces a .collect::<Vec<_>>() before the map — perhaps to reuse the stuff iterator for another pass:

fn process_blob_collected(blob: &[f64], stuff: impl Iterator<Item = (usize, f64)>) -> f64 {
    let stuff_vec: Vec<(usize, f64)> = stuff.collect();
    stuff_vec
        .iter()
        .map(|(index, weight)| weight * blob[usize::try_from(*index).unwrap()])
        .sum()
}

The closure is almost identical, and it still inlines. The performance drop, often by an order of magnitude, comes from the heap allocation and the extra traversal over the collected Vec. The closure itself remains a zero-cost abstraction. When profiling suggests a closure is slow, the first question to ask is what the closure is capturing and whether any allocation or iterator transformation changed alongside the closure.

Do not conflate closure cost with allocation cost:

If a benchmark shows a sudden performance regression after introducing a closure, verify that you have not also introduced a .collect(), a Box::new(), or a clone of captured data. The closure itself is rarely the root cause; the allocation it now forces or hides is.

Comparing Generic Closures to Function Pointers

You can pass a plain function pointer (fn(i32) -> i32) where a closure trait is expected, as long as the function doesn't capture anything. Function pointers also benefit from static dispatch when used with generics — the compiler knows the concrete function at compile time and can inline it. The difference is that a function pointer never has a captured state, so it is always a single pointer-sized value.

When would you reach for fn over Fn? Almost never for performance. A generic F: Fn(i32) -> i32 that happens to be instantiated with a plain function compiles identically to the equivalent fn version. The compiler sees through the generic bound and inlines the call either way. The only scenario where fn saves anything is when you want to avoid the generic code bloat of monomorphization — but that is a binary-size concern, not a runtime performance one.

Common Performance Pitfalls

Several patterns consistently lead to poorer-than-expected closure performance. They are not flaws in closures themselves; they are design choices that prevent the compiler from applying its best optimizations.

1. Unnecessary Boxing

Storing a closure in a Box<dyn Fn(...)> when the concrete type is known at compile time adds both a heap allocation and a dynamic dispatch. If a struct holds a closure, prefer a generic field:

// Allocates and dispatches dynamically.
struct Processor {
    operation: Box<dyn Fn(i32) -> i32>,
}
// Zero overhead, concrete type is part of the struct.
struct Processor<F: Fn(i32) -> i32> {
    operation: F,
}

2. Capturing Owned Data When a Reference Suffices

A move closure that takes ownership of a String or Vec forces the data to move into the closure struct. If the closure is then stored or moved repeatedly, each move copies the allocation metadata (not the buffer, but the three-word header). More importantly, the data becomes unavailable to the parent scope, which can force additional clones upstream. Capture by reference when the closure does not need ownership.

3. Returning Different Closure Types Without Trait Objects

Returning impl Fn(...) lets you return a single concrete closure type. If you need to choose between two different closures at runtime, you must either use a trait object or restructure the logic so the decision happens before the closure is created. The trait object path is not inherently wrong, but it should be a conscious trade-off.

4. Large Capture Sets

A closure that captures many variables becomes a large struct. Passing it by value to deeply nested call chains copies that struct each time. The per-copy cost is proportional to the size of the capture set. In extreme cases (dozens of captured 8-byte values), the aggregate copy cost can become measurable in a hot loop. The mitigation is the same: capture a reference to a struct that holds the data instead of capturing each field individually.

Profiling and Verifying Inlining

The only reliable way to know whether a closure is inlined is to look at the generated assembly. Tools like cargo-asm, Godbolt, or inline assembly markers let you confirm that a hot closure call site contains no call instruction to an unknown address.

A pragmatic workflow:

1

Step 1: Isolate the hot path

Wrap the closure call in a function that is easy to locate in the assembly output. For iterator chains, put the entire loop in a #[inline(never)] function so the compiler keeps it as a distinct block you can find.

2

Step 2: Generate assembly

Use cargo asm or --emit=asm with release flags to produce the assembly for that function. Search for your function name.

3

Step 3: Look for indirect calls

Indirect calls appear as call *%rax or similar. If the closure is a generic parameter, the assembly should show no indirect calls — the closure body will appear inlined directly.

4

Step 4: Identify allocation instructions

If the hot path contains a call to an allocation function like __rust_alloc, trace it back to see if a Box::new on a closure is responsible.

Once you see the closure body fused into the surrounding loop with no function calls, you can trust that the abstraction cost is zero. If you see an indirect call, review whether the closure type is being erased somewhere — a Box<dyn Fn>, a trait object collection, or a callback stored behind a type-erased interface.

Summary

Closures in Rust are not runtime objects layered on top of the function call mechanism; they are a compile-time construction that desugars into structs and trait implementations. The compiler uses monomorphization to erase them from the final binary when the concrete type is known, producing code that is indistinguishable from a hand-written inline expression.

The few cases where closures carry measurable runtime cost — heap allocation from Box<dyn Fn>, indirect dispatch through a vtable, or pass-by-value of large capture sets — are all consequences of type erasure or data movement choices, not of closures themselves. Recognizing this distinction is what lets you use closures liberally in performance-sensitive code without second-guessing the compiler.