DEV Community

Cover image for Rust Async in Tauri v2 — What Tripped Me Up and How I Fixed It
hiyoyo
hiyoyo

Posted on

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

All tests run on an 8-year-old MacBook Air.
All results from shipping 7 Mac apps as a solo developer. No sponsored opinion.
Tauri v2 uses Tokio under the hood. That sounds simple. In practice, async Rust in a Tauri app has specific patterns that took me too long to figure out.
Here's what actually tripped me up.

The cannot be sent between threads wall
The most common async error in Tauri development:
MutexGuard<T> 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.
The 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.
rust// Wrong — holds MutexGuard across await
async fn bad(state: State<'_, Mutex>) {
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<'_, Mutex>) {
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:
rustlet result = tokio::task::spawn_blocking(|| {
// blocking operation here
do_something_slow()
}).await??;
spawn_blocking offloads to a dedicated thread pool. The async runtime stays responsive.

Long-running tasks and progress updates
For operations that take seconds — file sync, large transfers — you want progress updates to the frontend. Use Tauri's event system:
rust#[tauri::command]
async fn sync_files(handle: AppHandle) -> Result<(), AppError> {
for (i, file) in files.iter().enumerate() {
process_file(file).await?;
handle.emit("sync-progress", i).ok();
}
Ok(())
}
Frontend listens with listen('sync-progress', ...). Clean separation between the async work and the UI update.

The abort pattern for cancellable tasks
Users cancel operations. Build cancellation in from the start:
rustlet (tx, rx) = tokio::sync::oneshot::channel::<()>();

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

// Store tx somewhere, send to cancel
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 90% of what you'll hit shipping a real Tauri app.

If this was useful, a ❤️ helps more than you'd think — thanks!
Hiyoko PDF Vault → https://hiyokoko.gumroad.com/l/HiyokoPDFVault
X → @hiyoyok

Top comments (0)