· 6 min read ·

Clone Freely, Arc Everywhere: The Case for High-Level Rust

Source: lobsters

Writing Rust in the style the language tutorials push toward, with precise lifetime annotations, zero-copy string slices, and borrowed references threaded through every data structure, is a genuinely distinct skill from most programming. The borrow checker isn’t being pedantic for sport; it’s enforcing a discipline that eliminates entire categories of bugs. But that discipline carries a learning cost that discourages people who would benefit from Rust’s other properties.

A recent post on hamy.xyz argues for a middle path: write Rust that looks more like Go or TypeScript, lean on Arc, .clone(), and owned types, and accept a modest performance trade-off to dramatically lower the cognitive overhead. The argument holds up, and the performance trade-off is smaller than most people assume.

What High-Level Rust Means in Practice

The core swap is: instead of proving to the compiler that a reference is valid for some lifetime, you give the data shared ownership or just copy it. The three main tools are Arc<T> for shared ownership across threads, Rc<T> for single-threaded shared ownership, and .clone() for when you want an independent copy.

Consider a struct holding configuration data used across multiple tasks:

// Lifetime annotation required on every consumer
struct Config<'a> {
    host: &'a str,
    port: u16,
}

// High-level: own the data
#[derive(Clone)]
struct Config {
    host: String,
    port: u16,
}

With the owned version, you can pass Config around, clone it for each worker, store it in a Vec, and never think about whether the original is still alive. The compiler is satisfied. You move on.

For shared mutable state across async tasks, the pattern becomes Arc<Mutex<T>>:

use std::sync::{Arc, Mutex};
use tokio::task;

#[derive(Clone)]
struct AppState {
    counter: Arc<Mutex<u64>>,
    config: Arc<Config>,
}

let state = AppState {
    counter: Arc::new(Mutex::new(0)),
    config: Arc::new(config),
};

let s = state.clone();
task::spawn(async move {
    let mut n = s.counter.lock().await;
    *n += 1;
});

This should feel familiar to Go programmers. It’s sync.Mutex with the sharing made explicit in the type rather than implied by a pointer. The difference is that Rust’s type system enforces the locking discipline at compile time: you cannot access the inner value without holding the lock, and the compiler will reject any attempt to do so.

The Performance Math

The concern with this style is performance. Cloning a String allocates heap memory. Arc<T> adds reference-count increments and decrements. Mutex adds lock overhead. The question is whether these overheads are significant for a given workload.

For most application code, they are not. A String::clone() is a heap allocation proportional to the string’s length, but in a web server handler that’s already doing JSON deserialization, TLS, and database I/O, one extra string clone doesn’t appear on a flame graph. The bottleneck is network latency, the ORM, or the database itself.

The cases where it matters are tight inner loops processing large volumes of data: a parser reading gigabytes of logs, a game engine updating thousands of entities per frame, a database kernel scanning millions of rows. These workloads need zero-copy approaches. But if you’re writing a Discord bot, a REST API, a CLI tool, or a background service, you’re not in this category.

Reference counting via Arc has a measurable cost: the increment and decrement are atomic operations, and on ARM architectures they require memory barriers. Benchmarks put the cost of a single Arc::clone and corresponding drop at roughly 10-30 nanoseconds on modern ARM hardware. That’s negligible unless you’re creating and dropping millions of Arcs per second in a hot path, which application code typically doesn’t do.

The comparison point also matters. The alternative to Arc<T> in Go is a pointer with the garbage collector handling lifetimes at runtime. Depending on allocation rate, Go’s GC can consume 5-15% of CPU on a loaded server. Arc’s reference counting is more predictable, and the total overhead is often similar or lower for workloads without high short-lived allocation rates. You trade unpredictable GC pauses for deterministic but slightly more expensive clone operations.

Owned Strings Versus Slices

One of the most common lifetime struggles for newcomers involves String and &str. The rule for high-level Rust is simple: use String in structs, use &str in function parameters when you don’t need to own the data.

// Struct: own the string
struct User {
    name: String,
    email: String,
}

// Function parameter: &str accepts both &String and &str via deref coercion
fn is_valid_email(email: &str) -> bool {
    email.contains('@')
}

This gives you a clean calling convention without lifetime annotations on data structures. You’ll call .clone() at some boundaries, and that’s fine.

For the cases where you want a function that can return either a borrowed or owned string to avoid allocation when possible, Cow<str> from the standard library handles the split. It’s an enum with Borrowed(&str) and Owned(String) variants. It’s a useful targeted upgrade when you’ve identified a hot path that can avoid allocation most of the time, rather than a default approach.

Error Handling Without the Ceremony

Defining custom error enums, implementing From conversions, and wiring up ? across multiple error types is correct and expressive, but it’s a lot of scaffolding for a program that doesn’t need structured error variants at the call site. The anyhow crate provides a practical alternative:

use anyhow::{Result, Context};

fn load_config(path: &str) -> Result<Config> {
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("failed to read config from {path}"))?;
    let config: Config = serde_json::from_str(&content)
        .context("failed to parse config JSON")?;
    Ok(config)
}

anyhow::Result<T> is shorthand for Result<T, anyhow::Error>, and anyhow::Error wraps any type that implements std::error::Error. The ? operator works across every error type without a single From implementation. You lose the ability to pattern-match specific error variants at the call site, which matters when different errors need different handling paths. For programs where errors are either fatal or surfaced as messages to a user, anyhow eliminates substantial boilerplate without meaningful cost.

For library code where structured errors are necessary, thiserror reduces the ceremony by deriving Error implementations directly from an annotated enum, splitting the difference between full manual implementation and anyhow’s type erasure.

What You Keep

The important thing about high-level Rust is what it preserves. Memory safety is unconditional: the compiler still rejects use-after-free, null pointer dereferences, and data races regardless of how many .clone() calls are in the code. Arc<Mutex<T>> doesn’t let you access the inner value without holding the lock. .clone() creates real independent copies, not the kind of shallow copy that introduces aliasing bugs in C or surprising reference semantics in Python.

The type system’s expressive power remains fully available. Enums, pattern matching, traits, generics, Option<T>, Result<T, E>: none of this requires lifetime expertise. A well-modeled state machine using a Rust enum is more correct and easier to extend than a stringly-typed bag of maps, and that correctness is enforced at compile time regardless of whether the values inside are borrowed or owned.

The tooling, the module system, cargo’s dependency management, and the quality of ecosystem crates like serde, tokio, axum, and reqwest remain unchanged. You’re writing the same language; you’re being less precise about ownership in places where that precision isn’t load-bearing.

When to Go Lower

High-level Rust will eventually hit a wall on specific workloads. If profiling shows allocation or lock contention as the bottleneck, the path is targeted: introduce &str borrowing on specific hot paths, replace Mutex<Vec<T>> with a lock-free structure from crossbeam, switch to arena allocation for short-lived data. These are surgical optimizations, not full rewrites. The high-level code you already have provides a working, correct baseline to profile against.

The style also doesn’t fit no_std environments, embedded targets without a heap allocator, or kernel code where the allocator itself is under your control. Those are real constraints, but they apply to a small fraction of Rust code being written today.

For application code, service code, and tooling, the argument is correct. Write owned types, clone when you need to, reach for Arc<Mutex<T>> for shared state, use anyhow for error handling, and ship software. The borrow checker is still working for you on the parts of your program where ownership discipline matters. The rest of Rust’s value proposition arrives without requiring a deep mastery of lifetime annotations.

Was this interesting?