The Rust You Can Actually Ship: Making Peace with Owned Types and Clones
Source: lobsters
There is a version of Rust that most tutorials do not show you. It does not have 'a and 'b scattered through every function signature. It does not have structs holding references back into other structs. It allocates freely, clones without guilt, and wraps shared state in Arc<Mutex<T>> without asking whether you could have gotten away with something smarter. It is not the most efficient Rust. It is, for a wide class of programs, exactly the right Rust.
A recent article by hamy.xyz frames this as getting 80% of the benefits with 20% of the pain, and that framing is largely correct. What the framing undersells is which 80% you are actually getting, and why that matters more than the missing 20%.
What “High-Level Rust” Means in Practice
The core idea is to lean on owned types everywhere. Instead of &str, you store and pass String. Instead of &[T], you work with Vec<T>. Instead of borrowing across function boundaries with carefully annotated lifetimes, you clone at the boundary and move on.
In a struct definition this looks like:
// The borrow-heavy version
struct Config<'a> {
name: &'a str,
tags: &'a [String],
}
// The owned version
struct Config {
name: String,
tags: Vec<String>,
}
The owned version has no lifetime parameter. It can be stored anywhere, passed across thread boundaries, returned from async functions, and placed inside other structs without infecting them with lifetime annotations. The Config type becomes genuinely self-contained.
For shared mutable state, the pattern is Arc<Mutex<T>> or Arc<RwLock<T>>. You clone the Arc to share ownership across threads or async tasks, lock when you need to read or write, and let the reference count handle cleanup:
use std::sync::{Arc, RwLock};
use std::collections::HashMap;
#[derive(Clone)]
struct AppState {
cache: Arc<RwLock<HashMap<String, String>>>,
config: Arc<Config>,
}
The #[derive(Clone)] on the state struct is cheap because cloning an Arc only increments an atomic counter. The actual data is shared, not copied. This is the pattern that async Rust frameworks push you toward almost immediately, and for good reason: it composes cleanly with tokio::spawn and similar APIs that require 'static bounds on their futures.
Error handling in the high-level style leans on Box<dyn Error> or the anyhow crate rather than carefully designed error type hierarchies:
use anyhow::Result;
async fn fetch_data(url: &str) -> Result<String> {
let body = reqwest::get(url).await?.text().await?;
Ok(body)
}
The ? operator works throughout, errors get context via .context(), and you never have to define a custom error enum until the API boundary actually requires it.
The Safety You Keep
This is the part worth dwelling on. When you write Rust in this style, you give up some performance headroom. You do not give up the things that make Rust worth using in the first place.
The borrow checker still prevents use-after-free bugs. Memory is still freed exactly once, at exactly the right time. Data races remain a compile error. Null pointer dereferences stay impossible because Option<T> forces you to handle the absent case. Integer overflow panics in debug mode and wraps predictably in release mode rather than silently corrupting values.
None of those guarantees are contingent on zero-copy code. They hold regardless of whether you cloned a String or took a reference to one. The ownership model does not relax when you own more aggressively; it simply becomes less of an obstacle.
For networked services, this matters more than it might seem. A typical web service or bot spends its time waiting on I/O. The bugs that take services down are not missed allocation optimizations. They are race conditions in shared state, use-after-free in connection handlers, null dereferences in unexpected code paths. Rust eliminates all of those at compile time, and it keeps eliminating them even when your code is written in the most allocation-heavy style possible.
The Performance Reality
The honest version of the performance trade-off is this: you are paying for allocations that a more carefully written version of the code would not need.
In a CPU-bound tight loop, this matters. If you are processing millions of records in a batch job and your inner loop calls .to_string() on a value it could borrow, you will see the difference in a profiler. In an async application that is waiting on network calls, database queries, or Discord’s gateway, those allocations are invisible against the latency floor.
Modern allocators compound this further. jemalloc and mimalloc are fast enough that even moderately allocation-heavy Rust code benchmarks well against Go and comfortably ahead of Python or Ruby. You lose some of the gap versus carefully hand-optimized C++, but you were probably not competing with that anyway.
The Rust compiler also inlines aggressively and elides many copies that look expensive in source form. An owned String parameter that is never mutated will often have its move or clone optimized away entirely if the compiler can prove it is safe to do so.
Why Not Just Use Go
This is the obvious question. If you are not going to fight the borrow checker, if you are going to heap-allocate liberally and rely on reference counting for shared state, Go gives you similar ergonomics with a fraction of the learning curve.
The answer is that Go’s runtime safety is narrower. Go will catch nil pointer dereferences at runtime, which is better than a segfault but still a crash in production. Go has no compile-time race detector; the dynamic race detector is a testing tool, not a guarantee. Go channels and goroutines are ergonomic, but the language gives you plenty of ways to build data races with shared maps and structs that are not otherwise synchronized.
Rust’s guarantees are categorical where Go’s are probabilistic. “This code cannot have a data race” is a different statement from “this code passed the race detector in tests.” For long-running services with complex concurrent state, that difference accumulates.
Go is a good language. It is particularly good when you have a team that needs to onboard quickly and the domain does not push the edges of what a GC runtime can do. But it is not the same trade-off as high-level Rust. You are trading compile-time guarantees for ergonomics, not just trading zero-copy for ergonomics.
This Is How Async Rust Works in Practice
Something the “high-level Rust” framing clarifies is that this style is not a compromise for beginners. It is how production async Rust actually looks.
When I wrote my first Discord bot using poise on top of serenity, the framework’s own data model pushed me into Arc<RwLock<T>> for bot state immediately. Serenity’s Context type carries shared state that is cloned across every event handler invocation. You do not fight that design; you work with it.
A typical poise bot setup looks something like this:
#[derive(Clone)]
struct BotData {
db: Arc<sqlx::PgPool>,
cache: Arc<RwLock<HashMap<u64, CachedUser>>>,
config: Arc<BotConfig>,
}
type Context<'a> = poise::Context<'a, BotData, anyhow::Error>;
#[poise::command(slash_command)]
async fn ping(ctx: Context<'_>) -> anyhow::Result<()> {
ctx.say("Pong!").await?;
Ok(())
}
The BotData struct is cloned on every command invocation. The clone is cheap because all the heavy fields are behind Arc. The anyhow::Error as the error type means any library error propagates cleanly with ?. This is idiomatic, production-grade async Rust. It is also exactly the “high-level” style.
The borrow checker is still working on your behalf throughout. It prevents you from holding a RwLock guard across an .await point, which would deadlock. It prevents you from sending non-Send types into async tasks. It prevents you from storing a reference into the cache while also holding a mutable reference elsewhere. These are real bugs that would appear at runtime in equivalent Go or Node.js code.
Where to Go Deeper
The high-level style has limits, and they are worth knowing even if you do not hit them immediately.
CPU-bound processing is the clearest case. If you write a parser, a codec, an image processing pipeline, or a cryptographic primitive, the allocations start to matter. A parser that returns owned String values for every token will be measurably slower than one returning string slices into the original input buffer. The Rust ecosystem has excellent tooling for this: the nom parser combinator library is built around zero-copy parsing, and the bytes crate provides Bytes and BytesMut types that make shared buffer ownership ergonomic without full clones.
Embedded and no_std environments exclude heap allocation by definition. If you are writing firmware or kernel code in Rust, you are already in different territory and the high-level style is not available to you.
For everything else, the path forward is profiling before optimizing. Rust’s tooling here is good. cargo flamegraph and cargo-criterion make it straightforward to find and measure hot spots. When you find a clone that is actually a bottleneck, you can refactor that specific function to take a reference or return a slice. The rest of the codebase stays readable.
The Actual Takeaway
The article’s 80/20 framing is a useful entry point, but the more durable lesson is about scope. Rust’s ownership system is a spectrum. At one end, zero-copy everything, lifetime-annotated slices threading through every abstraction layer, minimal allocations, maximum control. At the other end, owned types everywhere, clones at boundaries, Arc for sharing, anyhow for errors. Both ends compile. Both ends have the same safety guarantees. Only the performance characteristics differ.
For most applications, and almost certainly for any network service or bot you are building today, the high-level end of that spectrum gives you everything you came to Rust for. You get memory safety, fearless concurrency, and a type system that catches entire categories of bugs before they reach production. You write it in a style that reads like modern Go or a strongly-typed scripting language. You can profile and tighten specific hot spots later, with the full context of actual production data telling you where it matters.
The alternative, spending the first three months fighting lifetime annotations on code that then sits idle while waiting for a database query, is a poor trade. Start owned, clone freely, ship the thing, and revisit when the profiler gives you a reason.