Rust 89 🦀 Desktop Web Hybrid Applications (Tauri)
Tauri is not Electron with a Rust backend bolted on. It is an application toolkit that composes a Rust core process with the operating system’s native WebView, and the architectural difference determines the binary size, the memory footprint, and the security model. The core process is a compiled Rust binary that has full access to the operating system; the WebView is a browser-like environment that renders HTML, CSS, and JavaScript using the platform’s built-in engine—WebView2 on Windows, WKWebView on macOS, and WebKitGTK on Linux . The WebView libraries are dynamically linked at runtime, not bundled into the executable, which is why a minimal Tauri application can be under 600KB .
The communication model is message passing through a command system. Rust functions annotated with #[tauri::command] are exposed to the frontend, and the JavaScript side invokes them with invoke(), which returns a promise that resolves with the command’s return value or rejects with its error . The frontend can be any framework that compiles to static HTML, CSS, and JavaScript—Svelte, React, Vue, or plain JavaScript—because Tauri treats the frontend as a static asset bundle that is compiled first and then embedded into the Rust binary . The architecture is a multi-process model: the core process orchestrates windows, system tray, and IPC routing, while the WebView processes render the UI and are isolated from the operating system by the core’s permission system .
This chapter covers the process model and the core-versus-WebView split, the command system and its serialization boundary, the capabilities-based security model that replaces the v1 allowlist, the performance and size characteristics relative to Electron, and the platform-specific WebView differences that affect rendering consistency. It assumes familiarity with Rust and basic web frontend concepts.
Key point: Tauri is two processes and a message channel. The Rust core owns the operating system access; the WebView owns the UI and has no direct access to the system. Commands are the bridge, and they serialize through JSON by default. The capabilities system is the security boundary: nothing is allowed unless a capability file explicitly grants it to a specific window or WebView.
Why Tauri uses the system WebView instead of bundling a browser
The binary size difference is an order of magnitude. Electron bundles a full Chromium browser and a Node.js runtime with every application, producing installers in the 80–150 MB range before any application code is added. Tauri uses the WebView that is already present on the operating system, so the application ships only its own code and a Rust binary. A typical Tauri application is 2.5–10 MB, and a minimal one can be under 600 KB . The academic comparison between Electron and Tauri measured the Tauri installer as approximately 99% smaller than the equivalent Electron installer .
The memory footprint follows the same pattern. Electron’s per-window architecture runs a separate Chromium renderer process for each window, each consuming 50–100 MB. A single-window Electron application typically uses 150–300 MB at idle. A comparable Tauri application uses 30–40 MB, because it shares the operating system’s WebView rather than running a dedicated browser instance . The measured difference in the academic study was up to ten times lower RAM usage during idle operation .
The trade-off is rendering consistency. Electron bundles Chromium, so the application renders identically on every platform. Tauri uses the system WebView, which means the rendering engine differs: WebKit on macOS, Chromium-based WebView2 on Windows, and WebKitGTK on Linux . A CSS feature that works in WebView2 may not work in WebKitGTK, and a JavaScript API that is available in one engine may be missing in another. The application must be tested on every target platform, and bleeding-edge web features may need polyfills or fallbacks. For pixel-perfect cross-platform UIs, this is the most significant cost of the Tauri architecture.
The Rust core is the security boundary. The core process is the only component with full operating system access. It creates windows, manages the system tray, and routes all IPC messages. The WebView processes have no direct system access; they can only call the commands that the core exposes, and those commands are gated by the capabilities system . This is the Principle of Least Privilege applied to a desktop application: the frontend gets only the permissions it needs to do its job, and the core keeps the rest.
a. The command system: calling Rust from JavaScript
A command is an async or synchronous Rust function annotated with #[tauri::command]. It is registered with the application builder through invoke_handler(tauri::generate_handler![...]), and from that point it is callable from the frontend by name .
// src-tauri/src/lib.rs
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
// Frontend
import { invoke } from '@tauri-apps/api/core';
const message = await invoke('greet', { name: 'Rohan' });
console.log(message); // "Hello, Rohan!"
The serialization boundary is JSON. Arguments passed from JavaScript are deserialized into the Rust function’s parameters, and the return value is serialized back to JSON. For arguments to work, their types must implement serde::Deserialize; for return values, serde::Serialize . This means that returning a large file or an HTTP response as a JSON-serialized value will slow the application down. For binary data, Tauri provides tauri::ipc::Response, which sends an ArrayBuffer without JSON serialization .
Errors are handled through Result. A command that returns Result<T, E> produces a resolved promise on Ok and a rejected promise on Err. The error type must implement serde::Serialize, which most standard library error types do not. The idiomatic solution is a custom error enum with thiserror and a manual Serialize implementation, or map_err to convert the error to a string for simple cases .
Async commands are the default for heavy work. A synchronous command runs on the main thread and blocks the UI; an async command runs on a separate thread and keeps the frontend responsive. The one constraint is that async commands cannot use borrowed types like &str across an await point; the parameter must be an owned type like String, or the return type must be wrapped in a Result .
b. The capabilities system: explicit permission grants
Tauri v2 replaced the v1 allowlist with a capabilities-based security model. The allowlist was a single list in tauri.conf.json that enabled or disabled API categories for the entire application. The capabilities system is granular: a capability file in src-tauri/capabilities/ grants a set of permissions to a set of windows or WebViews, and nothing is allowed by default .
A capability file is JSON or TOML. It has an identifier, an optional description, a list of windows or WebViews it applies to, a list of permissions, and an optional list of platforms .
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-capability",
"description": "Permissions for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"fs:allow-read-file",
"dialog:allow-ask"
]
}
The permissions are namespaced by plugin: fs:allow-read-file for the filesystem plugin, dialog:allow-ask for the dialog plugin, core:default for the core window and event operations . A permission can be scoped to restrict what it can access. fs:allow-read-file with a scope of { "path": "$APPDATA/*" } allows reading files only under the application’s data directory, not the entire filesystem .
The capability model is per-window. A window that only needs to show a dialog does not need filesystem access, and a window that only needs to read a configuration file does not need network access. The windows field in the capability file is the mechanism for this isolation: the capability applies only to the named windows . The capabilities can also be platform-specific, using the platforms field to enable a capability only on linux, windows, macos, android, or ios .
The security model has a documented scope. It protects against frontend compromise, accidental exposure of local system interfaces, and privilege escalation from the frontend to the backend. It does not protect against malicious or insecure Rust code, overly lax scopes and configuration, incorrect scope checks in the command implementation, or zero-days in the system WebView . The capability system is a boundary between the frontend and the Rust core, not a sandbox for the Rust core itself.
Complete Example Session
// ============================================
// PART 1: PROJECT STRUCTURE
// ============================================
// src-tauri/
// Cargo.toml
// tauri.conf.json
// capabilities/
// main.json
// src/
// lib.rs
// main.rs
// Frontend at project root (Vite, Svelte, React, etc.)
// ============================================
// PART 2: A SIMPLE COMMAND
// ============================================
// src-tauri/src/lib.rs
use tauri::command;
#[command]
fn add(a: i32, b: i32) -> i32 {
a + b
}
// ============================================
// PART 3: REGISTERING COMMANDS
// ============================================
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![add])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
// ============================================
// PART 4: ASYNC COMMAND WITH ERROR
// ============================================
use std::fs;
use serde::Serialize;
#[derive(Debug, thiserror::Error)]
enum AppError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
impl Serialize for AppError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where S: serde::Serializer {
serializer.serialize_str(self.to_string().as_ref())
}
}
#[command]
async fn read_config(path: String) -> Result<String, AppError> {
let content = fs::read_to_string(&path)?;
Ok(content)
}
// ============================================
// PART 5: FRONTEND INVOCATION
// ============================================
// import { invoke } from '@tauri-apps/api/core';
//
// const sum = await invoke<number>('add', { a: 2, b: 3 });
// console.log(sum); // 5
//
// try {
// const config = await invoke<string>('read_config', { path: '/etc/app/config.toml' });
// } catch (e) {
// console.error('Failed:', e);
// }
// ============================================
// PART 6: CAPABILITY FILE
// ============================================
// src-tauri/capabilities/main.json
// {
// "identifier": "main",
// "windows": ["main"],
// "permissions": [
// "core:default",
// "fs:allow-read-file"
// ]
// }
// ============================================
// PART 7: SCOPED FILESYSTEM PERMISSION
// ============================================
// {
// "identifier": "read-appdata",
// "windows": ["main"],
// "permissions": [
// {
// "identifier": "fs:allow-read-file",
// "allow": [{ "path": "$APPDATA/*" }]
// }
// ]
// }
// ============================================
// PART 8: BINARY DATA WITH IPC RESPONSE
// ============================================
use tauri::ipc::Response;
#[command]
fn read_binary(path: String) -> Result<Response, String> {
let data = fs::read(&path).map_err(|e| e.to_string())?;
Ok(Response::new(data))
}
// Frontend receives ArrayBuffer, not JSON.
// ============================================
// PART 9: WINDOW-SPECIFIC CAPABILITIES
// ============================================
// Window "main" gets fs + dialog.
// Window "preview" gets only core:default.
// A compromised preview cannot read files.
// ============================================
// PART 10: THE COMPLETE FLOW
// ============================================
// 1. Rust core starts, creates windows, routes IPC.
// 2. WebView loads frontend static assets.
// 3. Frontend calls invoke('command', args).
// 4. Core checks capability for the calling window.
// 5. Command runs in Rust, returns Result.
// 6. JSON serialized back to frontend promise.
// 7. Binary data uses ipc::Response (no JSON).
Quick Reference
Architecture Layers
| Layer | Responsibility | Language |
|---|---|---|
| Core process | Windows, tray, IPC routing, state | Rust |
| WebView process | UI rendering, frontend logic | HTML/CSS/JS |
| Bridge | Commands, events | JSON (or binary) |
| Security | Capabilities, permissions, scopes | JSON/TOML |
Command System
| Aspect | Detail |
|---|---|
| Annotation | #[tauri::command] |
| Registration | invoke_handler(generate_handler![...]) |
| Arguments | serde::Deserialize, camelCase JSON |
| Return | serde::Serialize, JSON |
| Errors | Result<T, E>, E: Serialize |
| Binary | tauri::ipc::Response |
| Async | async fn, owned types |
Capabilities
| Concept | Purpose |
|---|---|
| Capability | Named permission set for windows/WebViews |
| Permission | Access to a specific command or feature |
| Scope | Constraint on what a permission can access |
| Default | Nothing allowed; explicit grants only |
Performance (Typical)
| Metric | Tauri | Electron |
|---|---|---|
| Bundle size | 2.5–10 MB | 80–150 MB |
| Idle memory | 30–40 MB | 150–300 MB |
| Startup | <1 s | 1–3 s |
| Rendering | System WebView (varies) | Chromium (consistent) |
Best Practices
✅ Do This:
// Use async commands for I/O-bound work. // ✅
#[command]
async fn fetch(url: String) -> Result<String, String> { ... }
// Return Result for fallible commands. // ✅
#[command]
fn read(path: String) -> Result<String, AppError> { ... }
// Use ipc::Response for binary data. // ✅
#[command]
fn read_bin(path: String) -> Result<Response, String> { ... }
// Scope filesystem permissions to app directories. // ✅
{ "identifier": "fs:allow-read-file", "allow": [{ "path": "$APPDATA/*" }] }
// Isolate capabilities per window. // ✅
"windows": ["main"]
❌ Don’t Do This:
// Block the main thread with synchronous I/O. // ❌
#[command]
fn read(path: String) -> String { fs::read_to_string(path).unwrap() }
// Return a large file as JSON-serialized String. // ❌
// Use ipc::Response for binary.
// Grant full filesystem access to every window. // ❌
"fs:default" // Overly broad.
// Assume the WebView renders identically everywhere. // ❌
// Test on WebKit, WebView2, and WebKitGTK.
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Command not found | Not registered in invoke_handler | Add to generate_handler![] |
| Serialization error | Error type lacks Serialize | Use thiserror + manual Serialize |
| UI freezes | Sync command with heavy work | Make command async |
| Permission denied | Capability not granted | Add permission to capability file |
| Linux rendering differs | WebKitGTK feature gap | Test on Linux, polyfill |
| Windows first-install large | WebView2 not preinstalled | Accept runtime dependency |
Real-World Examples
1. Basic Command
#[tauri::command]
fn add(a: i32, b: i32) -> i32 { a + b }
2. Async Command
#[tauri::command]
async fn fetch(url: String) -> Result<String, String> { ... }
3. Register Commands
.invoke_handler(tauri::generate_handler![add, fetch])
4. Frontend Invoke
const result = await invoke('add', { a: 2, b: 3 });
5. Capability File
{ "identifier": "main", "windows": ["main"], "permissions": ["core:default"] }
6. Scoped Permission
{ "identifier": "fs:allow-read-file", "allow": [{ "path": "$APPDATA/*" }] }
7. Binary Response
#[tauri::command]
fn read_bin(path: String) -> Result<Response, String> { ... }
8. Custom Error
#[derive(Debug, thiserror::Error)]
enum AppError { #[error("IO: {0}")] Io(#[from] std::io::Error) }
9. Platform Capability
{ "identifier": "desktop", "platforms": ["linux", "macos", "windows"] }
10. Window Isolation
{ "windows": ["settings"], "permissions": ["dialog:allow-ask"] }
Visual
Process Model
┌──────────────────────────────────────────────┐
│ CORE PROCESS (Rust) │
│ - Full OS access │
│ - Creates windows │
│ - Routes IPC │
│ - Manages state │
│ │ │
│ ├── WebView (window "main") │
│ │ UI, no direct OS access │
│ │ Calls commands via invoke() │
│ │ │
│ └── WebView (window "preview") │
│ UI, restricted capabilities │
│ Cannot read files │
│ │
│ Capabilities gate what each WebView can call│
│ │
└──────────────────────────────────────────────┘
Command Flow
┌──────────────────────────────────────────────┐
│ FRONTEND │
│ invoke('read_config', { path: '...' }) │
│ │ │
│ ▼ │
│ IPC message routed to core │
│ │ │
│ ▼ │
│ Capability check for calling window │
│ │ │
│ ├── Denied → promise rejects │
│ │ │
│ ▼ │
│ Command runs in Rust │
│ │ │
│ ▼ │
│ Result serialized to JSON │
│ │ │
│ ▼ │
│ Promise resolves/rejects in frontend │
│ │
└──────────────────────────────────────────────┘
Size Comparison
┌──────────────────────────────────────────────┐
│ INSTALLER SIZE │
│ │
│ Electron: 80–150 MB (bundles Chromium) │
│ Tauri: 2.5–10 MB (system WebView) │
│ │
│ Ratio: ~20× smaller │
│ Academic measurement: ~99% smaller │
│ │
│ Windows caveat: │
│ WebView2 preinstalled on Win 10 1803+ / 11 │
│ Older systems: runtime install adds ~150 MB │
│ │
└──────────────────────────────────────────────┘
Security Boundary
┌──────────────────────────────────────────────┐
│ CAPABILITIES │
│ │
│ Window "main": │
│ core:default │
│ fs:allow-read-file (scoped $APPDATA) │
│ dialog:allow-ask │
│ │
│ Window "settings": │
│ core:default │
│ dialog:allow-ask │
│ │
│ Window "preview": │
│ core:default │
│ (no filesystem, no dialogs) │
│ │
│ Default: nothing allowed. │
│ Explicit grants only. │
│ │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Architecture | Rust core process + system WebView |
| WebView engines | WebView2 (Win), WKWebView (macOS), WebKitGTK (Linux) |
| Command annotation | #[tauri::command] |
| Command registration | invoke_handler(generate_handler![...]) |
| Serialization | JSON by default; ipc::Response for binary |
| Error handling | Result<T, E>, E: Serialize |
| Security model | Capabilities, permissions, scopes |
| Default permission | Nothing allowed |
| Bundle size | 2.5–10 MB typical |
| Idle memory | 30–40 MB typical |
| Rendering consistency | Varies by OS WebView |
Key takeaways:
- Tauri is a Rust core plus the system WebView, not a bundled browser. The core owns the operating system access, the WebView owns the UI, and the command system bridges them. This is why the binary is an order of magnitude smaller than Electron’s and why the memory footprint is lower .
- The command system is the IPC bridge. Rust functions annotated with
#[tauri::command]are callable from JavaScript viainvoke(). Arguments and return values serialize through JSON, and errors areResulttypes that must implementSerialize. - The capabilities system is the security boundary. Nothing is allowed unless a capability file grants it to a specific window or WebView. Permissions can be scoped to specific paths or URLs, and capabilities can be platform-specific .
- The system WebView introduces rendering inconsistency. WebView2 is Chromium-based, WKWebView is WebKit, and WebKitGTK can lag behind. CSS and JavaScript features must be tested on every target platform .
- The size and memory advantages are measured, not theoretical. The academic comparison measured a ~99% smaller installer and up to ten times lower idle RAM usage versus Electron . The caveat is Windows: older systems without WebView2 preinstalled pay a one-time runtime installation cost .
- The Rust core is not a sandbox. The capability system protects the frontend from the system and limits what a compromised frontend can do, but it does not protect against malicious Rust code, overly lax configuration, or zero-days in the WebView itself .
Remember: Tauri is a composition framework, not a browser replacement. The Rust core process is the application; the WebView is the rendering surface; the command system is the channel between them. The capabilities system gates the channel, and the system WebView determines the rendering behavior. The result is an application that is far smaller and lighter than an Electron equivalent, at the cost of platform-specific rendering differences and a Rust backend that must expose every system operation as a command. The trade-off is deliberate: less bundled code, more platform integration, and a security model that starts from “deny” and requires explicit grants. For applications where distribution size matters and the team can accept the Rust learning curve, Tauri is the modern answer.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!