rust.
rust6 min read

Tauri State with Arc<Mutex<T>>: A Safe Pattern for Shared State Across Commands

How to manage shared mutable state in Tauri v2 using tauri::State<Arc<Mutex<T>>>, when to reach for tokio::sync::Mutex, and the lock-across-await trap that crashes production apps.

Tauri State with Arc<Mutex<T>>: A Safe Pattern for Shared State Across Commands

Every Tauri app eventually hits the same wall: a counter, a connection pool, a cache, or a settings struct that two commands need to read and write. The Tauri docs sketch the answer in a few lines, but they skip the part that matters most \u2014 which Mutex to use, how to avoid deadlocks across .await, and why Arc is sometimes redundant. After shipping a few Tauri v2 daemons that hold long-lived sockets and async tasks, here is the pattern that survives production.

The baseline: tauri::State is already shared

Tauri's runtime already wraps your state in an Arc internally. When you call app.manage(my_state), the framework stores it in a type map and hands out cloned references through the State<'_, T> extractor. So State<MyConfig> works fine for read-only data \u2014 no Arc, no Mutex.

use tauri::State;

#[derive(Default)]
struct AppConfig {
    api_base: String,
}

#[tauri::command]
fn get_api_base(config: State<'_, AppConfig>) -> String {
    config.api_base.clone()
}

fn main() {
    tauri::Builder::default()
        .manage(AppConfig { api_base: "https://api.example.com".into() })
        .invoke_handler(tauri::generate_handler![get_api_base])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

The moment you need to mutate, the compiler stops you \u2014 State gives an immutable reference. That is where interior mutability comes in.

When you actually need Mutex<T>

Add a Mutex only when commands write to the same data. A request counter, a download queue, an in-memory user session \u2014 these all need synchronized writes. Wrap the data in Mutex and let manage hand it out:

use std::sync::Mutex;
use tauri::State;

#[derive(Default)]
struct Counter {
    hits: u64,
}

#[tauri::command]
fn bump(counter: State<'_, Mutex<Counter>>) -> u64 {
    let mut c = counter.lock().unwrap();
    c.hits += 1;
    c.hits
}

This compiles, works, and is what most tutorials stop at. It is also where most Tauri v2 apps trip over their first runtime bug.

Where Arc enters the picture

You need Arc<Mutex<T>> instead of plain Mutex<T> when the state has to leave the State extractor \u2014 usually because a background task or a spawned tokio future owns it for longer than a single command call. Examples:

  • A websocket reader loop that updates a shared session map
  • A debounced file-system watcher that mutates app state when files change
  • A long-running download whose progress a separate command queries

The State extractor only lives for the duration of one command call. If you spawn a task and need that task to keep mutating the same state, clone an Arc into the task before the command returns:

use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Manager, State};

#[derive(Default, Clone)]
struct Session {
    connected: bool,
    events_seen: u64,
}

type SharedSession = Arc<Mutex<Session>>;

#[tauri::command]
async fn start_listener(app: AppHandle, session: State<'_, SharedSession>) -> Result<(), String> {
    let owned: SharedSession = session.inner().clone();
    tauri::async_runtime::spawn(async move {
        loop {
            tokio::time::sleep(std::time::Duration::from_secs(1)).await;
            let mut s = owned.lock().unwrap();
            s.events_seen += 1;
        }
    });
    Ok(())
}

