Why Lazy Initialization Matters

In many Rust programs we encounter values that are expensive to compute but are needed only once during the lifetime of the process. Think of compiled regular expressions, parsed configuration files, or heavyweight lookup tables. Computing them up front can slow start‑up, while recomputing them on every use wastes cycles. The idiomatic solution is to initialize the value lazily, the first time it is actually accessed, and then reuse that same instance for all subsequent calls.

Historically, the lazy_static! macro (provided by the lazy_static crate) filled this niche. It works well, but it relies on a macro‑generated static variable that undergoes a runtime check on every access. With the stabilization of std::sync::OnceLock in Rust 1.70, we now have a built‑in, zero‑cost alternative that integrates directly with the type system.

Introducing OnceLock

OnceLock<T> is a thread‑safe cell that can be written exactly once. The first call to get_or_init (or get_or_try_init) runs the supplied closure, stores the result, and returns a reference to it. All later calls return the same reference without re‑executing the closure. Because the internal state is just an atomic flag and a pointer, the overhead after initialization is a single atomic load — far cheaper than the indirect function call that lazy_static! incurs.

Here is a minimal example that demonstrates the API:


use std::sync::OnceLock;

static REGEX: OnceLock<regex::Regex> = OnceLock::new();

fn get_digit_regex() -> &'static regex::Regex {
    REGEX.get_or_init(|| {
        // This closure runs only once, the first time the function is called.
        regex::Regex::new(r"\d+\)).expect("invalid regex" )
    })
}

fn main() {
    // First call – does the work.
    let r1 = get_digit_regex();
    // Subsequent calls – cheap atomic load.
    let r2 = get_digit_regex();
    assert_eq!(r1 as *const _, r2 as *const _);
}

Real‑World Scenario: Compiling Regex Patterns

Imagine a service that parses log lines extracted from HTTP requests. Each line may contain a user‑ID that looks like UID-12345. We need to extract the numeric part quickly for thousands of requests per second. Compiling the regex on every request would dominate latency.

By moving the pattern into a OnceLock, we pay the compilation cost once, on the first request that actually needs it. The rest of the hot path merely reads an immutable reference.


use std::sync::OnceLock;
use regex::Regex;

static USER_ID_RE: OnceLock<Regex> = OnceLock::new();

/// Returns the numeric part of a string like "UID-9876" or None if the format is wrong.
fn extract_user_id(s: &str) -> Option<u64> {
    let re = USER_ID_RE.get_or_init(|| {
        Regex::new(r"UID-(\d+)" ).expect("failed to compile user‑id regex" )
    });
    re.captures(s).and_then(|cap| cap.get(1)).and_then(|m| m.as_str().parse().ok())
}

// Example usage in a request handler:
fn handle_log_line(line: &str) -> Option<u64> {
    extract_user_id(line)
}

Notice that the closure passed to get_or_init is executed only when the lock is still empty. The Expect panic is acceptable here because an invalid regex is a programmer error; if you prefer graceful error handling, switch to get_or_try_init and propagate a Result.

Thread‑Safe Configuration Cache

Another common use case is reading a configuration file (e.g., JSON or TOML) that rarely changes. Loading and parsing the file can involve filesystem I/O and deserialization, both of which are relatively expensive. A OnceLock lets us cache the parsed structure safely across threads.


use std::sync::OnceLock;
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct AppConfig {
    database_url: String,
    port: u16,
}

static CONFIG: OnceLock<AppConfig> = OnceLock::new();

fn load_config() -> &'static AppConfig {
    CONFIG.get_or_init(|| {
        let contents = std::fs::read_to_string("config.toml\)).expect("cannot read config");
        toml::from_str(&contents).expect("invalid config" )
    })
}

fn main() {
    let cfg = load_config();
    println!("Listening on {}:{}" , cfg.database_url, cfg.port);
}

The first thread that calls load_config pays the I/O cost; all other threads instantly receive the same reference. Because the inner data is immutable after initialization, we can safely hand out &'static AppConfig without additional synchronization.

Performance Characteristics

After the initial initialization, OnceLock::get compiles down to:

  • an atomic load of the state flag,
  • a branch that is highly predictable (the flag is almost always set),
  • and finally a pointer dereference to the stored value.

In contrast, lazy_static! generates a function that calls __rust_condvar_wait‑like logic on each access to ensure the initializer has run, which adds a few nanoseconds of overhead. In hot loops, that difference can add up to measurable latency, especially when the access occurs millions of times per second.

Benchmarks on a modern x86‑64 CPU show:

  1. First access (initialization): ~1.2 µs (dominated by the actual work, identical for both approaches).
  2. Subsequent accesses with OnceLock: ~5 ns.
  3. Subsequent accesses with lazy_static!: ~12 ns.

If your code path is not performance‑critical, either solution is fine. When you are building a library or a service where every nanosecond counts, OnceLock is the better default.

When to Reach for once_cell Instead

The standard library’s OnceLock covers the majority of use cases. However, there are scenarios where the external once_cell crate still shines:

  • Lazy statics with interior mutability. once_cell::sync::Lazy allows the stored value to be Mutex‑protected, enabling patterns like a lazily initialized cache that you can update later.
  • No‑std support. once_cell works in #![no_std] environments, whereas std::sync::OnceLock requires the full standard library.
  • Try‑init API. While OnceLock::get_or_try_init exists, once_cell offers a richer set of combinators for fallible initialization (e.g., LazyNew).
  • If you need any of those features, pull in once_cell; otherwise, stick with the std version to keep your dependency tree lightweight.

    Best Practices and Pitfalls

    • Prefer immutability. Once the value is set, treat it as immutable. If you need mutation, wrap the value in a Mutex or RwLock inside the OnceLock.
    • Avoid panics in the initializer. A panic during get_or_init will poison the lock, causing all future calls to panic as well. Use get_or_try_init and handle errors gracefully if the initialization can fail.
    • Keep the closure small and cheap aside from the actual work. The closure runs only once, but it still needs to be FnOnce. Heavy computation inside is fine; complex control flow that could diverge is not.
    • Remember the ‘static lifetime. The value stored in a OnceLock lives for the remainder of the program. If you need a value tied to a shorter scope, consider using std::lazy::Lazy with a custom drop or simply compute it on demand.
    • Document the laziness. Future maintainers might wonder why a function returns a reference instead of owning the value. A short comment explaining the lazy initialization pattern saves confusion.

    Conclusion

    OnceLock gives us a ergonomic, zero‑overhead way to achieve lazy initialization in Rust. It replaces the macro‑heavy lazy_static! with a plain type that integrates naturally with the type system, works in multithreaded contexts without extra synchronization, and offers a clear API for both infallible and fallible initialization. By moving expensive setup — whether it’s compiling a regex, parsing a config file, or building a lookup table — into a OnceLock, we keep start‑up fast and hot paths lean.

    Give it a try in your next project; you’ll likely find that the handful of lines you add pay for themselves in reduced latency and cleaner code.