Tauri Commands Are a Typed Boundary, Not a Free Function Call
Tauri commands are the supported bridge between Rust and your web UI. Treat them as a serialized request/response boundary: design payloads carefully, guard shared state, and avoid high-frequency invoke loops.
14 Feb 2026, 07:38 UTC

If your web UI needs the Rust side to hash a file, query SQLite, or touch the operating system, the tempting move is to expose a local HTTP endpoint. In Tauri, the supported bridge is a command: a Rust function the frontend can call by name. The useful takeaway is to treat every call as a small, typed, serialized transaction. Commands are fast enough for user actions, but they are not a frame-rate data plane.
What a command actually is
A command is an ordinary Rust function annotated with #[tauri::command]. You register it in the Tauri builder, and the frontend calls it through invoke. Arguments and return values cross the boundary as JSON, using Serde in Rust. That means the shape you choose in Rust is the contract your JavaScript must satisfy.
The registration line is the important part; the exact context/run call varies by template and major version:
.invoke_handler(tauri::generate_handler![cart_total])
Design the payload before you write the function
Most command bugs are contract bugs: a field is unitPriceCents in JavaScript but unit_price_cents in Rust, or a number arrives as a string. Use Serde attributes to make the boundary explicit. This example keeps money in integer cents and validates before doing arithmetic:
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CartItem {
name: String,
unit_price_cents: i64,
quantity: i64,
}
#[derive(serde::Serialize)]
struct CartTotal {
item_count: i64,
total_cents: i64,
}
#[tauri::command]
fn cart_total(items: Vec<CartItem>) -> Result<CartTotal, String> {
let mut item_count = 0i64;
let mut total_cents = 0i64;
for item in items {
if item.quantity < 0 || item.unit_price_cents < 0 {
return Err("quantities and prices must be non-negative".into());
}
item_count += item.quantity;
total_cents = total_cents
.checked_add(
item.unit_price_cents
.checked_mul(item.quantity)
.ok_or("overflow")?,
)
.ok_or("overflow")?;
}
Ok(CartTotal { item_count, total_cents })
}
On the frontend, the command name is the Rust function name by default. The import path changed between major versions, so verify the one that matches your project:
import { invoke } from '@tauri-apps/api/core'; // Tauri v2
// import { invoke } from '@tauri-apps/api'; // Tauri v1
const total = await invoke('cart_total', {
items: [
{ name: 'Keyboard', unitPriceCents: 7499, quantity: 1 },
{ name: 'Mouse', unitPriceCents: 2999, quantity: 2 },
],
});
console.log(total);
For these inputs, the resolved object should represent three items and 13,497 cents. If fields are undefined, check key casing and numeric types first. A Rust Err or a Serde mismatch rejects the promise.
State and async: where commands get expensive
Commands can share resources through tauri::State. Rust's ownership rules still apply: if the state is mutable, wrap it in Mutex or RwLock. A minimal counter looks like this:
struct Counter { value: Mutex<i64> }
#[tauri::command]
fn next_value(state: tauri::State<'_, Counter>) -> i64 {
let mut value = state.value.lock().unwrap();
*value += 1;
*value
}
Register it with .manage(Counter { value: Mutex::new(0) }). Decide deliberately how you handle a poisoned lock instead of defaulting to unwrap in production code.
Async commands are useful for I/O-bound work. Do not assume every async fn keeps the UI responsive: heavy CPU-bound work can still occupy a runtime worker. If your version and workload require it, move that work to an explicit blocking task rather than blocking the command body.
The trade-off: commands are not a 60fps bus
Each invoke serializes arguments, crosses into Rust, deserializes, runs, and serializes the result. For clicks, form submits, and file operations, that overhead is irrelevant. For per-frame animation or high-frequency sensor data, it becomes the bottleneck. Keep animation state in the frontend, or batch and throttle before invoking.
| Need | Better default |
|---|---|
| One user action triggers backend work | Command with a small JSON payload |
| Shared mutable resource across commands | State plus Mutex/RwLock |
| Per-frame animation or high-frequency stream | Keep state in the frontend; batch or throttle before invoking |
| User string used in a path or process | Validate or allowlist in Rust before use |
Security is part of the contract. Command arguments are untrusted input. Never pass a raw user string into a file path, shell command, or system call without validation. Prefer allowlists, canonical paths where relevant, and explicit error messages over silent fallbacks.
Check it locally
- Run
npm create tauri-appin a terminal from a parent directory. It requires Node.js/npm and a Rust toolchain, and it writes a new project directory. For a quick experiment, use a throwaway directory and delete it to undo the scaffold. - Add the Rust command to the crate file your template uses, commonly
src-tauri/src/main.rs, and register it withgenerate_handler. - Call it from your frontend entry point and run the project's dev script, commonly
npm run tauri dev, from the project root. On Linux, confirm the system webview packages required by your Tauri version. - Verify in browser devtools: the promise resolves with the expected fields, and invalid input rejects. Add Rust unit tests for pure validation and arithmetic logic.
Because Tauri major versions differ in import paths, context macros, and permission/capability requirements, verify those details against the docs for your installed version before shipping. Custom commands are registered by your app, but frontend calls to Tauri APIs may still need capability entries depending on version.
Before adding the next command
Write the payload type first, decide the error type, ask whether the data belongs in managed State, and ask whether the command could be called in a loop. If the answer to the last question is yes, batch it. That single habit prevents most Tauri IPC performance surprises.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.