Skip to content
Select theme

Understanding the Tauri architecture

Step 3 of 13, and it edits nothing. Step 2 left you with a window on screen and a dev log that already told the story: BeforeDevCommand started Vite, DevCommand ran cargo run, and the window loaded the front end from localhost:1420. Two processes, one window. This article names the three layers behind that output, walks one call across the boundary between them, and answers the question every Tauri newcomer asks next — why your app doesn’t ship a browser engine, and what Electron does differently.

The whole architecture fits in the window you already ran:

┌─ rustnote ───────────────────────────────────────────────────┐
│ │
│ src/ — the front end │
│ React components, rendered by the OS's webview │
│ (WKWebView on macOS, WebView2 on Windows, │
│ WebKitGTK on Linux) │
│ │
│ invoke("greet", { name }) ─────────────▶ │
│ ◀───────────── the promise resolves │
│ │
│ src-tauri/ — the Rust core │
│ tauri::Builder routes "greet" to the handler │
│ registered in generate_handler![greet]; owns files, │
│ SQLite, menus — everything that needs to be native │
│ │
└──────────────────────────────────────────────────────────────┘

The window itself is created by the Rust process — that is why closing it ends npm run tauri dev, which step 2 showed. What fills the window is a webview: a component the operating system provides for rendering web content. Everything above the arrows is JavaScript; everything below is Rust; the arrows are the only road between them.

src/ is a Vite + React project with nothing Tauri-specific in it. index.html loads src/main.tsx, which renders src/App.tsx. Delete the one invoke import and you would have an ordinary web app that runs in any browser.

In dev, the webview loads it over HTTP from Vite — that is the devUrl: http://localhost:1420 line in tauri.conf.json, and the reason hot reload works in both languages at once. In a release build, npm run build bundles the same components to static files in ../dist (frontendDist), and the webview loads them straight off disk.

The rendering engine is not part of your app. Tauri uses whatever webview the platform ships: WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux. This is the single most important line in the whole architecture, and it cuts both ways. The upside: no engine in the download, no engine to update, no engine to keep alive in memory. The bill: the engine isn’t your choice either. Electron’s Chromium is identical on every OS; Tauri’s webview is not — on Linux you get whatever WebKitGTK the distribution provides, and its quirks are yours to work around. Both facts belong in your mental model from the start.

src-tauri/ is a real cargo crate, and it is the process. main.rs is three lines that call rustnote_lib::run(), and run() in lib.rs assembles the app with tauri::Builder: it registers plugins (the template has tauri-plugin-opener), registers commands, and calls .run(tauri::generate_context!()) to open the window.

Everything that needs the operating system lives on this side: files, SQLite, the menu bar, the system tray, global shortcuts. That is a design constraint, not a convention — the webview has no file-system access of its own, and the capabilities file you will meet below keeps it that way. Steps 5 through 11 add one piece of native work per step on this side.

The lib-plus-binary split looks odd until you see why: the same core can be linked as a static or dynamic library on mobile, and the #[cfg_attr(mobile, tauri::mobile_entry_point)] line in run() is the hook for it. This track stays desktop-only, but the scaffold is built for more.

The template page already makes the round trip. On the front side, in src/App.tsx:

import { invoke } from "@tauri-apps/api/core";
async function greet() {
setGreetMsg(await invoke("greet", { name }));
}

On the Rust side, in src-tauri/src/lib.rs:

#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

What happens when the form is submitted:

  1. invoke("greet", { name }) serialises the arguments to JSON and sends a message across the boundary, naming the command.
  2. The Rust side routes that name to a handler registered in generate_handler! — registration is what makes a command exist as far as the front end is concerned.
  3. The JSON arguments are deserialised into the handler’s parameter types, and the handler runs.
  4. The return value is serialised back, and await resolves. A command that fails rejects the promise instead — step 4 shows what an error needs to look like to cross cleanly.

The bridge is JSON in both directions, which is why serde sits in src-tauri/Cargo.toml before you have written a line: step 6 sends a Vec<Note> across this boundary, and it will cross as serialised data. One detail the template glosses over — how argument names convert between JavaScript’s camelCase and Rust’s snake_case — is also step 4 material.

One file in the scaffold looks like bureaucracy and is actually the security model: src-tauri/capabilities/default.json:

{
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}

Everything rendered in the webview is untrusted input by definition — it is HTML and JavaScript that came over the network or out of user content. So the webview can only do what a capability grants: this one applies to the main window and permits the default set of core commands plus the opener plugin. In Tauri 2 even built-in calls are gated this way. When step 9 adds the file-system and dialog plugins, the work is not done until their permissions appear in this file.

Electron solves the same problem — web UI, native work behind a message boundary — with different parts:

Electron Tauri
Renders the UI bundled Chromium the OS webview
Native work runs in a Node.js main process your Rust binary
Ships to users Chromium + Node, per app your code + the system webview
Cross-boundary call ipcRenderer.invoke via contextBridge invoke() to registered commands

In Electron you write the native side in JavaScript and the runtime is Node; the engine is downloaded with every app, which is why Electron installers are as large as they are and why an Electron app’s process list shows a Chromium helper for each window. In Tauri the native side is Rust — compiled, typed, and free to use the whole crates ecosystem — and the only process you wrote is one binary.

Neither design is free. Electron buys a uniform engine at the cost of size; Tauri buys the small download at the cost of a webview you don’t control, which is why the same rustnote build can render slightly differently on macOS, Windows, and Linux. Pick your trade-off knowingly.

Nothing changed: the project is still exactly the tauri-ch02 state in the examples repository, and no tag is added for this step. If you have been following along, your tree is current.

Step 4 writes the first command of your own: #[tauri::command] on a real function, invoke() from React with arguments, and the registration step everyone forgets — including the error the front end shows when you do. The track overview keeps the full list current as articles land.