In Async Rust, the 'static Bound Steers You Toward High-Level Patterns Anyway
Source: lobsters
The article at hamy.xyz makes the case for three patterns that reduce Rust’s borrow checker friction: clone data instead of threading lifetime annotations, use Arc<Mutex<T>> for shared mutable state, and use anyhow instead of exhaustive typed error enums. The framing is pragmatic; you are choosing ergonomics over performance, accepting allocation overhead in exchange for code that is easier to write and maintain.
That framing is accurate for synchronous Rust. Async Rust adds a constraint that shifts the picture considerably. The 'static lifetime bound on spawned tasks means the compiler was already going to push you toward Arc and .clone() regardless of your tolerance for borrow checker friction. The choice the article describes is less deliberate in async code than it appears from the outside.
Why ‘static Forces the Issue
Tokio’s spawn carries this signature:
pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
where
F: Future + Send + 'static,
F::Output: Send + 'static,
The 'static bound means the future cannot hold borrowed references with a lifetime shorter than 'static. This is not an arbitrary restriction; spawned tasks may outlive the scope that creates them, and the executor drives them to completion independently of the stack frame that spawned them. A borrow from a local variable would be invalid once that frame returns.
The practical consequence is that you cannot capture local references into spawned tasks:
async fn process(config: &Config) -> Result<()> {
tokio::spawn(async move {
do_work(config).await;
// error[E0521]: borrowed data escapes out of associated function
});
Ok(())
}
The only structural options are to move owned data into the task, which requires a clone, or to wrap the data in Arc<T> so the task holds a reference-counted pointer with no borrow lifetime to expire. For shared mutable state across multiple tasks, the only correct structure is Arc<Mutex<T>>. There is no version of shared mutable state across tokio::spawn boundaries that avoids Arc. The borrow checker enforces this:
let shared: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
for _ in 0..4 {
let state = Arc::clone(&shared);
tokio::spawn(async move {
let mut guard = state.lock().unwrap();
guard.push("event".to_string());
});
}
This is the idiomatic pattern for shared mutable state in async Rust. The high-level Rust article describes Arc<Mutex<T>> as a friction-reduction strategy, which is accurate for synchronous code. For async code, the language structure leads you here whether you are optimizing for ergonomics or not.
The Discord Bot Workload
Most Discord bot handlers share a common structure: an event loop dispatches messages to async handler functions, each of which needs access to shared state like database connection pools, configuration, and in-memory caches. The state has to be available across concurrent handlers, and it has to satisfy 'static for the async runtime.
A typical state setup looks like:
#[derive(Clone)]
struct AppState {
config: Arc<Config>,
db: Arc<Pool<Postgres>>,
cache: Arc<Mutex<HashMap<u64, CachedResponse>>>,
}
async fn handle_message(state: AppState, msg: Message) -> anyhow::Result<()> {
let cached = {
let cache = state.cache.lock().unwrap();
cache.get(&msg.channel_id.get()).cloned()
};
if let Some(response) = cached {
send_reply(&msg, response).await?;
return Ok(());
}
let result = state.db
.fetch_one(sqlx::query!(
"SELECT content FROM responses WHERE id = $1",
msg.id.get() as i64
))
.await
.context("database query failed")?;
send_reply(&msg, result.content).await?;
Ok(())
}
AppState derives Clone because the event loop passes a cloned copy to each handler. The internal fields use Arc because those cloned copies need to reference the same underlying data without copying it. The Mutex<HashMap<...>> for the cache is the only correct structure for shared mutable state that satisfies the async runtime’s requirements.
The anyhow::Result<()> return type fits naturally here. A handler function that fails due to a database error, a network timeout, or a serialization issue should log the error and continue; the caller does not need to distinguish between failure modes. anyhow with .context() provides a context chain for logging and adds nothing unnecessary for code that only needs to know whether the function succeeded.
None of this is a style preference. It is what the 'static bound and Tokio’s API surface produce.
Build Time Is the Ongoing Friction
The source article frames the borrow checker as the primary pain. For developers in the first months of Rust, that is accurate; lifetime annotations and ownership rules are the first obstacles. The patterns the article describes address that friction directly.
After the borrow checker becomes familiar, the sustained friction shifts to compilation time. A medium Rust project can take 30 to 90 seconds for a clean build. cargo check, which skips code generation and produces only diagnostics, brings this to 5 to 20 seconds for most crates. The Cranelift backend reduces debug builds by 30 to 50 percent. The mold linker on Linux cuts linking time by a factor of 2 to 5. With these tools assembled, the development inner loop is workable, but assembling them is work that Go does not require.
Go compiles a medium project in under a second. That gap is structural: monomorphization, the LLVM backend, and complex trait resolution cost time that faster linkers and alternative backends can reduce but not eliminate. The high-level patterns in the source article have no effect on this. Clone frequency and Arc usage are invisible to the compiler’s most expensive phases; build times are dominated by type-checking and code generation, not by the complexity of the resulting runtime behavior.
The “20% of the pain” framing in the source article’s title undersells build time as a sustained cost. The borrow checker becomes background noise with experience; compilation times do not diminish in the same way.
Where the Patterns Have a Ceiling
The three patterns work for application code workloads: web services, CLI tools, event-driven bots, API clients. A Discord handler’s performance is bounded by network latency to Discord’s API (100 to 300ms per round trip) and database query time, not by the 50ns cost of a string clone. The allocation overhead from Arc::clone (10 to 15ns on x86 with sequentially consistent ordering) and liberal use of .clone() produce no visible slowdown against that backdrop.
Numerical processing, DSP, image manipulation, and anything that needs to saturate memory bandwidth are different. In a tight loop over a large slice, heap allocations on every iteration accumulate into measurable slowdowns. Zero-copy slices, explicit lifetime annotations, and careful data layout are the correct tools there. The profiler identifies these paths without ambiguity.
This is not a per-project decision so much as a per-function one. Most functions in most applications are not performance-sensitive; the ergonomic patterns apply cleanly. The small fraction of functions that are performance-critical get zero-cost treatment. Rust accommodates both in the same codebase without any special configuration, which is something neither Go nor Python can claim.
What the Patterns Leave Intact
After adopting all three patterns, the compile-time safety properties that separate Rust from Go persist unchanged. Option<T> enforces null handling at the type level, with no nil pointer dereferences reachable. Mutex<T> structurally owns its data, so the inner value is inaccessible without acquiring the lock. Go’s sync.Mutex is advisory; nothing in Go’s type system prevents accessing the guarded data without holding the lock. Rust’s Send and Sync marker traits prevent data races at compile time, with no runtime race detector required. Exhaustive match on enums turns unhandled variants into compile errors rather than silent fallthrough.
These properties are independent of cloning strategy; they survive every .clone() call and every Arc::clone(). The runtime cost model shifts toward Go’s when you adopt high-level patterns, but the type model does not shift.
For async application development, the argument for high-level Rust is simpler than the source article frames it. The 'static bound on async tasks leads you to Arc and .clone() on its own. The ergonomic patterns are not a concession to the borrow checker but the structurally correct response to a concurrent, long-lived execution model. Most developers writing async Rust for application workloads are going to land here regardless of their starting intent.