Rust Async in Tauri v2 — What Tripped Me Up and How I Fixed It

Published: (May 12, 2026 at 08:30 PM EDT)
3 min read
Source: Dev.to

Source: Dev.to

All tests run on an 8‑year‑old MacBook Air.
All results are from shipping seven Mac apps as a solo developer. No sponsored opinion.

The “cannot be sent between threads” wall

The most common async error in Tauri development:

MutexGuard cannot be sent between threads safely

This happens when you hold a lock across an .await point. Tauri commands run on Tokio, which may switch threads at await points. A MutexGuard from std::sync::Mutex is not Send.

Fix: use tokio::sync::Mutex instead of std::sync::Mutex for state that needs to be held across await points, or restructure to drop the guard before awaiting.

// Wrong — holds MutexGuard across await
async fn bad(state: State>) {
    let guard = state.lock().unwrap();
    some_async_call().await; // MutexGuard still held here
    guard.do_something();
}

// Right — drop guard before await
async fn good(state: State>) {
    let value = {
        let guard = state.lock().unwrap();
        guard.get_value()
    }; // guard dropped here
    some_async_call().await;
    use_value(value);
}

Blocking calls in async commands

rusqlite, file I/O, and other synchronous operations block the current thread. In an async context this blocks the Tokio thread pool.

  • For short operations (sub‑millisecond), blocking is fine.
  • For anything longer, offload to a dedicated thread pool:
let result = tokio::task::spawn_blocking(|| {
    // blocking operation here
    do_something_slow()
}).await??;

spawn_blocking keeps the async runtime responsive.

Long‑running tasks and progress updates

For operations that take seconds (file sync, large transfers) you’ll want progress updates in the frontend. Use Tauri’s event system:

#[tauri::command]
async fn sync_files(handle: AppHandle) -> Result {
    for (i, file) in files.iter().enumerate() {
        process_file(file).await?;
        handle.emit("sync-progress", i).ok();
    }
    Ok(())
}

The frontend listens with listen('sync-progress', …), keeping a clean separation between the async work and UI updates.

The abort pattern for cancellable tasks

Users may cancel operations, so build cancellation in from the start:

let (tx, rx) = tokio::sync::oneshot::channel::();

tokio::spawn(async move {
    tokio::select! {
        _ = do_long_work() => { /* completed */ }
        _ = rx => { /* cancelled */ }
    }
});

// Store `tx` somewhere and send to cancel when needed

Retrofitting cancellation into a long‑running task that wasn’t designed for it is painful; design for it early.

The verdict

Async Rust in Tauri is manageable once you internalize the Send + Sync rules and know which mutex to reach for. The compiler errors are specific enough to guide you.

The patterns above cover roughly 90 % of what you’ll encounter when shipping a real Tauri app.

If this was useful, a ❤️ helps more than you’d think — thanks!

Hiyoko PDF Vault
X

0 views
Back to Blog

Related posts

Read more »