September 2025
I’ve been learning Rust by doing, sponsored directly by clients who needed Rust code in a React Native app.
As many others have pointed out before me, Rust has a steep learning curve once you go beyond the basics. You might be relying on an LLM to write some code for you but the quality of the code LLMs spit out is dubious.
I’ve picked up on some tricks and best practices over the last year. I decided that compiling some of the points might be valuable info.
Generally speaking RwLock is what you want instead of Mutex. It allows for multiple readers without fully locking your process. That being said, if you will read and write within the same function it’s very important to free any reader lock!
let my_read_var = MY_VAR.read().unwrap()
// If you don't drop
drop(my_read_var)
// This writer will lock
let mut my_write_var = MY_VAR.write().unwrap();
For global variables, LLMs regurgitate code that uses lazy_static or once_cell crates. Their functionality has been incorporated into the standard lib (std) and one can now just use OnceLock and LazyLock to initialize global variables.
// ❌ Don't
lazy_static! {
// your global variables
}
// ✅ Do
static MY_STR: LazyLock<RwLock<String>> = LazyLock::new(|| RwLock::new("Hello World!".into()));
You might need to call code only once on crate initialization or some other event. tokio::sync::OnceCell can be abused to achieve this:
use tokio::sync::OnceCell
static INIT: OnceCell<()> = OnceCell::const_new();
pub fn init() {
// Makes sure the code inside is only run once
INIT.get_or_init(|| async {
my_async_function().await;
})
.await;
}
cargo test runs tests in parallel but within a single instance. This is a pain in the ass for encapsulating state between the tests. IMO it’s better to use cargo-nextest which starts a separate process per test. Here is the config I use to give better results:
[profile.default]
retries = 3
fail-fast = false
status-level = "all"
cfgs to avoid async traits, send+sync usage. Worse case you might have to recurse to macros that completely kill IDE analysis.If you are exposing a C-API and returning std::ffi::Cstring to the calling C context, strings must be returned to Rust to be safely de-allocated.
#[no_mangle]
unsafe extern "C" fn get_a_string() -> *mut c_char {
let data = CString::new("Hello World!".into()).unwrap();
data.into_raw() as *mut c_char
}
// The pointer must be later returned to Rust for safe de-allocation
#[no_mangle]
unsafe extern "C" fn free_string(ptr: *mut c_char) {
if ptr.is_null() {
return;
}
let _ = CString::from_raw(ptr);
// Automatically dropped at the end of function
}
Result and Option. They allow for very idiomatic and terse Rust code. Enforce their usage.nativetls vs rustls crates. If you are targeting multi-platform go with rustls if possible.Ring is being deprecated/on-hold/abandoned, a lot of libraries are migrating to aws-lc-rs, so should you.
aws-lc-rs. You might need to toggle features or bump versions to take advantage of this.cargo-appreaiser extension on vscode. Not only it shows outdated packages but it also shows the crate features on hover.#[cfg(test)] or #[cfg(debug)] but this can have issues down the road with hidden errors that are not detected while developing. I’ve found using a if cfg!(test) is sometimes better as all the branches of your code are compiled and avoid a lot of dead compilation zones. Zones that be hiding deeper compilation issues or might throw errors/warnings when compiled in release mode.try operator (?) seems to be the recommended way of doing things, but one looses the exact line where the error was thrown? I’m not sure if I’m doing things wrong. In any case, it’s better to always use it in combination with your own custom Error enum.