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.
Three layers, one window
Section titled “Three layers, one window”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.
The front end is a plain web app
Section titled “The front end is a plain web app”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.
The Rust core owns everything native
Section titled “The Rust core owns everything native”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.
One call across the boundary
Section titled “One call across the boundary”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:
invoke("greet", { name })serialises the arguments to JSON and sends a message across the boundary, naming the command.- 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. - The JSON arguments are deserialised into the handler’s parameter types, and the handler runs.
- The return value is serialised back, and
awaitresolves. 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.
The webview is not trusted
Section titled “The webview is not trusted”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.
What Electron does instead
Section titled “What Electron does instead”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.
Where the code stands
Section titled “Where the code stands”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.