8 Patterns for Managing Global State in Rust Without Losing Your Mind

Discover 8 battle-tested patterns for managing global state in Rust—from static Mutex to trait object registries. Learn which approach fits your use case and avoid common pitfalls.

8 Patterns for Managing Global State in Rust Without Losing Your Mind

Let me tell you about the eight ways I’ve learned to handle global state in Rust. I’ve made every mistake in the book—deadlocks, race conditions, and data races that made me want to throw my laptop out the window. These patterns are the result of many late nights and the wisdom of people who wrote the Rust libraries I depend on. I’ll write this as if I’m explaining it to someone who just finished the basic Rust tutorial and is now stuck trying to share a configuration object across their whole application.

The Static Mutex Pattern

When you need a single piece of data that many threads can both read and write, the most straightforward pattern is a static variable wrapped in a Mutex. You declare it with static and use LazyLock or once_cell to initialize it only once. The Mutex ensures only one thread at a time can access the data inside.

Here’s what I mean. Imagine you have a simple application counter that increments every time a request comes in. You want to read its value from anywhere in the program.

use std::sync::{Mutex, LazyLock};

static COUNTER: LazyLock<Mutex<u32>> = LazyLock::new(|| Mutex::new(0));

fn increment() {
    let mut counter = COUNTER.lock().unwrap();
    *counter += 1;
}

fn get_count() -> u32 {
    let counter = COUNTER.lock().unwrap();
    *counter
}

LazyLock (available since Rust 1.80) behaves like a smart pointer that initializes the Mutex the first time it is accessed. The lock() method returns a guard that holds the mutex lock. When the guard is dropped—usually at the end of the scope—the lock is released automatically. The unwrap is safe because the mutex can only be poisoned if a thread panics while holding it, which is a rare edge case.

I used this pattern in a small web service for storing a configuration that might change at runtime through an admin API. The mutex made sure no two threads could write conflicting values. But I learned the hard way that if you call lock() in many places, you can create contention and slow down your whole program. For reads that are much more frequent than writes, the RwLock pattern is better.

The Static RwLock Pattern

When you have data that is read often but written rarely, a RwLock (reader-writer lock) is more efficient than a Mutex. Many threads can hold a read lock simultaneously, but only one thread can hold the write lock at a time, and no reads are allowed during a write.

Consider a global application configuration that gets loaded at startup and then rarely changed, say only when an admin pushes a new version.

use std::sync::{RwLock, LazyLock};

static CONFIG: LazyLock<RwLock<Config>> = LazyLock::new(|| {
    RwLock::new(Config::default())
});

struct Config {
    db_url: String,
    max_connections: u32,
}

impl Config {
    fn default() -> Self {
        Self {
            db_url: String::from("localhost"),
            max_connections: 10,
        }
    }
}

fn read_config() -> String {
    let config = CONFIG.read().unwrap();
    config.db_url.clone()
}

fn update_config(new_url: String) {
    let mut config = CONFIG.write().unwrap();
    config.db_url = new_url;
}

Notice how read() returns a guard that allows reading, while write() returns a mutable guard. You must be careful not to hold the write lock for too long, because it blocks all readers. In my own projects, I always wrap write operations in a short function and avoid calling any other function that might try to acquire the same lock while inside the write lock. That’s how you create a deadlock. The standard library’s RwLock does not support upgrading a read lock to a write lock, so you must release the read lock before acquiring a write lock.

Lazy Initialization with LazyLock or OnceCell

Sometimes you don’t need mutability at all—you just need a global value that is expensive to create and that you want to compute only once. This is the domain of LazyLock and OnceCell. These types guarantee that the initialization function runs exactly once, even if multiple threads try to access the value simultaneously.

I once built a physics simulation that required loading a large texture atlas into memory. Loading the atlas took several seconds, and I only wanted to do it once. Here’s how I used OnceCell.

use std::cell::OnceCell;

static TEXTURE_ATLAS: OnceCell<TextureAtlas> = OnceCell::new();

fn get_atlas() -> &'static TextureAtlas {
    TEXTURE_ATLAS.get_or_init(|| {
        // This closure runs only once, even if called from many threads.
        load_texture_atlas("atlas.png")
    })
}

struct TextureAtlas {
    data: Vec<u8>,
    width: u32,
    height: u32,
}

fn load_texture_atlas(path: &str) -> TextureAtlas {
    // Simulate loading: e.g., read file.
    println!("Loading texture atlas from {}", path);
    TextureAtlas {
        data: vec![0u8; 1024 * 1024],
        width: 1024,
        height: 1024,
    }
}

The get_or_init method uses a Once primitive internally. If the value has not been initialized, it calls the closure while holding an internal lock. After that, only a simple atomic load is needed to fetch the reference. This pattern is perfect for things like compiled regular expressions, database connection pools, or any one-time setup that you want to access globally without overhead.

Thread-Local Storage

Not all global state needs to be global across threads. Sometimes you want a separate instance of a value for each thread. This is where thread-local storage (TLS) shines. In Rust, you use the thread_local! macro to declare a static that holds a different value for each thread.

I used this pattern in a logging system where each thread should buffer log messages and flush them periodically to avoid contention on a shared log file.

use std::cell::RefCell;

thread_local! {
    static LOG_BUFFER: RefCell<Vec<String>> = RefCell::new(Vec::new());
}

fn log_message(msg: String) {
    LOG_BUFFER.with(|buffer| {
        buffer.borrow_mut().push(msg);
        if buffer.borrow().len() >= 10 {
            flush_buffer();
        }
    });
}

fn flush_buffer() {
    LOG_BUFFER.with(|buffer| {
        let messages = buffer.replace(Vec::new());
        // Send messages to the actual logging output.
        for m in messages {
            println!("{}", m);
        }
    });
}

Each thread gets its own Vec<String> inside a RefCell. The RefCell gives interior mutability, which is safe because only one thread accesses its own copy. TLS is incredibly useful for caching, random number generators per thread, or any per-thread state that avoids synchronization overhead.

One pitfall: TLS values are destroyed when the thread exits. If you try to access the TLS after the thread has been destroyed (e.g., from a destructor), you will get a panic. The standard library also offers std::thread::LocalKey for dynamic access, but the macro is the most common way.

One-Time Initialization with std::sync::Once

The std::sync::Once type is a lower-level primitive that lets you run a closure exactly once. It is used internally by LazyLock and OnceCell, but you can also use it directly when you need more control, such as initializing multiple variables conditionally.

I once had a system where I needed to register a signal handler on startup, but I wanted to ensure it happened only once, even if multiple library users tried to initialize it.

use std::sync::Once;

static INIT: Once = Once::new();
static mut DB_POOL: Option<Vec<u32>> = None; // ugly, but necessary for Once pattern

fn connect_to_database() -> &'static [u32] {
    INIT.call_once(|| {
        // Unsafe block because we write to a mutable static.
        unsafe {
            DB_POOL = Some(vec![1, 2, 3]); // pretend these are connections
        }
    });
    unsafe { DB_POOL.as_deref().unwrap() }
}

The call_once method uses a fast atomic check. If the initialization has already finished, the closure is not executed. This pattern works with unsafe because we are writing to a mutable static from multiple threads, but the Once guarantees that only one write happens. The rest of the code reads the static mut only after the initialization is done. This is dangerous if you do not understand the memory ordering implications, but it is a valid pattern used in many Rust libraries.

I prefer to use LazyLock or OnceCell whenever possible because they encapsulate the unsafe and reduce the chance of mistakes. But knowing how Once works gives you flexibility in advanced scenarios.

Atomic Global Counters and Flags

When your global state is a simple integer or a boolean, you can use Rust’s atomic types instead of a mutex. These are operations that the CPU guarantees to be indivisible (atomic) even across threads. They are much faster than locks.

The standard library provides AtomicBool, AtomicI32, AtomicU64, AtomicUsize, and others. You can use Ordering to specify memory ordering constraints—usually Relaxed is fine for counters, but SeqCst is safer if you need a full memory barrier.

I once built a simple profiling system that counted the number of times a particular function was called across all threads.

use std::sync::atomic::{AtomicU64, Ordering};

static CALL_COUNT: AtomicU64 = AtomicU64::new(0);

fn expensive_operation() {
    CALL_COUNT.fetch_add(1, Ordering::Relaxed);
    // do the actual work here
}

fn get_call_count() -> u64 {
    CALL_COUNT.load(Ordering::Relaxed)
}

The fetch_add operation increments the value atomically. Multiple threads can call this without any locks, and the counter will be accurate. For more complex operations like compare-and-swap, you can use compare_exchange or fetch_update.

Atomics are great for flags as well. I used an AtomicBool to indicate that a global shutdown has been requested:

use std::sync::atomic::{AtomicBool, Ordering};

static SHOULD_SHUTDOWN: AtomicBool = AtomicBool::new(false);

fn request_shutdown() {
    SHOULD_SHUTDOWN.store(true, Ordering::Release);
}

fn worker_loop() {
    while !SHOULD_SHUTDOWN.load(Ordering::Acquire) {
        // do work
    }
}

The Release and Acquire ordering ensure that any writes before the store are visible to the thread that loads the flag. Atomics are the foundation of all synchronization, and they are the first thing I reach for when the shared state is a simple scalar.

Leaking Memory for a Singleton

