A quarter-million lines of Rust on Tauri 2.0: what shipping at this scale actually taught us

599 Rust files · 268,450 lines · 380 registered IPC commands · 5,676 tests · counts re-verified against the tree at publish

Most Tauri content is written at hello-world scale: a webview, a couple of #[tauri::command] functions, a 600 KB “look how small” binary. Useful, but it answers the wrong question. The question that decided our architecture was: what happens when the Rust side becomes the whole product?

4DA is a developer-intelligence engine that happens to have a window: 599 Rust files, 268,000 lines, 380 registered IPC commands, a SQLite database with vector search, an embedded scoring pipeline, and a background engine that runs with no UI at all. The webview is one consumer of that machine — not the machine. A year of building it this way taught us things the hello-world posts can’t. Here are the ones with scars attached.

1. Your backend is a real process. Treat it like one.

The best architectural decision we made was discovering — mid-project, under pressure — that a Tauri app doesn’t need a window to be a Tauri app.

Our pipeline (fetch twenty sources, embed, score against the user’s codebase) only borrows the AppHandle for best-effort UI events. When the GUI is closed, nothing refreshes the database — unless something else can run the pipeline. The fix is two lines we’ve never seen documented anywhere:

let mut context = crate::app_context();
context.config_mut().app.windows.clear();   // headless.rs

Clear the window list before building the app, and you get a genuine AppHandle — events, state, everything — with zero WebView2 instantiation. The same binary now has a second front door: fourda.exe --engine-once runs a full fetch-score-audit cycle from Task Scheduler, windowless, writing a signed receipt per run. One codebase, one binary, two process personalities.

The corollary cost us a real bug to learn: if two personalities share one SQLite database, your single-instance lock must become conditional. The GUI takes a file lock before opening the DB (preventing WAL corruption from two processes racing, app_setup.rs); the headless engine deliberately skips it and coexists with a running GUI over the same WAL file (headless.rs). WAL mode makes this safe; assuming it would have made it a corruption lottery.

2. At 380 commands, IPC is an API product

Nobody warns you what invoke() looks like at scale. We register 380 commands (460 defined; the delta is feature-gated and test-only). At that count, three things stop being optional:

A typed contract. Every frontend call goes through one generated layer (src/lib/commands.ts) — names, argument shapes, return types. Raw invoke("string", {...}) scattered through components is how you build a liar’s API.

Ghost detection. The deadliest IPC failure is silent: the frontend invokes a command no backend registered, gets a rejected promise, and a feature quietly dies. We keep a static registry of every handler (victauri_commands.rs) and a release-gate check that diffs every frontend invoke() against it. Ghost count must be zero to ship.

A casing convention you enforce, not remember. Tauri maps JS camelCase arguments onto Rust snake_case parameters. Across 380 commands, “remember the convention” fails — our worst single bug class was an argument-name mismatch that silently dropped user feedback writes, starving the learning pipeline that is the entire product. The fix wasn’t discipline; it was making the typed layer the only path.

3. Async Rust state wants a written constitution

With dozens of long-lived subsystems sharing state across an async runtime, we ended up doing something that felt bureaucratic and has since prevented every deadlock: a lock-order comment at the top of state.rs that is law:

LOCK ORDERING (acquire in this order to prevent deadlocks)
 1. SETTINGS_MANAGER  2. DATABASE  3. CONTEXT_ENGINE
 4. ACE_ENGINE        5. SOURCE_REGISTRY  6. ANALYSIS_STATE
CRITICAL: Never hold a MutexGuard<T> across an .await point.

That last line is the one that bites every Tauri team eventually: parking_lot::MutexGuard is not Send, so holding one across an await is a compile error in the lucky cases and a redesign in the rest. Our patterns that survived: clone-and-drop the guard before awaiting; Arc<AtomicBool> instead of a mutex for cross-async flags (our analysis-abort signal); OnceCell for lazy singletons. And log-stampede hygiene nobody warns you about: our “sqlite-vec verified” line printed 224 times per cold boot — once per connection callsite — until a one-shot atomic made verification once-per-process.

4. Windows-the-OS will fight your windows-the-feature

Three windows: the main app, plus two transparent, borderless, pre-created-hidden surfaces (a notification card and a morning briefing). Lessons, each paid for:

5. SQLite is enough — if you respect its sharp edges

One WAL-mode SQLite file is our entire persistence story: content, embeddings, full-text index, settings, signed engine receipts. At ~150 tables it is still boring in the best way. The sharp edges were all at the extension boundary:

6. Test through the glass, not at it

Webview-driver testing treats your app as pixels. We embedded the instrumentation inside the process instead: a debug-mode MCP server runs in the app itself, with direct access to the real AppHandle — invoke any registered command, query the live database, snapshot the actual DOM, and cross-check what the frontend renders against what the backend knows. Sub-millisecond, no driver flakiness, and it catches the class browser tools structurally can’t: frontend and backend disagreeing about the same fact. It started as our test harness and became an open-source project — Victauri — because every Tauri team hits this wall.

The small stuff that will bite you specifically

The honest verdict

The release binary is 56 MB — embedded models and a large feature surface; nobody should repeat the “Tauri means 600 KB” line at production scale. What survives honestly: a fraction of Electron’s memory budget, sub-second cold start, one language for the entire engine, and an OS webview that has been a smaller source of bugs than our own async code.

Tauri 2.0’s real promise at this scale isn’t the small binary. It’s that the backend is a first-class Rust process that happens to know how to open windows — which meant that when we needed a windowless engine, signed background receipts, and an in-process test harness, the framework didn’t fight us. We cleared a window list and kept building.

5,676 tests pass as of publication — 4,326 Rust, 1,350 frontend. The webview never met most of them.

4DA reads the internet for developers — privately, locally — and gets sharper every day. The engine described here is at 4da.ai (every network call it makes is enumerated in NETWORK.md); the in-process testing framework is open source.