Back to Blog

Async Commands in Tauri: Background Tasks with Progress Events | Rust + React

Sandy LaneSandy Lane

Video: Async Commands in Tauri: Background Tasks with Progress Events | Rust + React by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

async fn plus app.emit to 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.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.