Telex logo Telex

Rust Patterns That Matter #13: 'static + Clone - The Escape Hatch

Post 13 of 22 in Rust Patterns That Matter. Companion series: Building a Chat Server in Rust.

Previous: #12: Custom Iterators | Next: #14: Enum Dispatch

You're spawning a thread or an async task. The API requires 'static. Your data borrows from something. The compiler won't let it through. You try adding lifetime annotations, restructuring, wrapping things in Arc. The code gets more complex with each attempt. There's a simpler answer: clone the data.

The motivation

fn process(data: &str) {
    std::thread::spawn(|| {
        println!("{}", data);
    });
}
error[E0373]: closure may outlive the current function,
              but it borrows `data`, which is owned by the current function

thread::spawn requires the closure to be 'static - it can't borrow from the enclosing function because the thread might outlive it. The same applies to tokio::spawn, rayon::spawn, and any API that takes F: FnOnce() + Send + 'static.

Why everything wants 'static

A spawned task or thread has no guaranteed lifetime. It might run for a microsecond or for the rest of the program. The runtime can't prove that borrowed data will still be valid when the task reads it. 'static means "this value is self-contained - it owns everything it needs." No dangling references possible.

This applies to any owned type: String, Vec<u8>, i32, PathBuf. All owned types are 'static because they don't borrow from anything. The bound isn't asking for immortal data - it's asking for data that doesn't depend on someone else's lifetime.

The pattern: clone and move

fn process(data: &str) {
    let data = data.to_string(); // owned copy
    std::thread::spawn(move || {
        println!("{}", data);
    });
}

data.to_string() creates an owned String. The move closure takes ownership of it. The closure is now 'static because it owns everything it captures. No lifetime issues.

For structs with multiple fields:

fn spawn_worker(config: &Config) {
    let config = config.clone();
    tokio::spawn(async move {
        // config is owned — no lifetime constraints
        do_work(&config).await;
    });
}

"But cloning is wasteful!"

This is the most common objection, and it's almost always premature. Consider what a clone actually costs:

Now consider the context. If you're spawning a task to make a network request (milliseconds), query a database (milliseconds), or do any kind of I/O, the clone is nanoseconds of noise. You can't measure it. The anxiety about cloning is almost always misplaced.

Profile before you optimise. If your profiler shows that cloning is a bottleneck, address it. Until then, clone freely.

Arc as a middle ground

When the data is genuinely large (megabytes, or cloned thousands of times per second), cloning might show up in profiles. In that case, share instead of copying:

use std::sync::Arc;

fn spawn_workers(data: Arc<LargeDataset>) {
    for _ in 0..10 {
        let data = Arc::clone(&data);
        tokio::spawn(async move {
            process(&data).await;
        });
    }
}

Arc::clone is a reference count increment - a single atomic operation. All ten tasks share the same LargeDataset allocation. Arc<T> is 'static + Send (when T: Send + Sync), so it satisfies spawn bounds.

The decision tree

  1. Does the task need 'static? If the data is already owned, you're done - just move it in.
  2. Is the data small or the clone infrequent? Clone it. This is the right answer 90% of the time.
  3. Is the data large and shared by many tasks? Use Arc.
  4. Does the data need to be mutated by multiple tasks? Use Arc<Mutex<T>> (#19).

When to use it

The instinct to avoid cloning comes from languages where copies are expensive by default. In Rust, cloning is explicit, visible, and usually cheap. When it's not, the profiler will tell you.

See it in practice: Building a Chat Server #3: Parsing and Performance uses this pattern for moving parsed data across scope boundaries.