Asynchronous Commands
Learn how to define and call async Tauri commands that keep your app responsive while Rust handles long-running tasks
What Asynchronous Commands Solve
Every Tauri command runs on a thread managed by the Rust backend. A synchronous command holds that thread until it finishes. For quick operations — formatting a string, adding two numbers — that is exactly what you want. But if a command needs to read a large file, wait for a network response, or perform a CPU‑heavy calculation, it will block its thread and, in the worst case, freeze the entire application until the work completes.
An asynchronous command solves this by releasing the thread while it waits. It uses Rust’s async/await syntax and Tauri’s built‑in async runtime (tokio) to pause execution at .await points without tying up a thread. The frontend experiences it the same way it experiences a synchronous command: a function call that returns a promise. But the backend side is fundamentally non‑blocking.
A beginner’s mental model is to think of an async command as a restaurant order. A synchronous command would be a waiter standing at the kitchen until the dish is plated. An async command lets the waiter take other orders and return when the dish is ready — the thread does other work in the meantime.
Tauri’s async runtime:
Tauri v2 runs on tokio, a production‑grade async runtime for Rust. Any Rust crate built for tokio — like reqwest for HTTP, tokio::fs for file I/O, or database drivers — works seamlessly inside an async command.
Defining an Async Command in Rust
The syntax is almost identical to a synchronous command. You add the async keyword before fn and the return type wraps in Result. Internally Tauri detects the async signature and spawns the future onto its tokio runtime.
A minimal async command that simulates a long operation using a non‑blocking delay:
#[tauri::command]
async fn slow_greeting(name: String) -> Result<String, String> {
// tokio::time::sleep does NOT block the thread
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
Ok(format!("Hello, {}! That took a while.", name))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![slow_greeting])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
The crucial difference from a blocking std::thread::sleep is that the thread is free during the 2‑second wait. If another command arrives, Tauri can handle it immediately.
Never block inside an async command:
Calling std::thread::sleep or any synchronous I/O operation that takes significant time inside an async command starves the async runtime. The application may become unresponsive. Always use tokio‑aware alternatives: tokio::time::sleep, tokio::fs::read_to_string, reqwest::get, etc.
Registering the Async Command
Registration is identical to synchronous commands. Add the function name to generate_handler![]. If the command is defined in a separate module, use its full path:
mod commands;
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![commands::fetch_data])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
The command name exposed to the frontend is the function name without the module prefix. commands::fetch_data is called from JavaScript as fetch_data.
Calling an Async Command from React
On the frontend side, an async command is indistinguishable from any other command. The invoke function always returns a promise. The difference is that the promise resolves when the Rust future completes — potentially after network calls, file reads, or delays.
A React component that triggers the slow_greeting command and displays the result:
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
function App() {
const [greeting, setGreeting] = useState("");
const [loading, setLoading] = useState(false);
async function handleGreet() {
setLoading(true);
try {
const message = await invoke<string>("slow_greeting", {
name: "Tauri",
});
setGreeting(message);
} catch (error) {
console.error("Command failed:", error);
setGreeting("Something went wrong");
} finally {
setLoading(false);
}
}
return (
<div>
<button onClick={handleGreet} disabled={loading}>
{loading ? "Waiting..." : "Say Hello (slowly)"}
</button>
<p>{greeting}</p>
</div>
);
}
export default App;
Works like a regular async call:
If the button stays responsive for 2 seconds and then shows the greeting, your async command is working correctly. The UI never freezes during the wait because the JavaScript event loop is untouched — only the Rust thread pool is involved in the delay.
When Async Commands Are the Right Choice
A common question after learning both sync and async commands is: which one should I use? The decision hinges on what the command actually does.
Use a sync command when the operation is short and uses only CPU or memory on the Rust side. Examples: computing a hash, parsing a small string, performing a quick in‑memory calculation. The overhead of an async state machine is unnecessary.
Use an async command when the operation involves I/O (network, disk, database), waits for an external process, or is long‑running enough that it would block the thread pool. Examples: fetching data from an API, reading a multi‑megabyte file, interacting with a database, running an image processing task that can be chunked into non‑blocking steps.
A sync command that does I/O can freeze the app:
Tauri v2 uses a thread pool for commands. If all threads are busy with blocking I/O, no other command can execute. Async commands cooperatively yield, keeping the pool free. A single sync command that reads a large file with std::fs::read_to_string could stall every other command for seconds.
Common Mistakes Beginners Make with Async Commands
Several pitfalls show up repeatedly in real projects. Being aware of them early will save you from puzzling freezes and broken promises.
Using the Wrong Sleep Function
std::thread::sleep blocks the current thread. tokio::time::sleep only suspends the current task. In an async command, always use the tokio version.
// WRONG: blocks the entire thread
std::thread::sleep(std::time::Duration::from_secs(3));
// CORRECT: yields control back to the runtime
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
Forgetting to Mark the Function as Async
A command that calls .await inside its body but is not declared async will not compile with a clear error message. Rust will complain that await is only allowed inside async functions. Always check that both the signature and the body agree.
Not Handling the Promise Rejection in JavaScript
An async command returning Err(...) causes the promise to reject. If the frontend code does not have a .catch or try/catch around invoke, the error will appear as an unhandled promise rejection in the console. Always wrap invoke calls in try/catch when the command can fail.
Mixing Blocking and Async Code
Inside an async command, avoid calling synchronous I/O methods directly. If you must run a genuinely blocking piece of code (for example, a C library call), use tokio::task::spawn_blocking so the blocking work runs on a dedicated thread without disrupting the async runtime.
Building a Realistic Example: Fetching Data from an API
Imagine you want your Tauri app to display the current price of a cryptocurrency by calling a public API. The network request is a perfect candidate for an async command.
We will use the reqwest crate, which is built on tokio. The following steps assume you already have a Tauri v2 project with a React frontend.
Step 1: Add the reqwest dependency
Open src-tauri/Cargo.toml and add reqwest with the json feature enabled so responses can be parsed automatically.
[dependencies]
tauri = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["full"] }
Run cargo build inside src-tauri to fetch the new dependency. This ensures the crate compiles before you try to use it.
Step 2: Write the async command in Rust
Create a new file src-tauri/src/commands.rs to keep the command separate from the builder setup. The command will take a cryptocurrency ID and return the price in USD.
use serde::Deserialize;
#[derive(Deserialize, Debug)]
struct CoinGeckoResponse {
market_data: MarketData,
}
#[derive(Deserialize, Debug)]
struct MarketData {
current_price: CurrentPrice,
}
#[derive(Deserialize, Debug)]
struct CurrentPrice {
usd: f64,
}
#[tauri::command]
pub async fn fetch_crypto_price(coin_id: String) -> Result<f64, String> {
let url = format!(
"https://api.coingecko.com/api/v3/coins/{}?localization=false",
coin_id
);
let client = reqwest::Client::new();
let response = client
.get(&url)
.header("User-Agent", "TauriApp")
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Err(format!("API returned status {}", response.status()));
}
let data: CoinGeckoResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
Ok(data.market_data.current_price.usd)
}
Then register the command in lib.rs:
mod commands;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![commands::fetch_crypto_price])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Step 3: Call the async command from React
Build a simple UI that accepts a coin ID (e.g., bitcoin, ethereum) and shows the price.
import { useState, FormEvent } from "react";
import { invoke } from "@tauri-apps/api/core";
function App() {
const [coinId, setCoinId] = useState("bitcoin");
const [price, setPrice] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleFetch(e: FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
setPrice(null);
try {
const usd = await invoke<number>("fetch_crypto_price", { coinId });
setPrice(usd);
} catch (err) {
setError(String(err));
} finally {
setLoading(false);
}
}
return (
<div style={{ padding: "2rem" }}>
<form onSubmit={handleFetch}>
<input
value={coinId}
onChange={(e) => setCoinId(e.target.value)}
placeholder="Coin ID"
/>
<button type="submit" disabled={loading}>
{loading ? "Fetching..." : "Get Price"}
</button>
</form>
{price !== null && <p>Price: ${price.toFixed(2)}</p>}
{error && <p style={{ color: "red" }}>Error: {error}</p>}
</div>
);
}
export default App;
Enter bitcoin and click the button. If everything is wired correctly, the UI will briefly show a loading state, then display the current price in USD.
Network errors are common:
The CoinGecko API rate‑limits free requests. If you call it too frequently, the async command will return an error, and your frontend should handle it gracefully — exactly as the try/catch block above does.
This pattern extends to any network‑dependent operation: downloading files, calling a local AI model over HTTP, or interacting with a REST API that your backend needs to consume before returning results to the UI.
Summary
Async commands let you perform long‑running or I/O‑bound Rust work without freezing the frontend. They build on the same #[tauri::command] infrastructure as synchronous commands but release threads while waiting, which keeps the entire app responsive. The frontend sees a promise, exactly as it does for any other Tauri invocation.
The single most important rule: inside an async command, use tokio‑aware libraries and never block the thread with synchronous I/O or std::thread::sleep. If you follow that rule, the boundary between your React UI and Rust backend will feel seamless regardless of how heavy the work is.