Understanding Frontend-Rust Communication
Learn how Tauri v2 bridges your React frontend and Rust backend, using commands, the invoke function, and IPC serialization to securely access native capabilities.
A Tauri application is two programs running in the same process: a web frontend displayed in an operating system webview, and a Rust backend that handles everything the web sandbox cannot reach. The frontend is just HTML, CSS, and JavaScript—it cannot touch the file system, spawn processes, or read system memory on its own. The Rust side has full access to the machine. For the app to do anything useful beyond what a browser tab can do, the two sides must talk to each other.
Why Rust and JavaScript Need to Communicate
Every desktop feature that goes beyond rendering a web page starts as a request from the frontend to the Rust layer. Opening a file dialog, reading a SQLite database, sending a notification, or running a background task are all operations the webview’s security sandbox blocks. Rust, running outside that sandbox, performs the privileged work and sends the result back.
This split creates a clean security boundary. The frontend cannot accidentally or maliciously access the operating system. It can only ask Rust for specific, pre‑approved actions. The communication mechanism itself is the gate.
Two directions, one goal:
Communication flows in both directions: frontend calls Rust (via commands), and Rust pushes data to the frontend (via events). This document focuses on the core pattern—calling Rust from the frontend—since that is how most interactions start. Events are covered in later sections of this chapter.
The invoke Function Is the Bridge
From the frontend’s perspective, every call to Rust goes through a single function: invoke. It is imported from @tauri-apps/api/core. You give it the name of a Rust command and, optionally, an object of arguments, and it returns a promise that resolves with the command’s return value.
import { invoke } from '@tauri-apps/api/core';
// Call a Rust command named "get_user_name" with an id argument
const userName = await invoke<string>('get_user_name', { id: 42 });
console.log(userName);
A frontend developer does not need to know how invoke works internally. But a mental model helps: think of it as a remote procedure call. The string 'get_user_name' is the procedure name. The object { id: 42 } is the payload. The Rust side has a function registered under that exact name, receives the payload, runs, and returns a value that the promise resolves to.
If you see a resolved promise:
When invoke returns a value and your .then() or await receives it without an error, the communication pipeline is working correctly—the frontend reached the Rust function, and the return value was serialized back successfully.
How a Command Receives the Call on the Rust Side
On the Rust side, a function becomes callable from the frontend when you annotate it with #[tauri::command] and register it in the builder. Here is the smallest possible command:
// src-tauri/src/lib.rs
#[tauri::command]
fn greet() -> String {
"Hello from Rust!".to_string()
}
Then, inside the run function that sets up the Tauri app, you pass it to the handler:
// src-tauri/src/lib.rs
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
The invoke_handler holds a list of all commands the frontend is allowed to call. If a command is not in that list, calling it from JavaScript will reject with an error—no Rust code runs, no file is opened, nothing unexpected happens.
The pairing between invoke('greet') on the frontend and fn greet() on the Rust side is entirely name‑based. The command name is the function name, converted to a string. There is no automatic binding generation by default; you must keep both sides in sync manually.
A missing command panics at startup:
If you write a command function but forget to list it in generate_handler![], the app will compile and run—but the frontend will never be able to reach it. If you reference a command that is not registered, Tauri panics at build time during the context generation step, so the mistake is caught early. However, omitting a command you intend to use is silent: invoke will reject with an error at runtime.
Serialization — Why JSON and What It Means
Everything that crosses the boundary between JavaScript and Rust is serialized to JSON. When you call invoke, the argument object is turned into a JSON string, passed to Rust, and deserialized into Rust types. The return value is serialized to JSON in Rust, sent back, and parsed into a JavaScript value.
This has two immediate consequences:
- Everything must be serde‑compatible. Rust types used in commands must implement
serde::Serializefor return values andserde::Deserializefor arguments. Most standard types already do, and custom structs are easy to derive. - Large binary payloads are expensive. Serializing a megabyte of bytes to a JSON array of numbers is wasteful. For large data, Tauri provides
tauri::ipc::Responsethat sends raw bytes directly, bypassing JSON.
The frontend payload uses camelCase keys, even though Rust conventionally uses snake_case. Tauri automatically handles the conversion if you tell serde to rename fields.
// Rust struct for a command argument
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct CreateUserArgs {
user_name: String,
age: u8,
}
On the JavaScript side you call it naturally:
await invoke('create_user', { userName: 'Alice', age: 30 });
Snake_case keys will silently fail:
If you pass an argument with a snake_case key like user_name, the Rust side sees None or a default value for that field (depending on whether it’s Option). No error is thrown—the command simply receives incomplete data. Always use camelCase keys in your JavaScript calls when the Rust struct uses rename_all = "camelCase".
Rust as the Backend Logic Layer
The Rust side is not a thin wrapper around a few system calls. It is a full backend. You can run expensive computations, manage state shared across multiple commands, communicate with databases, and spawn long‑running tasks—all without blocking the UI thread in the webview.
A frontend React component never opens a SQLite file directly. It calls invoke('get_notes') and receives an array. The Rust command opens the database, runs the query, and returns the rows. The frontend does not need to know the database path or even that SQLite is the storage engine. This separation keeps the frontend portable and the backend replaceable.
The architecture also means that performance‑critical code lives in Rust, where you have precise control over memory and CPU. A fuzzy search over thousands of items, for instance, can run entirely in a Rust function and return the matches to JavaScript in a few microseconds.
Common Misconceptions and Pitfalls
“I can call any Rust function from the frontend.”
Only functions annotated with #[tauri::command] and registered in the invoke handler are reachable. The rest of your Rust code is invisible to the webview.
“invoke is synchronous.”
It returns a promise. Even if the Rust command runs synchronously, you must await it or use .then(). Trying to use the return value as if it were immediate is a frequent mistake.
“I can pass JavaScript objects with functions or DOM references.”
No. The payload is serialized to JSON. Functions, DOM nodes, and circular references cannot be sent. Only data that survives JSON.stringify will reach Rust.
“The command name in Rust can be different from the function name.”
By default they are the same. You can rename the command with #[tauri::command(name = "my_name")], but the string you pass to invoke must match whatever name the Rust side advertises. The function name itself is invisible to the frontend.
Two Communication Patterns: Commands and Events
Tauri provides two distinct mechanisms for Rust‑frontend communication, each for a different job.
- Commands: Request‑response. The frontend initiates, Rust responds with a single return value. Think of a function call.
- Events: Push‑based. Rust emits an event, and the frontend (or multiple listeners) receives it. Think of a notification stream.
Commands are typed, validated, and governed by the capability system. Events are more flexible—they carry any JSON payload, can be global or targeted to a specific webview, and are the right tool for progress updates, real‑time notifications, or data pushed from background tasks.
This document has focused on commands because they are the starting point for every Tauri app. The following sections in this chapter walk through creating your first command, passing data, returning data, error handling, and eventually the event system.