Tauri 2 IPC: How Rust and React Actually Talk
A source-code walkthrough of the bridge — and a Bitcoin wallet that keeps private keys safe

Published May 3, 2026
Just last week, somebody filed a bug against Tauri. Their app pointed a webview at an external URL via tauri::WebviewUrl::External, and invoke() started failing with "Origin header is not a valid URL." They were certain they'd configured everything correctly. The docs didn't help. The framework was just rejecting their commands.
The bug is real and the cause is specific: the embedded webview doesn't add an Origin header on cross-origin requests, and the Referer is empty, so Tauri's IPC scope checker can't verify the request came from an allowed origin and refuses to dispatch it. To debug it, you have to know what the IPC bridge actually does — not what invoke() looks like, but how the request travels from JavaScript through the OS-native webview into Rust and back.
Most Tauri tutorials show you invoke('hello') and stop. That's enough to ship a hello-world; it's not enough to debug #15190-class bugs, design a security boundary, or make architectural choices that survive contact with production. This post reads the Tauri 2 IPC bridge end to end, traces a real Bitcoin wallet's commands across it, and shows the security pattern that makes Tauri worth picking over Electron in the first place.
By the end you'll know:
- What
invoke()actually does, with line references to the Tauri source - How to design an IPC surface where private keys never leak to the frontend
- When Tauri 2's capability system blocks a command and when it doesn't
- The difference between the Tauri and Electron threat models, in one paragraph
- The five mistakes that bite real teams using Tauri in 2026
The example throughout is a Bitcoin wallet from my book Rust Blockchain: A Full-Stack Implementation Guide — 18 commands, two halves, a real working architecture you can clone and run. But the lessons generalize to any Tauri app: a password manager, an enterprise SSO dashboard, an ML inference UI. Anywhere a Rust backend has secrets and a webview UI shouldn't.
1. What Tauri IPC actually is
Tauri is a desktop-app shell. Your application's UI runs in the operating system's native webview (WebView2 on Windows, WKWebView on macOS, WebKitGTK on Linux). Your application's logic runs in a Rust process. The two halves live in the same OS process, but the boundary between them is enforced — JavaScript inside the webview can only reach Rust through commands that Rust has explicitly registered.
The mechanism for crossing that boundary is inter-process communication (IPC), even though it's technically intra-process. The naming is conventional, not literal.
A request from React to Rust looks like a remote procedure call:
- React calls
invoke('cmd_name', { arg: 'value' }) - Tauri serializes the arguments to JSON
- The message is delivered through the webview's host-bridge channel
- Tauri dispatches to a Rust function registered with
#[tauri::command] - The Rust function returns a
Result<T, E> - Tauri serializes the result to JSON
- React receives the resolved promise
That's the whole protocol. It looks like fetch, except there's no network — the bridge is a message channel inside one OS process — and the "server" is a Rust function that has memory-safe access to your filesystem, your secrets, and your databases.

