C.W.K.
Stream
Lesson 05 of 07 · published

Async Commands & the Runtime

~14 min · tauri, async, rust, performance

Level 0Web Tourist
0 XP0/56 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"A slow synchronous command freezes the app. An async one lets the window keep breathing while the work happens."

Just Add async

To make a command asynchronous, write it as async fn. Tauri runs it on its async runtime (Tokio) and the frontend's invoke Promise resolves when the future completes. This matters because a long synchronous command can block, making your UI feel frozen. Network calls, waiting on a child process, anything I/O-bound — make it async and the app stays responsive.

I/O-bound vs CPU-bound

There's a real distinction. I/O-bound work (HTTP, disk, waiting) fits async perfectly — the runtime parks the task while it waits and runs other work. CPU-bound work (resizing a huge image, hashing, heavy parsing) doesn't benefit from async alone; if you grind the CPU inside an async task you can still stall the runtime. For heavy compute, offload to a blocking thread with tokio::task::spawn_blocking (or a plain std::thread) so the async runtime stays free.

The Send Gotcha

One rule the compiler will enforce: data you hold across an await in an async command must be safe to move between threads (Send). A common trap is holding a std::sync::Mutex guard across an await point — it won't compile. The fix is to drop the lock before awaiting, or use an async-aware lock. You'll meet this properly in the state track; for now, know that 'cannot be sent between threads safely' usually means 'you held a lock across an await.'

Code

An async command (I/O-bound)·rust
// I/O-bound: async keeps the UI responsive while the network call runs.
#[tauri::command]
async fn fetch_title(url: String) -> Result<String, String> {
    let body = reqwest::get(&url)
        .await
        .map_err(|e| e.to_string())?
        .text()
        .await
        .map_err(|e| e.to_string())?;
    Ok(body.chars().take(80).collect())
}
Offloading CPU work with spawn_blocking·rust
// CPU-bound: don't grind inside the async task — offload it.
#[tauri::command]
async fn heavy_hash(data: Vec<u8>) -> Result<String, String> {
    let digest = tokio::task::spawn_blocking(move || {
        expensive_pure_cpu_hash(&data) // runs on a blocking thread pool
    })
    .await
    .map_err(|e| e.to_string())?;
    Ok(digest)
}

External links

Exercise

Turn a command into an async one. Easiest demo: write an async command that awaits a 2-second tokio sleep then returns a string. Call it from the frontend and confirm the rest of the UI (a spinner, a counter) keeps moving while it runs. Then write the same delay synchronously with std::thread::sleep and feel the difference — the sync version freezes the window.
Hint
Async: use tokio::time::sleep(Duration::from_secs(2)).await. Sync (to feel the freeze): std::thread::sleep(...) directly in a non-async command. The frozen window is the lesson.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.