Using Tauri’s Async Commands and State for Safe Front‑end‑to‑Backend Communication
Learn how to define async Rust commands with #[tauri::command], share state via tauri::State, and keep the UI responsive while avoiding a bundled Node.js runtime.
02 Oct 2026, 09:56 UTC

Problem: UI freezes when heavy work runs on the main Rust thread
When a Tauri frontend calls a long‑running Rust function through the default synchronous invoke API, the WebView blocks until the function returns. This makes the application feel unresponsive, especially for file I/O, cryptography, or any CPU‑intensive task.
Thesis
By declaring Rust command handlers as async fn and accessing shared data through tauri::State, you can move heavy work off the UI thread, keep the frontend responsive, and still enjoy type‑safe IPC without pulling in a Node.js runtime.
Defining async commands
The #[tauri::command] attribute works on both synchronous and asynchronous functions. When the function is async, Tauri automatically awaits the future before sending the result back to the frontend as a resolved Promise.
use tauri::{State, Manager};
#[derive(Default)]
struct AppState {
counter: std::sync::Mutex,
}
#[tauri::command]
async fn increment(state: State<'_, AppState>) -> Result {
// Simulate work that could block the UI if run synchronously
let mut guard = state.counter.lock().map_err(|e| e.to_string())?;
*guard += 1;
Ok(*guard)
}
fn main() {
tauri::Builder::default()
.manage(AppState::default()) // register the state
.invoke_handler(tauri::generate_handler![increment])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
The frontend calls this command with the standard @tauri-apps/api helper:
import { invoke } from '@tauri-apps/api/core'
async function clickHandler() {
try {
const newCount = await invoke<'increment', number>('increment')
document.getElementById('count')!.textContent = newCount.toString()
} catch (e) {
console.error('Command failed:', e)
}
}
Using State for shared data
tauri::State provides a type‑safe way to hold data that lives for the lifetime of the application. The state is stored inside Tauri’s core runtime and accessed via dependency injection in the command signature. Because the state is wrapped in a Mutex (or RwLock for read‑heavy workloads), concurrent IPC calls are serialized safely without blocking the UI thread when the command itself is asynchronous.
Trade‑off: serialization overhead and thread safety
All arguments and return values traverse the IPC bridge as JSON, which adds serialization/deserialization cost. For large payloads (e.g., megabyte‑sized images) consider passing file paths or using Tauri’s tauri::api::dialog to let the frontend handle the binary data directly. Additionally, if you need true parallelism, spawn a blocking task with tauri::async_runtime::spawn_blocking inside the command and await its result.
Worked example: verifying the setup
- Create a new Tauri project:
cargo create-tauri-app ipc-demo. - Replace
src-tauri/src/main.rswith the code block above. - Add the TypeScript snippet to your frontend (e.g.,
src/main.ts) and bind it to a button. - Run
tauri devand click the button; the counter should increase without freezing the UI. - To confirm no Node runtime is bundled, inspect the release binary size:
tauri buildproduces a single executable whose size is comparable to a native Rust app (typically a few MB). - Check the generated
appfolder for the absence of anode_modulesdirectory or a Chromium runtime.
Actionable closing
Start by converting any command that performs more than trivial work to async fn. Guard shared mutable state with tauri::State and a synchronization primitive. Measure UI responsiveness with the browser’s performance panel while the command runs; you should see the main thread stay under 16 ms per frame. If you notice jank, consider off‑loading the payload to spawn_blocking or refactoring the data flow to avoid large JSON transfers. This approach gives you the safety and performance benefits of a native Rust backend while keeping the familiar web‑based frontend.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.