The diagram above is the architecture for bitcoin-desktop-ui-tauri, the admin UI from Chapter 17 of the book. A React component asks useQuery() for the latest blockchain state. The query function calls commands.getBlockchainInfo(). That function wraps a single invoke() call. On the Rust side, a #[tauri::command] handler receives the call, asks Tauri's dependency injection for the shared AppState, builds an HTTP client (AdminClient) from the shared bitcoin-api crate, hits the blockchain API at :8080, and returns JSON. React gets the response. The component re-renders.
The whole roundtrip is about ~150 lines of Rust and TypeScript split across four files. We'll walk all four.
2. The IPC bridge under the hood
This section is the longest, because most existing Tauri content stops where this section begins. Read it once and you'll understand invoke() better than 95% of people shipping Tauri apps today.
2.1 Where the bridge lives in the source
The Tauri repository at github.com/tauri-apps/tauri contains the IPC implementation in two places:
crates/tauri/src/ipc/— the high-level command dispatch (theinvoke()you call, the#[tauri::command]macro,Channel,InvokeBody,InvokeResolver)crates/tauri-runtime-wry/src/webview.rs— the wry-backed implementation of the message bus that talks to the actual platform webview
When you call invoke('cmd_get_balance', { addr: '1A1zP1...' }) in TypeScript, here's what happens:
Step 1 — JavaScript-side serialization. The @tauri-apps/api/core package's invoke function bundles the command name and the arguments object, then posts a message to the webview's host bridge. On Windows that's chrome.webview.postMessage. On macOS it's webkit.messageHandlers.ipc.postMessage. On Linux/GTK it's a custom URI scheme handler. Tauri abstracts these three platform mechanisms behind a single Webview::eval and Webview::on_window_event API so user code never has to think about them.
Step 2 — message arrives in Rust. The Tauri runtime receives the platform message and routes it to crates/tauri/src/ipc/invoke.rs, where the Invoke struct is constructed. This struct holds the command name (a string), the payload (a JSON value), and a resolver — the channel back to the webview that will eventually carry the response.
Step 3 — capability check. Before dispatching, the IPC layer consults the access control list (ACL) loaded from the capabilities/*.json files in your project. If the calling window's capability set doesn't include the requested command, the request is rejected with a permission error. We'll come back to this in §6.
Step 4 — argument deserialization. Tauri uses serde and the #[tauri::command]-generated wrapper code to deserialize the JSON payload into the typed parameters of your Rust function. Each parameter is matched by name to a key in the JSON object. Type mismatches become deserialization errors that propagate back to the JavaScript caller as Error objects, not panics.
Step 5 — your function runs. The Rust function executes. Async commands execute on tauri::async_runtime (Tokio underneath). The function can take a State<T> parameter, which Tauri's dependency injection populates from values you registered with tauri::Builder::manage().
Step 6 — result serialization and return. The function's return value (Result<T, E> where both T and E are serde::Serialize) is serialized back to JSON and posted through the resolver back to the JavaScript caller. The TypeScript invoke() Promise resolves with the decoded result, or rejects with the decoded error.
2.2 What this means for your application code
The Rust side of an IPC handler is genuinely small. From the book's admin UI, here is the canonical handler — cmd_get_blockchain_info, in bitcoin-desktop-ui-tauri/src-tauri/src/commands/blockchain.rs:
use crate::api::service::BitcoinApiService;
use tauri::State;
use serde_json::Value;
use crate::AppState;
#[tauri::command]
pub async fn cmd_get_blockchain_info(
state: State<'_, AppState>,
) -> Result<Value, String> {
let response = BitcoinApiService::get_blockchain_info(
state.base_url.clone(),
state.api_key.clone(),
)
.await?;
serde_json::to_value(&response)
.map_err(|e| e.to_string())
}
Three things to notice. First, the #[tauri::command] macro is doing a lot of work — it generates a wrapper that handles JSON deserialization, dispatches to your function, and serializes the result. You don't write any of that.
Second, the state: State<'_, AppState> parameter is dependency injection. You registered AppState once in main() via .manage(app_state), and Tauri populates this parameter on every call. No globals, no thread-local statics, no manual locking — it's the same idiomatic pattern as axum::extract::State or actix_web::web::Data.
Third, the function returns Result<serde_json::Value, String>. The String error type is conventional in Tauri because everything that crosses the bridge has to be Serialize, and String is the cheapest serializable error. Production code typically uses a custom error enum that derives Serialize, but the pattern is the same.
The TypeScript side is even smaller, in bitcoin-desktop-ui-tauri/src/commands.ts:
import { invoke } from '@tauri-apps/api/core';
import { BlockchainInfo, BlockSummary, ApiResponse } from './types';
export async function getBlockchainInfo(): Promise<ApiResponse<BlockchainInfo>> {
return invoke<ApiResponse<BlockchainInfo>>('cmd_get_blockchain_info');
}
A one-line wrapper around invoke(). The string 'cmd_get_blockchain_info' is the same name the Rust function exports through tauri::generate_handler!. The TypeScript generic argument <ApiResponse<BlockchainInfo>> tells the compiler what shape the resolved value will have — but note this is a TypeScript-only assertion, not a runtime guarantee. If the Rust side returns a differently-shaped object, TypeScript won't notice until something reads a missing property at runtime.
We'll come back to this gap when we discuss tauri-specta.
2.3 The tauri::generate_handler! macro
Every command you want to expose has to be listed in a single tauri::generate_handler! call inside main(). The book's admin UI does this with all 22 of its commands:
fn main() {
tauri::Builder::default()
.manage(AppState {
base_url: "http://127.0.0.1:8080".to_string(),
api_key: std::env::var("BITCOIN_API_ADMIN_KEY")
.unwrap_or_else(|_| "admin-secret".to_string()),
})
.invoke_handler(tauri::generate_handler![
cmd_get_blockchain_info,
cmd_get_latest_blocks,
cmd_get_blocks,
cmd_get_block_by_hash,
cmd_get_mining_info,
cmd_generate_to_address,
cmd_get_health,
cmd_get_liveness,
cmd_get_readiness,
cmd_get_mempool,
cmd_get_mempool_transaction,
cmd_get_transactions,
cmd_get_address_transactions,
cmd_create_wallet_admin,
cmd_get_addresses_admin,
cmd_get_wallet_info_admin,
cmd_get_balance_admin,
cmd_send_transaction_admin,
cmd_set_base_url,
cmd_set_api_key,
cmd_get_config,
cmd_check_connection,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Two gotchas worth flagging immediately:
- Calling
invoke_handlermore than once silently overwrites. Only the last call wins. If you split command registration across multiple files thinking each one will register its own handlers, only one set will actually be live. Pass every command to a singlegenerate_handler!call. - A command not listed in
generate_handler!fails silently from the frontend. The Rust function still compiles; it's just unreachable through IPC. From JavaScript you get a generic "command not found" error at runtime, with no compile-time warning. The list above is a runtime contract — keep it audited.
2.4 Async, runtimes, and Send
Async commands run on tauri::async_runtime, which is a Tokio multi-threaded runtime under the hood. Two consequences follow.
First, everything in your async command's environment must be Send, because the runtime can move the future across worker threads at any .await point. Standard std::sync::MutexGuard is intentionally not Send (this is what protects you from cross-thread deadlocks in non-async code), so you cannot hold one across an .await. Use tokio::sync::Mutex, whose MutexGuard is Send. If you guess wrong, you get a runtime panic — there's no compile-time check that catches it.
Second, State<T> references and AppHandle work differently at .await boundaries. A State<'_, T> borrow is short-lived and shouldn't be held across an .await. If you need to own data across an await point, extract the data you need out of State before the first .await, or use AppHandle (which is Clone and owned) to access state inside async closures. The book's wallet uses both: synchronous reads use State, while long-running async tasks pass an owned AppHandle.
2.5 The ergonomic gap: stringly-typed invoke()
If you've made it this far, you've noticed something: the link between invoke('cmd_get_balance') and cmd_get_balance in Rust is a string. TypeScript doesn't know the command exists until the Promise resolves at runtime. There's no autocomplete on command names, no compile-time check that you spelled the command correctly, no signal that you passed the wrong argument shape.
This is where the community has done the work that the official docs haven't done, in the form of tauri-specta. It's a library that generates TypeScript bindings from your Rust commands at build time, using the Specta crate for type introspection. The same cmd_get_balance function, with #[tauri::command] augmented by #[specta::specta], becomes a typed export the TypeScript side can import:
import { commands } from './bindings'; // generated by tauri-specta
const balance: u64 = await commands.cmdGetBalance({ addr: '1A1zP1...' });
The compiler now knows the command exists, knows it takes an addr: string, and knows it resolves to a u64. Rename the Rust function and the TypeScript build breaks immediately. Ship the wrong argument shape and the TypeScript build breaks immediately. The Tauri 2 docs don't show this. Production teams use it almost universally.
If you take only one practical recommendation from this post, it's add tauri-specta to any Tauri 2 project as soon as you have more than ~five commands. The official invoke() is great for prototypes and tutorials. For real applications, the typed bindings are the difference between bugs at compile time and bugs at runtime.
3. Tauri vs Electron — design and security, not vibes
The "Tauri vs Electron" comparison appears in every Rust desktop discussion in 2026, and most of those comparisons are vibes-based. Here is the comparison grounded in design and security, with citations to each project's own documentation.
| Axis | Tauri 2 | Electron |
|---|---|---|
| Backend language | Rust | Node.js (V8) |
| Webview engine | OS-native (WebView2 / WKWebView / WebKitGTK) | Bundled Chromium |
| Mobile support | Yes (Tauri 2 added iOS and Android, Oct 2024) | Desktop only |
| IPC mechanism | JSON over the platform webview's host-bridge | JSON / structured-clone over ipcMain / ipcRenderer |
| Permission model | Capability-based ACL (opt-in per window) | None by default; full filesystem unless you add manual gates |
| Plugin ecosystem | ~120 official + community plugins (April 2026) | 10,000+ npm packages |
| Bundle size, hello-world | 2–10 MB (community-cited) | 80–200 MB (community-cited) |
| Memory at idle | ~50 MB (community-cited) | ~120 MB+ (community-cited) |
| License | MIT / Apache 2.0 | MIT |
(Bundle and memory figures are widely-cited community measurements from 2026 comparative analyses; I'm not claiming these as my own benchmarks. They are useful as orders-of-magnitude context, not as precision numbers.)
The headline trade-off is this. Electron buys you the most mature desktop-app ecosystem in existence in exchange for shipping an entire copy of Chromium with every app and giving the JavaScript side default access to the user's filesystem. Tauri buys you a tenth of the bundle size and a permission system that's secure by default, in exchange for asking your team to write the backend in Rust and giving up most of npm.
Pick Electron when:
- Your team doesn't know Rust and doesn't have time to learn it
- The application doesn't hold secrets, doesn't need a small footprint, and ships only on desktop
- You depend on a Node.js library (or an Electron-specific plugin) that has no Rust equivalent
- Examples that match: VS Code, Slack, Discord, Atom
Pick Tauri when:
- Your application holds secrets — wallets, password managers, API keys, encryption keys
- Your application is positioned on lightweight or low-resource — productivity tools that compete with Electron incumbents (AppFlowy vs Notion is the canonical example)
- You need iOS or Android support from the same codebase
- Your business logic is already a Rust crate, and the desktop client is just the latest consumer (Spacedrive's
spacedrive-coreis the textbook case) - Examples that match: Cap.so, AppFlowy, Spacedrive, Padloc, Hoppscotch
For the rest of this post we're firmly in Tauri territory: the example application holds private keys, has a strict threat model, and wants the smallest reasonable attack surface.
4. A working example: a Bitcoin wallet that keeps keys safe
This is where the post's example earns its keep. The book's bitcoin-wallet-ui-tauri crate is a Bitcoin wallet — 18 IPC commands, an encrypted SQLite database, and a strict invariant: private keys never cross the IPC boundary. The React UI never touches them. JavaScript can ask Rust to sign a transaction; it cannot ask Rust to hand over the key that does the signing.
This pattern generalizes beyond blockchain. A password manager has the same threat model. So does an enterprise dashboard that holds API tokens. So does an ML inference UI that holds proprietary model weights. The wallet is a concrete instance of a class of secret-holding desktop apps; once you understand its IPC surface, you understand all of them.

The diagram above shows the wallet's IPC surface. The left side is what React can ask for. The right side is what only Rust knows. The orange dashed bar between them is the IPC boundary, and the design choice is that nothing on the right side has a path to the left side, even in principle.
4.1 The wallet's state structure
The wallet's AppState looks like this:
use tauri::State;
use std::sync::RwLock;
#[derive(Clone, serde::Serialize, serde::Deserialize)]
pub struct WalletState {
pub current_wallet: Option<String>,
pub wallets: Vec<String>,
pub database_password: String,
}
#[derive(Clone)]
pub struct AppState {
pub wallet: RwLock<WalletState>,
pub base_url: String,
}
Notice what WalletState doesn't contain. There is no private_key field. There is no seed_phrase field. There is no keypair. The struct that's available to IPC handlers via State<AppState> does not contain anything secret. Private keys live elsewhere — loaded from an encrypted SQLite database into a SecretKey value that's used inside a single function call and dropped before the function returns.
The database_password field is a deterministic hash of \(USER and \)HOME, so the same user on the same machine always unlocks the same wallet database — but the password itself is derived locally and never shipped to the frontend.
4.2 The 18 commands the wallet exposes
These are all 18 commands the wallet exposes across IPC, in the order they're registered with generate_handler!:
| Command | Returns | What it does |
|---|---|---|
cmd_list_wallets |
Vec<String> |
List wallet identifiers stored on disk |
cmd_create_wallet |
String |
Create a new wallet, return its identifier |
cmd_select_wallet |
() |
Set the active wallet for subsequent operations |
cmd_get_balance |
u64 |
Balance of an address, in satoshis |
cmd_get_addresses |
Vec<Address> |
All addresses owned by the active wallet |
cmd_generate_address |
Address |
Derive and return a new address |
cmd_get_transaction_history |
Vec<Tx> |
Past transactions for the active wallet |
cmd_get_unspent_outputs |
Vec<Utxo> |
UTXOs available for spending |
cmd_send_transaction |
TxId |
Build, sign, and broadcast a transaction |
cmd_sign_transaction |
SignedTx |
Sign a draft transaction (does not broadcast) |
cmd_get_tx_status |
TxStatus |
Confirmation count for a given txid |
cmd_export_wallet |
String |
Export an encrypted wallet bundle (no plaintext keys) |
cmd_import_wallet |
() |
Import a wallet bundle into local storage |
cmd_backup_wallet |
() |
Trigger a backup to the configured location |
cmd_set_passphrase |
() |
Set the wallet's unlock passphrase |
cmd_unlock_wallet |
bool |
Verify the passphrase and unlock for the session |
cmd_get_wallet_info |
WalletInfo |
Metadata about the active wallet |
cmd_cancel_transaction |
() |
Cancel a pending transaction by txid |
Look at what isn't in this list. There is no cmd_get_private_key. There is no cmd_export_seed_phrase. There is no cmd_decrypt_database. The IPC surface itself does not offer a path for keys to escape Rust. Even if a malicious npm package compromised the React UI tomorrow and an attacker had complete control of the JavaScript side, the worst they could do is ask Rust to sign things — they could not extract the keys to sign things on their own machine.
This is the security pattern. The IPC layer is your API surface; design it so that the operations you need are present and the operations that would leak secrets are intentionally absent.
4.3 Three IPC patterns the wallet demonstrates
Pattern 1 — synchronous query. A read-only command that takes no parameters or simple parameters and returns a value:
#[tauri::command]
pub async fn cmd_get_balance(
state: State<'_, AppState>,
addr: String,
) -> Result<u64, String> {
let wallet = state.wallet.read().map_err(|e| e.to_string())?;
// ... query the chain, sum UTXOs, return total
}
The TypeScript side calls it the same way as a fetch:
const balance: number = await invoke<number>('cmd_get_balance', { addr });
React Query wraps this with caching:
export function useBalance(addr: string | null) {
return useQuery({
queryKey: ['balance', addr],
queryFn: () => addr ? commands.getBalance(addr) : Promise.resolve(0),
enabled: !!addr,
staleTime: 10000, // balances refresh every 10s
});
}
Pattern 2 — async write that mutates state. A command that changes something — broadcasts a transaction, creates a wallet — and that the rest of the UI needs to know about:
#[tauri::command]
pub async fn cmd_send_transaction(
state: State<'_, AppState>,
to: String,
amount: u64,
) -> Result<String, String> {
// build the tx, sign it (in place, with the key never leaving Rust),
// broadcast to the network, return the txid
}
The React side uses useMutation and invalidates relevant cached reads on success:
const sendTx = useMutation({
mutationFn: (vars: { to: string; amount: number }) =>
commands.sendTransaction(vars.to, vars.amount),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['transaction-history'] });
},
});
After a successful send, the cached balance is marked stale so the next render fetches fresh data. This is React Query's bread and butter — it's also the right design for any IPC bridge that needs to keep client-side caches consistent with server-side mutations.
Pattern 3 — security-bounded operation. This is the wallet's distinctive pattern. cmd_sign_transaction takes a transaction draft (recipient address, amount, fee), signs it inside Rust, and returns the signed bytes. The private key is loaded from the encrypted database, used inside a single function call, and dropped before the response is serialized back to the frontend.
#[tauri::command]
pub async fn cmd_sign_transaction(
state: State<'_, AppState>,
draft: TransactionDraft,
) -> Result<SignedTransaction, String> {
let wallet = state.wallet.read().map_err(|e| e.to_string())?;
let sk = load_secret_key(&wallet.database_password)?;
let signed = sign_in_place(draft, &sk)?;
// sk is dropped here, before the function returns
Ok(signed)
}
The crucial observation: there is no path through this function that surfaces sk to the caller. The function returns SignedTransaction, which is just bytes — no key material in it. The IPC surface offers signing but does not offer key extraction. A malicious frontend can spam-call this function but cannot exfiltrate the underlying key.
This is the pattern that separates a Tauri-style desktop wallet from a browser extension wallet. In a browser extension, the "frontend" and the "backend" share the same JavaScript runtime, so isolating keys requires a much weaker form of process boundary. In Tauri, the IPC bridge is a hard boundary enforced by the operating system's webview, and the Rust side controls what crosses it.
4.4 The shared-crate architecture
One more architectural insight worth its own subsection. The book has four implementations of the wallet UI: a pure-Rust Iced version (Chapter 18), this Tauri-with-React version (Chapter 19), and a parallel admin UI in both frameworks (Chapters 16 and 17).
All four call the same underlying Rust crate: bitcoin-api, which exposes AdminClient and WalletClient types that talk to the blockchain over HTTP. The Tauri commands are thin wrappers — they receive State<AppState>, call into the bitcoin-api crate, and return JSON. The Iced UIs do the same calls from Message handlers. The framework choice is decoupled from the application logic.
Why does this matter? Because Tauri is a UI shell, not an application framework. If your team's business logic is already a reusable Rust crate, swapping the desktop shell from Electron to Tauri (or from one Rust UI framework to another) doesn't require refactoring anything except the layer that makes API calls. Spacedrive's open-source codebase demonstrates this at production scale: their spacedrive-core crate is a pure Rust library; the Tauri 2 desktop app, the CLI, and the Spacebot service are all thin consumers of that one crate.
If you're considering Tauri but worried about lock-in, this architecture pattern is your insurance policy. Keep the application logic in plain Rust crates; treat the Tauri layer as the desktop adapter; you can rip out the Tauri layer in a weekend if you ever need to.
5. The capability system: security at the IPC boundary
We promised in §2 that we'd come back to the capability system. Here it is.
In Tauri 1, every IPC command was reachable from every window by default. You opted out of dangerous APIs by editing an allowlist. Tauri 2 inverts this: nothing is reachable by default, and you grant access by declaring capabilities in JSON files under your project's capabilities/ directory.
A capability declaration is a small JSON document that names a window or webview, names the permissions it should have, and optionally scopes those permissions further (which filesystem paths, which HTTP endpoints, which IPC commands). When a window calls invoke(), the Tauri IPC layer consults the capabilities for that window before dispatch — if the requested command isn't covered by a capability grant, the call is rejected before the Rust handler is ever invoked.
For the wallet, a minimal capability file granting access only to the wallet commands looks something like this:
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "wallet-main",
"description": "Capabilities for the main wallet window",
"windows": ["main"],
"permissions": [
"core:default",
{
"identifier": "wallet:allow-balance-and-history",
"allow": [
"cmd_get_balance",
"cmd_get_addresses",
"cmd_get_transaction_history",
"cmd_send_transaction",
"cmd_sign_transaction"
]
}
]
}
The windows: ["main"] line means this capability only applies to the window labeled main. If your app has a tray-icon window or a notification overlay window, those windows have their own capability files with their own permission grants. Per-window scoping is the design feature that makes the capability system useful — you can give the main interactive window the broad permissions it needs while keeping the tray-icon window restricted to a single command.
Three things this design buys you:
A clear audit story. A security reviewer can read your capabilities/ directory and immediately see what each window can ask Rust to do. There is no hidden middleware, no ambient permissions. The capability file is the authorization policy.
Defense in depth. Even before the ACL check, the IPC surface itself is limited (we saw this in §4 with the wallet's commands). Even if a misconfigured capability accidentally grants too much, the only attack surface available is the commands you actually wrote. The capability layer is the second gate, not the only gate.
A red-team analysis you can write down. Suppose tomorrow a malicious npm package compromised your React build. The attacker now controls the JavaScript running in the webview. What can they do? The answer is: exactly what the capability file allows, no more. They can call cmd_send_transaction if cmd_send_transaction is in the allowlist. They cannot reach files outside the configured filesystem scope. They cannot make HTTP requests to arbitrary servers. They especially cannot extract private keys, because no command exists that would return one.
This story is impossible to write for an Electron app with the default configuration, because Electron's default is "the renderer process can do anything the main process can do." You can lock Electron down — contextIsolation, sandbox, nodeIntegration: false, custom IPC validators — but these are opt-in, ad hoc, and easy to misconfigure. Tauri 2's capability model is opt-in by inversion: secure by default, dangerous by explicit grant. For an application that holds secrets, this default is the entire reason to pick Tauri.
6. Five things that bit me
A practical section for readers who are about to ship Tauri 2 in production. These are the recurring pitfalls — drawn from the Tauri repo's issue tracker, Discord conversations, and the experience of building the book's two Tauri apps.
1. invoke_handler only registers the last call. If you call .invoke_handler(generate_handler![cmd_a]) and then .invoke_handler(generate_handler![cmd_b]), only cmd_b is registered. There is no compile-time warning. Pass every command to a single generate_handler! invocation. If the list gets unwieldy, group commands into modules and re-export them, but the final list lives in one macro call.
2. Standard MutexGuard is not Send — your async command won't compile or will panic. When you hold a std::sync::MutexGuard across an .await point, the Rust compiler refuses to make the future Send, and Tauri requires async-command futures to be Send. Use tokio::sync::Mutex and tokio::sync::RwLock, whose guards are Send. The book's wallet uses std::sync::RwLock for synchronous code and switches to Tokio's primitives whenever a lock is held across an await.
3. Event listeners need cleanup. Tauri's frontend event API (listen('event-name', handler)) registers a JavaScript callback for a Rust-emitted event. If the React component that registered the listener unmounts without calling the unlisten function, the callback stays alive — and if a new instance of the component mounts, you now have two callbacks reacting to the same event. The book's React hooks always store the unlisten promise and clean up in useEffect return:
useEffect(() => {
const unlistenPromise = listen('block-mined', (event) => {
// ...
});
return () => {
unlistenPromise.then(fn => fn());
};
}, []);
4. Setup-hook events fire before listeners attach. If you call app.emit('startup') from the Tauri setup hook in Rust, none of the React components are mounted yet, so the event has no listeners and is dropped. The pattern is to wait for a "ready" handshake from the frontend before emitting startup events. The frontend mounts, calls invoke('cmd_frontend_ready'), and the Rust side then emits the startup events with confidence.
5. The official invoke() is stringly-typed; production teams use tauri-specta. This was the recommendation in §2.5 and it's worth saying again as a pitfall. Without tauri-specta, every IPC call is a string lookup, every argument is unknown until you assert the type, and every rename of a Rust function is a runtime bug waiting to happen. Add tauri-specta early. The migration cost from invoke() to typed bindings is small if you do it before you have hundreds of invoke() call sites.
Bonus issue worth knowing about: Origin header handling on external URLs (issue #15190) is the bug we opened the post with. If you set the webview to an external URL via tauri::WebviewUrl::External, the embedded webview may not include an Origin header on IPC requests, and the scope checker rejects them. The current workaround is to use tauri::WebviewUrl::App with an internal HTML shim that loads the external content, or to disable origin checking for that specific window (which has security trade-offs of its own).
7. Where Tauri is heading
The Tauri team has been public about what comes after 2.x. Three directions worth knowing about:
Servo as an alternative webview engine. Tauri currently uses each platform's native webview, which means inheriting that webview's bugs and feature set. Servo — Mozilla's experimental Rust-native browser engine — is being actively developed as an alternative renderer. If it lands, you'll be able to ship Tauri apps that don't depend on the host operating system's webview, eliminating a class of cross-platform inconsistency bugs at the cost of bundling a (still much smaller than Chromium) browser engine. Watch Tauri Discussions and the Servo project for status updates.
WASI plugin architecture. Native Tauri plugins are written in Rust and compiled into the application binary. A WASI-based plugin model would let plugins ship as .wasm files that the Tauri runtime loads at startup, with capability constraints enforced by the WebAssembly sandbox. This unlocks third-party plugins that the user can install at runtime without recompiling their Tauri app.
Mobile maturity. Tauri 2.0 added iOS and Android targets, but the mobile experience still has rough edges: Xcode tooling integration is immature, many plugins don't yet support mobile, and the docs lag behind desktop. Expect the 2.x line to keep filling these gaps.
For the Bitcoin wallet specifically, the most interesting near-term direction is mobile. A Tauri-on-iOS Bitcoin wallet that shares its Rust core with the desktop wallet — same bitcoin-api crate, same signing logic, same secret handling — is a single-codebase mobile + desktop app of a kind that's been historically very expensive to build. The shared-crate architecture from §4.4 is what makes that affordable.
8. Summary
The five takeaways:
invoke()is a JSON-RPC over the platform webview's host bridge — you can read the implementation incrates/tauri/src/ipc/and it's not much code. Understanding it makes debugging IPC bugs tractable.- Design your IPC surface so secrets never have a path out. The Bitcoin wallet's 18 commands include
cmd_sign_transactionbut notcmd_get_private_key. The capability system gates command access; the API surface itself bounds what's even askable. - Tauri is a UI shell, not an application framework. Keep your business logic in plain Rust crates. The framework choice becomes a swap, not a rewrite.
- Use
tauri-spectafor any non-trivial app. The defaultinvoke()is stringly-typed; production teams addtauri-spectafor compile-time safety on every command call. - Tauri vs Electron is a threat-model question. If your app holds secrets, the capability-based opt-in permission model is the entire reason you'd pick Tauri. If your app doesn't hold secrets and you have an Electron-only npm dependency, Electron is fine.
If you want the working code that goes with this post — all 22 admin commands, all 18 wallet commands, the React frontends, the Rust backends, the capability files, and the Iced parallel implementations — clone the companion repository on GitHub. The wallet alone is ~1,400 lines of Rust and ~1,200 lines of TypeScript, and you can have it building locally in fifteen minutes.
If you want the long form — 33 chapters, 768 pages, taking the same blockchain from the Bitcoin whitepaper to a deployed Kubernetes cluster — that's the book.
Sources and further reading
Tauri primary sources
- Inter-Process Communication — official Tauri 2 IPC docs
- Calling Rust from the Frontend —
#[tauri::command]reference - Capabilities — capability declarations
- Permissions — permission identifiers and sets
- Security model overview — Tauri 2's security architecture
- State Management —
State<T>,RwLock,tokio::syncchoices - Tauri 2.0 Stable Release announcement — what 2.0 changed
Tauri repo issues and discussions cited
#15190— Origin header bug with external URL#6889— Custom protocol IPC break- Discussion
#5690— IPC Improvements - Discussion
#5413— Setup events not captured - Discussion
#5194— Listen-twice prevention
Type safety
Production examples
- Awesome-Tauri — curated Tauri apps and resources
- Spacedrive launch + architecture — the
spacedrive-coreshared-crate pattern at production scale
Comparative analyses
- Tauri vs Electron 2026 — PkgPulse
- Tauri vs Electron 2026 — Tech Insider
- Tauri v2 vs Electron, 6-month real-world experience
The book and its companion repository
- Rust Blockchain: A Full-Stack Implementation Guide — Amazon | Gumroad (PDF + EPUB) | Leanpub (pay what you want)
- github.com/bkunyiha/rust-blockchain — the companion source code, including both Tauri apps and the shared
bitcoin-apicrate
Available now on Amazon (paperback + Kindle + hardcover), Gumroad (PDF + EPUB), and Leanpub (pay what you want).
Free resource: Clone the Starter Template




