Async Commands in Tauri: Background Tasks with Progress Events | Rust + React
Video: Async Commands in Tauri: Background Tasks with Progress Events | Rust + React by Taught by Celeste AI - AI Coding Coach
async fnplusapp.emitto stream progress while the command runs. The pattern for any operation that takes more than a second.
Some operations finish in milliseconds — read a file, query a small DB, compute a checksum. Others take longer — download a 100MB file, run a build, transcode video. For the slow ones, you don't want to leave the user staring at a frozen UI. Tauri's async commands plus event emission give you progress updates.
Why async
A synchronous command:
#[tauri::command]
fn long_task() -> Result<(), String> {
std::thread::sleep(std::time::Duration::from_secs(5));
Ok(())
}
This blocks a Tauri runtime thread for 5 seconds. Other commands queue up; the UI may stutter; if the call is from the main JS thread, the JS engine waits.
The async variant runs on a Tokio executor:
#[tauri::command]
async fn long_task() -> Result<(), String> {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
Ok(())
}
Add async to the function. Use Tokio's async APIs (tokio::time::sleep instead of std::thread::sleep, tokio::fs::* instead of std::fs::*, reqwest for HTTP). Tauri runs it on the Tokio runtime without blocking anything else.
For pure-Rust CPU-bound work that doesn't have an async API, use tokio::task::spawn_blocking:
#[tauri::command]
async fn cpu_heavy(data: Vec<u8>) -> Result<u64, String> {
let result = tokio::task::spawn_blocking(move || {
// expensive sync work
compute_checksum(&data)
})
.await
.map_err(|e| e.to_string())?;
Ok(result)
}
spawn_blocking puts the work on a thread pool reserved for blocking operations. The async runtime stays responsive.
Emitting progress events
A long task often has a notion of progress. Emit events as it runs:
use tauri::{AppHandle, Emitter};
#[tauri::command]
async fn download_file(app: AppHandle, url: String) -> Result<(), String> {
let resp = reqwest::get(&url).await.map_err(|e| e.to_string())?;
let total = resp.content_length().unwrap_or(0);
let mut downloaded: u64 = 0;
let mut stream = resp.bytes_stream();
use futures_util::StreamExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| e.to_string())?;
downloaded += chunk.len() as u64;
let progress = if total > 0 {
(downloaded as f64 / total as f64 * 100.0) as u32
} else {
0
};
app.emit("download-progress", progress).ok();
// write chunk to file...
}
app.emit("download-complete", ()).ok();
Ok(())
}
The pattern: read the response in chunks, accumulate bytes, emit the percentage on every chunk.
Frontend listener
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
function Downloader() {
const [progress, setProgress] = useState(0);
const [done, setDone] = useState(false);
useEffect(() => {
let unp: UnlistenFn, unc: UnlistenFn;
(async () => {
unp = await listen<number>("download-progress", (e) => setProgress(e.payload));
unc = await listen("download-complete", () => setDone(true));
})();
return () => { unp?.(); unc?.(); };
}, []);
async function start() {
setDone(false);
setProgress(0);
await invoke("download_file", { url: "https://example.com/big-file.zip" });
}
return (
<>
<progress value={progress} max={100}>{progress}%</progress>
<button onClick={start}>Download</button>
{done && <p>Done!</p>}
</>
);
}
Subscribe to events. Trigger the command. The progress bar fills as the command emits.
Channels for per-call streaming
Events are broadcast — every listener receives them. For per-call streaming (only the caller of invoke cares), Tauri 2 channels are cleaner:
use tauri::ipc::Channel;
#[tauri::command]
async fn download_file(url: String, on_progress: Channel<u32>) -> Result<(), String> {
// ...
on_progress.send(progress).ok();
// ...
Ok(())
}
import { Channel } from "@tauri-apps/api/core";
const channel = new Channel<number>();
channel.onmessage = (progress) => setProgress(progress);
await invoke("download_file", { url, onProgress: channel });
A channel is scoped to one invoke call. Cleaner than naming a global event when the consumer is a single component.
Cancellation
Long-running tasks need a cancel button. Pattern: use a shared atomic flag that the task checks periodically.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[derive(Default)]
struct AppState {
cancel_flag: Arc<AtomicBool>,
}
#[tauri::command]
async fn download_file(state: tauri::State<'_, AppState>, url: String) -> Result<(), String> {
state.cancel_flag.store(false, Ordering::Relaxed);
let mut stream = reqwest::get(&url).await.map_err(|e| e.to_string())?.bytes_stream();
while let Some(chunk) = stream.next().await {
if state.cancel_flag.load(Ordering::Relaxed) {
return Err("cancelled".into());
}
// process chunk
}
Ok(())
}
#[tauri::command]
fn cancel_download(state: tauri::State<AppState>) {
state.cancel_flag.store(true, Ordering::Relaxed);
}
The frontend calls cancel_download on a button click. The download checks the flag every chunk; when it sees true, it returns an error. Standard cooperative cancellation.
Concurrency control
If you start ten downloads at once, you'll hammer the network. Use a semaphore to limit concurrent operations:
use tokio::sync::Semaphore;
struct AppState {
download_sem: Arc<Semaphore>,
}
#[tauri::command]
async fn download_file(state: tauri::State<'_, AppState>, url: String) -> Result<(), String> {
let _permit = state.download_sem.acquire().await.map_err(|e| e.to_string())?;
// do the download — only `permits` of these run at once
Ok(())
}
Initialise with Semaphore::new(3) to allow 3 concurrent downloads.
Common mistakes
std::thread::sleep inside async. Blocks the runtime thread. Use tokio::time::sleep.
Sync file I/O in an async command. std::fs::read blocks. Use tokio::fs or spawn_blocking.
Emitting too frequently. Events for every byte downloaded floods the IPC. Throttle to ~50 events per second (every 20ms or every 1MB).
No cancellation. Users start a task they can't stop. Always provide cancel for tasks > 1 second.
Forgetting Ordering::Relaxed. Atomics need an ordering parameter. Relaxed is fine for cancel flags; SeqCst for stronger guarantees.
What's next
Next lesson: organising Rust modules in a Tauri app. Once you have many commands, they need structure.
Recap
async fn for non-blocking commands. Use Tokio's async I/O (tokio::fs, reqwest) inside. spawn_blocking for CPU-heavy sync work. Emit events or use channels for progress. Cancel with AtomicBool checked between chunks. Bound concurrency with Semaphore.
Next: Rust modules.