Sometimes the simplest way to have a global mutable singleton is to intentionally leak memory. You allocate a value on the heap, convert it to a raw pointer, and then use Box::leak to get a &'static mut. This bypasses Rust’s lifetime checks, so you have to be absolutely sure that the data will not be accessed after it is freed (which it never will, because you never free it).

I used this pattern in a library that needed a global hash map of file descriptors that had to be accessible from signal handlers (which are extremely restricted in what they can do). The map was initialized once and never modified.

use std::collections::HashMap;
use std::sync::Mutex;

static FILE_MAP: &'static Mutex<HashMap<u32, String>> = {
    let map = Mutex::new(HashMap::new());
    Box::leak(Box::new(map))
};

fn register_file(fd: u32, path: String) {
    let mut map = FILE_MAP.lock().unwrap();
    map.insert(fd, path);
}

The Box::leak call creates a leak, giving us a reference that lives for the entire program. Because it never gets dropped, it is safe to treat it as 'static. But you must be careful: if you ever have multiple threads initializing such a value without synchronization, you might create a data race. In this example, the static initializer runs at compile time—it is a constant expression—so it is safe.

Leaked memory is fine in most programs because the OS reclaims all memory when the process exits. The downside is that you lose control over destructors, so if your global state needs cleanup (closing database connections, flushing files), you cannot rely on Drop. You would need to manually call a cleanup function before exit.

The Global Registry with Trait Objects

The final pattern I want to share is a global registry for trait objects. This is useful when you have a plugin system or a set of services that implement a common trait, and you want to look them up by name anywhere in your code.

I built a small task scheduler that allowed registering handlers for different event types. Each handler implemented a Handler trait.

use std::any::Any;
use std::collections::HashMap;
use std::sync::{Mutex, LazyLock};

trait Handler: Send + Sync {
    fn handle(&self, event: &str);
}

static HANDLERS: LazyLock<Mutex<HashMap<String, Box<dyn Handler>>>> =
    LazyLock::new(|| Mutex::new(HashMap::new()));

fn register_handler(name: &str, handler: Box<dyn Handler>) {
    HANDLERS.lock().unwrap().insert(name.to_string(), handler);
}

fn dispatch_event(name: &str, event: &str) {
    if let Some(handler) = HANDLERS.lock().unwrap().get(name) {
        handler.handle(event);
    }
}

// Example handler
struct LogHandler;
impl Handler for LogHandler {
    fn handle(&self, event: &str) {
        println!("Log: {}", event);
    }
}

The core is a global HashMap behind a Mutex, mapping strings to boxed trait objects. The trait must be Send + Sync because we are storing it in a static that can be accessed from any thread. This pattern works well when the number of registrations is static or happens early in the program. However, if you are constantly registering and unregistering handlers, you might want to use an RwLock for better read performance.

I learned that you should avoid putting closures or other non-Send types in the registry. Also, the Box<dyn Handler> allocation lives forever if not removed. For most applications this is fine.

Final thoughts on keeping your sanity

All these patterns come with trade-offs. The static mutex is simple but can become a bottleneck. Thread-local storage is fast but isolates data per thread. Atomics are wonderful for counters but cannot hold complex data. The once-initialization patterns protect against double initialization but require care with unsafe. Leaked singletons are tempting but you lose cleanup. The registry pattern is flexible but adds runtime overhead.

My advice is to start with the simplest pattern that works for your case. If you just need a read‑only configuration, use LazyLock with an immutable value. If you need mutable global state that is rarely written, use RwLock. If you need per-thread caches, use thread-local storage. Only reach for the more advanced patterns like Once and leaking when you are sure you need them.

I also recommend wrapping your global state behind a module-level function that hides the internals. That way you can change the implementation later without rewriting every caller. For example, instead of exposing the static directly, write a pub fn get_config() -> &Config that returns the result of CONFIG.read(). This gives you freedom to switch from Mutex to RwLock or even to a thread-local copy later.

The hardest part of global state in Rust is not the syntax—it’s the design. Think about whether the state really needs to be global. Often you can pass it as a function parameter or use a dependency injection framework. But when you truly need a global, these eight patterns will keep you from going crazy. I’ve used all of them in production systems, and they never failed me when applied correctly.


// Keep Reading

Similar Articles

Mastering Rust's Concurrency: Advanced Techniques for High-Performance, Thread-Safe Code
Rust

Mastering Rust's Concurrency: Advanced Techniques for High-Performance, Thread-Safe Code

Rust's concurrency model offers advanced synchronization primitives for safe, efficient multi-threaded programming. It includes atomics for lock-free programming, memory ordering control, barriers for thread synchronization, and custom primitives. Rust's type system and ownership rules enable safe implementation of lock-free data structures. The language also supports futures, async/await, and channels for complex producer-consumer scenarios, making it ideal for high-performance, scalable concurrent systems.

Read Article →