Rust Async in Tauri v2 — What Tripped Me Up and How I Fixed It
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 →