fn main() {
    let session: SharedSession = Arc::new(Mutex::new(Session::default()));
    tauri::Builder::default()
        .manage(session)
        .invoke_handler(tauri::generate_handler![start_listener])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Note session.inner().clone() \u2014 that clones the Arc, not the inner data. The cost is one atomic increment.

std::sync::Mutex over tokio::sync::Mutex \u2014 most of the time

Picking the right Mutex flavor is the single biggest source of bugs in Tauri commands. The default rule from the tokio docs themselves is unintuitive: prefer std::sync::Mutex unless you specifically need to hold the lock across an .await.

The reasoning: std::sync::Mutex::lock() is a synchronous call that returns immediately if uncontended, with no async overhead. It is roughly 3\u00d7 faster than tokio::sync::Mutex::lock().await for short critical sections. The tokio team is explicit about this in their documentation on choosing the right mutex.

use std::sync::Mutex;

#[tauri::command]
async fn fast_update(state: State<'_, Mutex<Counter>>) -> Result<u64, String> {
    let value = {
        let mut s = state.lock().map_err(|e| e.to_string())?;
        s.hits += 1;
        s.hits
    };
    do_some_async_work().await;
    Ok(value)
}

The { } block forces the guard to drop before the .await. That is the entire trick.

The lock-across-await trap

Reach for tokio::sync::Mutex only when you genuinely need the lock held across an .await point. Here is the bug that motivates it:

async fn bad_pattern(state: State<'_, Mutex<Cache>>) {
    let mut cache = state.lock().unwrap();
    let result = fetch_remote().await;
    cache.insert("key", result);
}

This compiles, but the future returned is not Send because MutexGuard is !Send. Tauri's async runtime will refuse to spawn it, and you get a confusing compile error mentioning Send bounds. The fix is to drop the guard before awaiting:

async fn good_pattern(state: State<'_, Mutex<Cache>>) -> Result<(), String> {
    let result = fetch_remote().await;
    let mut cache = state.lock().map_err(|e| e.to_string())?;
    cache.insert("key".to_string(), result);
    Ok(())
}

If the lock truly must straddle the await (rare \u2014 usually it means the data model is wrong), switch to tokio::sync::Mutex, which is Send-safe across yield points:

use tokio::sync::Mutex as AsyncMutex;

async fn truly_async(state: State<'_, AsyncMutex<Cache>>) {
    let mut cache = state.lock().await;
    let result = fetch_remote().await;
    cache.insert("key".to_string(), result);
}

The cost: every lock acquisition goes through the async runtime. Fine for low-frequency calls, a measurable bottleneck for hot paths.

When RwLock beats Mutex

If the read-to-write ratio is heavily skewed \u2014 say, 10 reads per write \u2014 RwLock lets multiple readers acquire the lock simultaneously. The same std::sync vs tokio::sync rule applies. For a settings struct that ten commands read and one command writes, swap Mutex for parking_lot::RwLock to get poison-free, faster locks:

use parking_lot::RwLock;
use std::sync::Arc;

type SharedSettings = Arc<RwLock<Settings>>;

#[tauri::command]
fn get_theme(settings: State<'_, SharedSettings>) -> String {
    settings.read().theme.clone()
}

#[tauri::command]
fn set_theme(theme: String, settings: State<'_, SharedSettings>) {
    settings.write().theme = theme;
}

parking_lot is worth the dependency for any non-trivial state \u2014 no lock poisoning, no unwrap() boilerplate, and benchmarks show ~50% lower contention overhead than std::sync.

A decision table

ScenarioUse
Read-only configState<T>
Mutated only inside command bodies, short critical sectionsState<std::sync::Mutex<T>>
Mutated by command + background taskState<Arc<Mutex<T>>>
Heavy read, occasional writeState<Arc<parking_lot::RwLock<T>>>
Must hold lock across .awaitState<Arc<tokio::sync::Mutex<T>>>

Default to the first row that fits. The further down the table you go, the more cost and complexity you absorb.

Common slipups

  • Returning a MutexGuard from a function \u2014 the guard cannot outlive the lock, so this never compiles. Return the inner data cloned out.
  • Calling .lock().unwrap() in production \u2014 a panic in one command poisons the lock for every other caller. Map the error or use parking_lot (no poisoning).
  • Storing a tokio::sync::Mutex and then using try_lock everywhere \u2014 defeats the point of async locks. If you find yourself doing this, you wanted std::sync::Mutex all along.

The pattern that survives: start with State<T>, escalate to Mutex<T> only when commands write, escalate to Arc<Mutex<T>> only when a background task shares ownership, and pick the async variant only when the lock genuinely crosses an .await.

References: