Tauri 2 Performance Review Skill
Expert workflow for reviewing Tauri 2 code with a focus on IPC throughput, async correctness, and binary/large-payload handling.
When to Use
Invoke this skill (or the tauri-performance-reviewer agent) when changes touch:
- Any
#[tauri::command]returning or accepting non-trivial data - Frontend
invoke()callers (especially in hot paths) - State management —
Mutex,RwLock,Arc tauri.conf.jsonbuild / dev / bundle settingsCargo.tomlfeatures and profiles
Core Principle: IPC Has Cost
Every invoke() round-trip serializes args to JSON, crosses a process boundary, and deserializes the response. Optimize per call, then minimize call count, then stream when total bytes are large.
Checklist by Area
Rust Commands
□ Async fn for I/O work (file, net, db) — never block the runtime
□ Sync fn for pure compute / very fast operations
□ Returns < 100KB use normal serde JSON
□ Returns ≥ 100KB binary use tauri::ipc::Response::new(bytes)
□ Streaming/progress uses tauri::ipc::Channel<T>
□ Inputs aren't Vec<u8> when an ArrayBuffer Request body would work
□ No std::sync::Mutex held across .await (use tokio::sync::Mutex)
□ No .clone() of large Vec/String when a reference works
□ &str arguments instead of String when ownership not needed
□ No JSON parse/stringify inside hot loops
□ Database/HTTP connection pooled (managed state, not per-call)
State Management
□ Mutex chosen for the access pattern (sync vs tokio)
□ Read-heavy state uses RwLock, not Mutex
□ Lock scope is minimal — release before slow work
□ No Arc<Mutex<T>> wrapping (Tauri does the Arc internally)
□ No deep clones of state on every read — return references via map_or borrow
Frontend invoke()
□ No invoke() in render loops without memoization
□ Multiple sequential invokes that fetch related data → combine into one command
□ Large blob downloads use Channel for progress + Response for bytes
□ Listen for Tauri events instead of polling via invoke
□ No invoke() inside React useEffect without dependency array
tauri.conf.json
□ build.frontendDist points to a built (minified) frontend, not dev source
□ bundle.resources only includes what's needed (not entire src/)
□ bundle.macOS.minimumSystemVersion set (avoids fat universal slices for old macOS)
□ app.windows[].visible: false at start if you want to wait for "ready" event (avoids white flash)
□ app.windows[].decorations / transparent set conservatively (each costs paint perf)
□ No huge bundled binaries in externalBin if avoidable
Cargo.toml
□ [profile.release] uses opt-level = 3 (default) and lto = "thin" or "fat"
□ codegen-units = 1 in release for max optimization (slower compile, faster binary)
□ strip = true in release (smaller binary)
□ panic = "abort" in release (smaller binary, faster)
□ No unnecessary default features on heavy crates (use default-features = false)
Startup Time
□ tauri::Builder::default() doesn't do heavy work in setup (defer to first command)
□ Plugins initialized lazily where the plugin supports it
□ Frontend doesn't fetch all data on mount — paginate
□ Splash window or hidden-then-show pattern for slow initialization
Common Tauri 2 Performance Bug Patterns
Pattern 1: Large Binary Returned As serde JSON
// ❌ WRONG — 10MB image becomes ~14MB base64 JSON, ~200ms encode + parse
#[tauri::command]
fn read_image(path: String) -> Result<Vec<u8>, Error> {
Ok(std::fs::read(path)?)
}
// ✅ CORRECT — raw ArrayBuffer, ~10ms
#[tauri::command]
fn read_image(path: String) -> Result<tauri::ipc::Response, Error> {
Ok(tauri::ipc::Response::new(std::fs::read(path)?))
}
Frontend:
const buf = await invoke<ArrayBuffer>('read_image', { path })
Pattern 2: std::sync::Mutex Across .await (Panic + Slow)
// ❌ WRONG — panics in some Tokio configs; serializes async tasks
#[tauri::command]
async fn save(state: State<'_, std::sync::Mutex<AppState>>) -> Result<(), Error> {
let mut s = state.lock().unwrap(); // sync lock
tokio::fs::write("...", &s.buf).await?; // held across .await
Ok(())
}
// ✅ CORRECT — use tokio's async mutex
#[tauri::command]
async fn save(state: State<'_, tokio::sync::Mutex<AppState>>) -> Result<(), Error> {
let mut s = state.lock().await;
tokio::fs::write("...", &s.buf).await?;
Ok(())
}
Pattern 3: Polling Instead of Events
// ❌ WRONG — invoke every 500ms, wakes the UI thread + Rust constantly
setInterval(async () => {
setStatus(await invoke<Status>('get_status'))
}, 500)
// ✅ CORRECT — emit from Rust, listen from frontend
import { listen } from '@tauri-apps/api/event'
const un = await listen<Status>('status:changed', (e) => setStatus(e.payload))
app.emit("status:changed", status)?;
Pattern 4: Streaming Without Channel
// ❌ WRONG — caller waits 30s for download; no progress UI possible
#[tauri::command]
async fn download(url: String) -> Result<Vec<u8>, Error> {
Ok(reqwest::get(&url).await?.bytes().await?.to_vec())
}
// ✅ CORRECT — Channel sends progress + chunks
#[derive(Clone, Serialize)]
#[serde(tag = "event", content = "data")]
enum DownloadEvent {
Progress { downloaded: u64, total: u64 },
Chunk(Vec<u8>),
Done,
}
#[tauri::command]
async fn download(url: String, on_event: Channel<DownloadEvent>) -> Result<(), Error> {
let resp = reqwest::get(&url).await?;
let total = resp.content_length().unwrap_or(0);
let mut downloaded = 0;
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
downloaded += chunk.len() as u64;
on_event.send(DownloadEvent::Progress { downloaded, total })?;
on_event.send(DownloadEvent::Chunk(chunk.to_vec()))?;
}
on_event.send(DownloadEvent::Done)?;
Ok(())
}
Pattern 5: N+1 Invoke Calls
// ❌ WRONG — 50 round-trips for 50 notes
const notes: Note[] = []
for (const id of ids) {
notes.push(await invoke<Note>('get_note', { id }))
}
// ✅ CORRECT — one round-trip
const notes = await invoke<Note[]>('get_notes', { ids })
Pattern 6: Mutex Held While Doing Work
// ❌ WRONG — lock held while writing 100MB to disk; blocks all readers
let mut s = state.lock().await;
tokio::fs::write(&path, &s.huge_buffer).await?; // 2 seconds
s.last_saved = Some(now());
drop(s);
// ✅ CORRECT — clone what's needed, release lock immediately
let buffer = {
let s = state.lock().await;
s.huge_buffer.clone()
};
tokio::fs::write(&path, &buffer).await?;
state.lock().await.last_saved = Some(now());
Pattern 7: Heavy Work in .setup() Blocks Window Show
// ❌ WRONG — DB load on startup adds 3s to first paint
.setup(|app| {
let db = load_database_sync()?; // blocks
app.manage(db);
Ok(())
})
// ✅ CORRECT — initialize lazily, show window first
.setup(|app| {
let handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
let db = load_database().await.unwrap();
handle.manage(db);
handle.emit("db:ready", ()).ok();
});
Ok(())
})
Pattern 8: Cargo Release Profile Not Tuned
# ❌ Default — 80MB binary
[profile.release]
opt-level = 3
# ✅ Tuned for ship-quality binary — typically 25-40MB
[profile.release]
opt-level = "z" # or "s" for size
lto = true
codegen-units = 1
panic = "abort"
strip = true
Pattern 9: Frontend Bundles Dev Dependencies
// ❌ WRONG — frontendDist points at unminified source
"build": { "frontendDist": "../src" }
// ✅ CORRECT — point at production build output
"build": {
"beforeBuildCommand": "pnpm build",
"frontendDist": "../dist"
}
Pattern 10: .clone() On Large State Read
// ❌ WRONG — clones a 10MB Vec on every read
#[tauri::command]
fn get_buffer(state: State<'_, Mutex<AppState>>) -> Vec<u8> {
state.lock().unwrap().buffer.clone()
}
// ✅ CORRECT — return Response (raw bytes), no JSON, single copy
#[tauri::command]
fn get_buffer(state: State<'_, Mutex<AppState>>) -> tauri::ipc::Response {
tauri::ipc::Response::new(state.lock().unwrap().buffer.clone())
}
// Or even better: use Bytes/Arc<[u8]> in state to avoid the clone entirely
Severity Guide
| Severity | Examples | Action |
|---|---|---|
| CRITICAL | std::sync::Mutex across .await (panic risk), 100MB+ JSON returns, polling at <1s interval, blocking I/O in async command | Block merge |
| HIGH | Channel not used for streaming, N+1 invoke pattern, large clone() in hot path, lock held across slow work | Fix before merge |
| MEDIUM | Missing release profile tuning, missing minimumSystemVersion, suboptimal Mutex choice for read-heavy state | Follow-up |
| LOW | Stylistic, micro-opts in cold paths | Backlog |
Official Documentation References
| Check | Source |
|---|---|
| Calling Rust (commands, Channel, Response) | https://v2.tauri.app/develop/calling-rust/ |
| State management | https://v2.tauri.app/develop/state-management/ |
| Bundle / config | https://v2.tauri.app/reference/config/ |
Related
- Agent:
tauri-performance-reviewer - Commands:
/tauri-perf,/tauri-check,/tauri-audit