Tauri: Build Desktop Apps with Web Tech and Rust
Tauri: Build Desktop Apps with Web Tech and Rust
Tauri is a framework for building desktop applications using web technologies for the frontend and Rust for the backend. It produces binaries that are dramatically smaller and more memory-efficient than Electron, while providing stronger security guarantees through Rust's memory safety and a permission-based access control system.
The project exists because Electron -- while enormously successful -- bundles an entire Chromium browser and Node.js runtime into every application. A "Hello World" Electron app weighs 150+ MB. The same app built with Tauri weighs under 2 MB. That difference matters for distribution, updates, and the user's system resources.

Tauri vs Electron: The Real Differences
The size comparison gets the headlines, but the architectural differences go deeper than bundle size.
| Metric | Tauri | Electron |
|---|---|---|
| Hello World bundle | ~1.5 MB | ~150 MB |
| Memory usage (idle) | ~40 MB | ~150 MB |
| Backend language | Rust | JavaScript (Node.js) |
| Rendering engine | OS webview | Bundled Chromium |
| Security model | Permissions + CSP + IPC isolation | Node.js integration (opt-out) |
| Cross-platform | Windows, macOS, Linux (+ iOS/Android in v2) | Windows, macOS, Linux |
| Auto-updater | Built-in plugin | Built-in (electron-updater) |
| Native menus/tray | Yes | Yes |
Bundle size: Tauri uses the operating system's native webview instead of shipping Chromium. On Windows, that's WebView2 (Edge-based, pre-installed on Windows 10+). On macOS, it's WKWebView. On Linux, it's WebKitGTK. This eliminates the 100+ MB Chromium dependency entirely.
Memory: An idle Tauri app uses roughly 40 MB of RAM compared to Electron's 150 MB baseline. For apps that users leave running in the background (status bars, clipboard managers, notification tools), this difference is significant.
Security: Electron gives the renderer process access to Node.js APIs by default (though nodeIntegration is off by default in recent versions). Tauri takes the opposite approach: the frontend has no direct access to system APIs. Every capability -- file system access, shell commands, HTTP requests -- must be explicitly granted through a permissions configuration. If the webview is compromised through an XSS attack, the blast radius is contained.
The tradeoff: Tauri requires you to write backend logic in Rust. If your team doesn't know Rust, that's a real barrier. Rust's learning curve is steep, and the borrow checker will fight you until you internalize ownership patterns. For teams that already know Rust, or are willing to learn, the payoff in performance and security is substantial.
Architecture: How Tauri Works
Tauri's architecture has four layers that work together:
TAO (The Application Object) handles window creation and management. It's a cross-platform windowing library written in Rust that abstracts over Win32, Cocoa, and X11/Wayland. TAO manages the application lifecycle, system tray, menus, global shortcuts, and window events.
WRY (WebView Rendering librarY) sits on top of TAO and provides a unified API for rendering web content using the OS-native webview. WRY handles the webview creation, JavaScript evaluation, IPC message passing, and custom protocol handling.
Tauri Core ties TAO and WRY together with application-level features: the command system (IPC), plugin architecture, configuration management, event system, and build tooling.
Your Application is the fourth layer -- your frontend code running in the webview, communicating with your Rust backend code through Tauri's IPC bridge.
The IPC bridge is the critical piece. Your frontend JavaScript calls invoke() to execute Rust functions. Your Rust backend can emit() events that the frontend listens for. This is a message-passing architecture, not shared memory -- the frontend and backend are isolated from each other by design.
Getting Started
Tauri provides create-tauri-app as the quickest way to scaffold a project. You need Rust installed (via rustup) and the platform-specific dependencies for your OS.
# Install Rust if you haven't already
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Create a new Tauri project
cargo install create-tauri-app
cargo create-tauri-app my-app
The scaffolding tool asks which frontend framework you want. It supports React, Vue, Svelte, SolidJS, Angular, vanilla JavaScript, and others. Pick whichever you're comfortable with -- Tauri is frontend-agnostic.
For an existing web project, you can add Tauri to it:
cd my-existing-web-app
cargo install tauri-cli
cargo tauri init
This creates a src-tauri/ directory inside your project containing the Rust backend code and Tauri configuration.
Project Structure
A typical Tauri project looks like this:
my-app/
├── src/ # Frontend code (React, Vue, etc.)
│ ├── App.tsx
│ ├── main.tsx
│ └── ...
├── src-tauri/ # Rust backend
│ ├── src/
│ │ ├── main.rs # Entry point
│ │ └── lib.rs # Command handlers
│ ├── Cargo.toml # Rust dependencies
│ ├── tauri.conf.json # Tauri configuration
│ ├── capabilities/ # Permission definitions
│ └── icons/ # App icons
├── package.json
└── index.html
The tauri.conf.json file is the central configuration. Here's a representative example:
{
"productName": "My App",
"version": "1.0.0",
"identifier": "com.mycompany.myapp",
"build": {
"beforeDevCommand": "bun run dev",
"devUrl": "http://localhost:5173",
"beforeBuildCommand": "bun run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "My App",
"width": 1024,
"height": 768,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'"
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
The build section tells Tauri how to start and build your frontend. During development, beforeDevCommand starts your dev server and Tauri opens a webview pointing at devUrl. For production builds, beforeBuildCommand generates your static assets into frontendDist.
Inter-Process Communication: Commands and Events
This is where Tauri gets interesting. The IPC system has two mechanisms: commands (request-response, frontend initiates) and events (pub-sub, either side can emit).
Commands
Commands are Rust functions that the frontend can call. Define them with the #[tauri::command] attribute:
// src-tauri/src/lib.rs
use tauri::Manager;
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! Greetings from Rust.", name)
}
#[tauri::command]
async fn read_file(path: String) -> Result<String, String> {
std::fs::read_to_string(&path)
.map_err(|e| format!("Failed to read file: {}", e))
}
#[tauri::command]
async fn save_settings(
app: tauri::AppHandle,
settings: serde_json::Value,
) -> Result<(), String> {
let config_dir = app.path().app_config_dir()
.map_err(|e| e.to_string())?;
std::fs::create_dir_all(&config_dir)
.map_err(|e| e.to_string())?;
let path = config_dir.join("settings.json");
std::fs::write(&path, settings.to_string())
.map_err(|e| e.to_string())
}
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
greet,
read_file,
save_settings,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Commands automatically serialize/deserialize arguments and return values using serde. You can accept strings, numbers, structs, enums -- anything that implements Serialize/Deserialize. Return Result<T, String> to handle errors gracefully.
On the frontend, call commands with invoke():
import { invoke } from "@tauri-apps/api/core";
// Simple command
const greeting = await invoke<string>("greet", { name: "World" });
console.log(greeting); // "Hello, World! Greetings from Rust."
// Command with error handling
try {
const contents = await invoke<string>("read_file", {
path: "/etc/hostname",
});
console.log(contents);
} catch (error) {
console.error("Failed to read file:", error);
}
// Complex arguments
await invoke("save_settings", {
settings: { theme: "dark", fontSize: 14 },
});
The argument names must match between Rust and TypeScript. The invoke generic parameter (<string>) types the return value on the TypeScript side.
Events
Events are fire-and-forget messages that either side can emit and listen for:
// Emit from Rust to frontend
use tauri::Emitter;
#[tauri::command]
async fn start_processing(app: tauri::AppHandle) -> Result<(), String> {
for i in 0..100 {
app.emit("progress", i).map_err(|e| e.to_string())?;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
app.emit("complete", "Done!").map_err(|e| e.to_string())?;
Ok(())
}
import { listen } from "@tauri-apps/api/event";
// Listen for events from Rust
const unlisten = await listen<number>("progress", (event) => {
console.log(`Progress: ${event.payload}%`);
updateProgressBar(event.payload);
});
await listen<string>("complete", (event) => {
console.log(event.payload);
});
// Clean up listener when done
unlisten();
Events are useful for long-running operations where you want to stream progress updates back to the UI without blocking.
Plugins
Tauri v2 has a modular plugin system. Core features that used to be built-in are now opt-in plugins, keeping the base framework lean. Some key official plugins:
- @tauri-apps/plugin-fs: File system access (read, write, watch)
- @tauri-apps/plugin-dialog: Native file/folder/save dialogs
- @tauri-apps/plugin-shell: Execute shell commands and open URLs
- @tauri-apps/plugin-http: HTTP client (fetch alternative with no CORS restrictions)
- @tauri-apps/plugin-notification: Desktop notifications
- @tauri-apps/plugin-clipboard-manager: Clipboard read/write
- @tauri-apps/plugin-store: Persistent key-value storage
- @tauri-apps/plugin-updater: Auto-update support
- @tauri-apps/plugin-sql: SQLite, MySQL, PostgreSQL access
Install a plugin from both the Rust and JavaScript sides:
# Add the Rust dependency
cd src-tauri
cargo add tauri-plugin-fs
# Add the JS bindings
cd ..
bun add @tauri-apps/plugin-fs
Register it in your Rust code:
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.invoke_handler(tauri::generate_handler![...])
.run(tauri::generate_context!())
Then use it from your frontend:
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
const content = await readTextFile("config.toml");
await writeTextFile("output.txt", "Hello from Tauri");
Each plugin has its own permission scope. You declare which paths, URLs, or operations the plugin is allowed to access in the capabilities/ directory, following the principle of least privilege.
Auto-Updater
Tauri's updater plugin supports delta updates and multiple distribution channels. Configure it in tauri.conf.json:
{
"plugins": {
"updater": {
"endpoints": [
"https://releases.myapp.com/{{target}}/{{arch}}/{{current_version}}"
],
"pubkey": "YOUR_PUBLIC_KEY_HERE"
}
}
}
The updater signs updates with a keypair to prevent supply-chain attacks. Generate keys with cargo tauri signer generate. The endpoint returns a JSON manifest describing available updates, and Tauri handles downloading, verifying, and applying them.
Tauri v2: Mobile Support and Enhanced Security
Tauri v2 (stable since October 2024) introduced two major additions:
Mobile support: Tauri v2 can build for iOS and Android in addition to desktop platforms. The mobile backend uses the same Rust core, with platform-specific webview implementations (WKWebView on iOS, Android WebView on Android). Your frontend code works identically across desktop and mobile. The Rust backend is compiled for ARM and runs natively.
# Initialize mobile targets
cargo tauri android init
cargo tauri ios init
# Run on Android emulator
cargo tauri android dev
# Build for iOS
cargo tauri ios build
Capabilities-based permissions: Tauri v2 replaced the v1 allowlist with a more granular permissions system. Instead of a flat list of allowed APIs, you define capabilities -- named permission sets that can be scoped to specific windows, URLs, or paths. This lets you give a settings window different permissions than your main window.
{
"identifier": "main-window",
"description": "Permissions for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"fs:allow-read-text-file",
"fs:allow-write-text-file",
{
"identifier": "fs:scope",
"allow": [{ "path": "$APPDATA/**" }]
},
"dialog:allow-open",
"dialog:allow-save"
]
}
This is a meaningful security improvement. If an attacker finds an XSS vulnerability in your app, they can only access the capabilities you've explicitly declared, and only for the specific scopes you've allowed.
Building and Distributing
Build production binaries with:
cargo tauri build
This produces platform-specific installers:
- Windows:
.msiand.exe(NSIS) installers - macOS:
.dmgand.appbundles - Linux:
.deb,.rpm, and.AppImagepackages
For CI/CD, Tauri provides a GitHub Action (tauri-apps/tauri-action) that builds for all three desktop platforms in parallel and creates a GitHub Release with the artifacts:
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: v__VERSION__
releaseName: "App v__VERSION__"
releaseBody: "See the changelog for details."
Cross-compilation is limited. You generally need to build on the target OS (or use CI runners for each platform). The Tauri team recommends GitHub Actions with matrix builds for macOS, Windows, and Linux runners.
Real-World Apps Built with Tauri
Several production applications have adopted Tauri, demonstrating its viability beyond toy projects:
- Cody (by Sourcegraph): AI coding assistant desktop app
- Spacedrive: Cross-platform file manager with a Rust-powered virtual distributed filesystem
- Padloc: Password manager that benefits from Tauri's security model
- Pake: Tool to turn any website into a desktop app using Tauri (bundles under 5 MB)
- GitButler: Git client with a focus on virtual branches and modern workflows
- Ollama Desktop: UI for running local LLMs
- CrabNebula Cloud: Distribution platform built by Tauri's maintainers
These range from simple wrapper apps (Pake) to complex applications with heavy Rust backends (Spacedrive, GitButler). The common thread is that each benefits from small bundle sizes, low memory usage, or the security properties of the Rust backend.
When to Choose Tauri Over Electron
Choose Tauri when:
- Bundle size matters (users on slow connections, frequent updates)
- Memory efficiency matters (system tray apps, always-running tools)
- Security is a primary concern (password managers, financial tools)
- You want or need a Rust backend (CPU-intensive work, system-level access)
- You're targeting mobile alongside desktop
Stick with Electron when:
- Your team doesn't know Rust and doesn't want to learn
- You need guaranteed cross-platform rendering consistency (Chromium gives you that; OS webviews have minor rendering differences)
- You depend heavily on Node.js-specific libraries in the backend
- You need the mature Electron ecosystem of tools, testing frameworks, and community knowledge
The ecosystem gap is real but narrowing. Electron has a decade of community plugins, Stack Overflow answers, and battle-tested patterns. Tauri's ecosystem is growing fast but is smaller. If you run into a problem with Electron, someone has already solved it. With Tauri, you might be the first.
Getting Deeper
The official documentation at tauri.app is well-maintained and comprehensive. The Tauri Discord server is active and the core team is responsive to questions. For Rust beginners, the Rust Book is the standard starting point -- you don't need to be a Rust expert to use Tauri, but you need to understand ownership, error handling with Result, and basic async patterns.
Start with the frontend you know, add commands as you need them, and let the Rust backend grow organically. Most Tauri apps have modest Rust code -- the real complexity lives in the frontend, which is exactly the code you already know how to write.