Most writing about high-level Rust stays at the pattern level. Use owned types. Clone at async boundaries. Wrap shared state in Arc<RwLock<T>>. Use anyhow for errors. The patterns are correct, and hamy’s article on the high-level Rust approach lays out the reasoning clearly. What gets less attention is what these patterns look like when they are all combined in actual bot code, with an async runtime running through everything.
I have been writing Discord bots in Rust for a while now, and the picture is more ergonomic than Rust’s reputation suggests, with one specific exception. The friction does not spread evenly across the language. It concentrates almost entirely in one place: holding a lock guard across an await point. Understanding that constraint turns most of the complexity into a single rule to learn rather than a pervasive complication.
Setting Up Shared State
Every real Discord bot maintains state across command invocations: user cooldowns, cached API responses, per-channel configuration, counters. The ownership question comes up immediately. In Python you would use a module-level dict. In Rust, the application-layer answer is Arc<RwLock<T>>:
use std::sync::Arc;
use tokio::sync::RwLock;
use std::collections::HashMap;
use std::time::Instant;
#[derive(Clone)]
pub struct BotState {
pub user_cooldowns: Arc<RwLock<HashMap<u64, Instant>>>,
pub channel_config: Arc<RwLock<HashMap<u64, ChannelConfig>>>,
pub http_client: reqwest::Client,
}
#[derive(Clone)] on the struct generates clone implementations for each field. For the Arc fields, that is an atomic reference count increment, not a copy of the underlying map. For reqwest::Client, the client is Arc-backed internally, so cloning it is also O(1). The struct can be passed to any async handler, moved into a tokio::spawn, or stored in a HashMap without touching a lifetime annotation.
In poise, the Discord bot framework that sits on top of serenity, state flows through every command via a typed context:
type Context<'a> = poise::Context<'a, BotState, anyhow::Error>;
The error type is anyhow::Error. Every command returns anyhow::Result<()>, which means ? propagates any failure up the call stack and poise renders it gracefully. No custom error enum, no manual Display implementations.
A Command That Uses the State Correctly
Here is a cooldown check command written with the scoped-lock pattern that async Rust requires:
#[poise::command(slash_command, prefix_command)]
async fn roll(ctx: Context<'a>) -> anyhow::Result<()> {
let user_id = ctx.author().id.get();
// Extract what we need, then drop the lock before any await
let remaining_secs = {
let cooldowns = ctx.data().user_cooldowns.read().await;
cooldowns.get(&user_id).and_then(|last_used| {
let elapsed = last_used.elapsed();
(elapsed < Duration::from_secs(5)).then(|| 5 - elapsed.as_secs())
})
}; // read guard drops here
if let Some(secs) = remaining_secs {
ctx.say(format!("Wait {} more seconds.", secs)).await?;
return Ok(());
}
// Write lock acquired after all reads are done
ctx.data().user_cooldowns.write().await
.insert(user_id, Instant::now());
let result = rand::thread_rng().gen_range(1..=20);
ctx.say(format!("You rolled a {}!", result)).await?;
Ok(())
}
The lock scope uses a block to ensure the read guard is dropped before the first await. The data we need (remaining_secs) is extracted as an owned value and the lock releases at the closing brace. The subsequent await calls happen without any lock held.
The Send Bound Problem
The reason the scoped block matters is the Send bound. Tokio requires that futures submitted to its runtime implement Send, which means any type held across an await must be safely moveable across threads. Lock guards hold a raw pointer to their underlying data and are explicitly not Send.
Holding a guard across an await produces a compiler error:
// This does not compile:
async fn bad_handler(ctx: Context<'a>) -> anyhow::Result<()> {
let guard = ctx.data().user_cooldowns.read().await;
ctx.say("processing...").await?; // await while holding guard
let count = guard.len(); // guard used after await
drop(guard);
Ok(())
}
error: future cannot be sent between threads safely
--> src/commands.rs:15:1
|
= note: the trait `Send` is not implemented for
`tokio::sync::RwLockReadGuard<'_, HashMap<...>>`
The message names the specific type and the rule it violates. The fix is always the same: extract the data you need into an owned value before awaiting. The compiler catches every violation. You will never ship a version that races on shared state because you forgot this pattern somewhere.
This is the one friction point in high-level async Rust that does not go away with owned types and strategic cloning. Once you internalize the rule — extract, drop, then await — you stop fighting it. But it takes real experience with async code to recognize the pattern on first encounter.
What Rust 1.75 Changed for Bot Code
Before Rust 1.75 stabilized async fn in traits in December 2023, writing a trait with async methods required the async-trait proc macro, which boxes every returned future:
// Before Rust 1.75 -- boxes every future
#[async_trait::async_trait]
trait EventHandler: Send + Sync {
async fn on_message(&self, ctx: Context, msg: Message) -> anyhow::Result<()>;
}
Each call to an async trait method allocated a Box<dyn Future>. For a bot processing hundreds of messages per second, those allocations accumulate. After Rust 1.75:
// Native async fn in traits -- no boxing
trait EventHandler: Send + Sync {
async fn on_message(&self, ctx: Context, msg: Message) -> anyhow::Result<()>;
}
Poise 0.6 and serenity 0.12 both target this stabilized API. In practice, the difference for most bots is a single removed dependency and cleaner trait definitions. For high-throughput bots where allocation-per-event matters, the difference in flame graphs is visible.
The Crate Stack That Makes It Work
The high-level patterns pay off specifically because the major Discord bot crates are designed around them. Serenity’s client uses Arc-based internal state as the expected model. Poise builds typed command context on top of it. Serde deserializes API responses to owned structs with #[derive(Deserialize)]. Reqwest’s client is internally Arc-wrapped, so passing it through BotState::clone costs nothing.
A working Cargo.toml for this stack:
[dependencies]
poise = "0.6"
serenity = { version = "0.12", default-features = false, features = ["client", "gateway", "rustls_backend", "model"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
reqwest = { version = "0.12", features = ["json"] }
rand = "0.8"
Clean builds from this dependency set take 60-90 seconds on a mid-range machine. Incremental rebuilds for command changes take 3-8 seconds. The compile time is the most legitimate complaint about the stack, and it does not improve with high-level patterns.
What the Patterns Actually Provide
Three concrete properties hold regardless of how many times you call .clone():
The bot will not crash from unhandled null values. Discord’s API returns optional fields throughout, and serenity models them as Option<T>. The compiler requires handling the None case before accessing the inner value. Pattern matching over an Option<Member> on every event that might receive one is not optional in the way that a nil check in Python is optional.
The bot will not silently race on shared state. The Arc<RwLock<T>> pattern ensures the only way to access the inner map is through the lock. There is no way to get a reference to the inner data without acquiring a read or write guard. This enforcement is structural, not advisory.
Adding a new Event variant to an enum will break every match that does not handle it. This matters when the bot’s internal event model grows. A missing match arm is a compile error, not a case that silently falls through.
The original argument is correct that you can access most of Rust’s guarantees without mastering lifetime annotations. For Discord bot code specifically, the evidence is in the code: a reasonably complex command handler with shared mutable state, cooldown tracking, external API calls, and structured error handling fits comfortably in the high-level style. The Send bound problem concentrates the remaining friction in one learnable pattern rather than spreading it across the language. That tradeoff is worth it.