Choosing the Right Tauri IPC Mechanism: Commands, Events, or Custom Protocols
A decision guide for Tauri developers choosing between commands, events, or a custom protocol to expose Rust backend functionality, covering security, payload size, and streaming trade‑offs with concrete code examples.
17 Sept 2025, 23:53 UTC

Problem
When a Tauri application needs to expose Rust backend functionality to the webview, developers must pick an inter‑process communication (IPC) mechanism. The choice affects security surface, payload handling, and whether the frontend can receive push updates or stream large binaries. Tauri v2 replaces the v1 allow‑list with a capability/permission model, so missing permissions surface as runtime errors rather than compile‑time failures.
Decision Constraints
- Security – Prefer mechanisms that are permission‑gated and typed.
- Payload size – JSON serialization over commands or events blocks the main thread for large payloads.
- Streaming / push – Backend‑initiated updates or binary streams need a different path than request/response.
Supported Options at a Glance
| Mechanism | Typical Use‑Case | Return Value | Permission Model | Payload Handling | Complexity |
|---|---|---|---|---|---|
| Commands | Synchronous CRUD, typed request/response | Yes (Result) | Capability‑gated, per‑command | JSON (or custom serializer) – full payload in memory | Low |
| Events | Backend push, progress, long‑lived subscriptions | No | Capability‑gated, per‑event | JSON – same limits as commands | Low |
| Custom Protocol | Streaming large binaries, virtual files loaded via URL | No (handled by webview loader) | Manual origin/path validation required | Raw bytes – can stream without full JSON parse | High |
Trade‑offs
Commands
Commands are the default for request/response flows. They are async Rust functions annotated with #[tauri::command], registered via invoke_handler, and invoked from JavaScript with await invoke('cmd_name', { … }). The capability file must list the command under permissions. Because they are typed, the compiler catches signature mismatches. However, every call serializes the entire argument and return value as JSON, which can block the event loop for payloads > 1 MiB.
Events
Events are fire‑and‑forget broadcasts. Rust emits with app.emit("event_name", payload) and the frontend subscribes via listen("event_name", handler). They are ideal for progress bars, log streaming, or notifications. The downside is no return channel; the frontend cannot acknowledge receipt. Unthrottled emission can flood the webview, so consider a back‑pressure strategy (e.g., batching or a bounded channel).
Custom Protocol
A custom protocol registers a scheme (e.g., tauri://) that the webview treats like a normal URL. The handler receives a raw http::Request and must return a http::Response with a body stream. This bypasses Tauri’s JSON IPC entirely, enabling true streaming of large files or generated assets. The cost is manual security: you must validate the request origin, sanitize paths to prevent directory traversal, and set a strict Content‑Security‑Policy (CSP) for the custom scheme.
Concrete Implementation – Commands & Events
Rust side (src-tauri/src/main.rs)
use tauri::{command, AppHandle, Emitter, Manager, Runtime};#[command] async fn save_note(app: AppHandle, note: Note) -> Result<(), String> { // business logic Ok(()) }#[command] async fn start_long_task(app: AppHandle) -> Result<(), String> { // spawn background work tauri::async_runtime::spawn(async move { for i in 0..=100 { app.emit("progress", i).expect("emit failed"); tokio::time::sleep(std::time::Duration::from_millis(50)).await; } }); Ok(()) }fn main() { tauri::Builder::default() .invoke_handler(tauri::generate_handler![save_note, start_long_task]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }Capability file (src-tauri/capabilities/default.json)
{ "identifier": "default", "description": "Core app capabilities", "windows": ["main"], "permissions": [ "core:default", "core:event:allow-listen", "core:event:allow-emit", "core:command:allow-save_note", "core:command:allow-start_long_task" ]}Frontend side (src/main.ts)
import { invoke, listen } from '@tauri-apps/api/core';async function save() { try { await invoke('save_note', { note: { title: 'Test', body: 'Hello' } }); console.log('saved'); } catch (e) { console.error('save failed', e); }}async function run() { const unlisten = await listen('progress', (e) => { console.log('progress:', e.payload); }); await invoke('start_long_task'); // later: unlisten(); }Validation Checklist
- Open
src-tauri/tauri.conf.jsonand thecapabilities/directory – confirm each command and event appears underpermissions. - Run
cargo clippyandcargo testto verify backend logic compiles and passes unit tests without the webview. - Start the app in dev mode:
cargo tauri dev. Watch the Rust console forpermission deniederrors and the browser DevTools console forinvokerejections. - Exercise the UI: call
save()and verify the note persists; callrun()and confirm progress events arrive in order.
Limitations & Gotchas
- Global name collisions – Command and event names share a global namespace across plugins. Prefix with a feature name (e.g.,
notes:save_note) to avoid silent drops. - Large payloads – JSON serialization runs on the main thread. For > 1 MiB consider chunked uploads via a custom protocol or a temporary file + command that receives a path.
- Custom protocol security – If you adopt a custom scheme, you must implement origin checks (
request.headers().get("origin")) and path sanitization (std::path::Path::new(&path).canonicalize()) yourself; Tauri does not enforce CSP for custom schemes. - Permission errors are runtime – Missing entries in the capability file produce a
PermissionDeniederror only when the command/event is invoked.
Diagram Labels
- Command: frontend → invoke → Rust → JSON response
- Event: Rust task → emit → frontend listener
- Custom protocol: webview URL → handler → stream
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.