tauri-errors-ipc

Installation
SKILL.md

tauri-errors-ipc

Diagnostic Decision Tree

invoke() fails or returns unexpected result
|
+-- Error: "command <name> not found"
|   --> Step 1: Command Registration (Section 1)
|
+-- Error: "command <name> not allowed"
|   --> Permission issue. See tauri-errors-permissions skill.
|
+-- Error contains "invalid type" / "missing field" / "invalid value"
|   --> Step 2: Serialization & Type Mismatch (Section 2)
|
+-- Error: invoke returns string instead of object (or vice versa)
|   --> Step 3: Error Serialization Pattern (Section 3)
|
+-- Rust panics (app crashes, no error returned to JS)
|   --> Step 4: Async Command Panics (Section 4)
|
+-- Error caught but message is unhelpful ("null" or empty string)
|   --> Step 5: Structured Error Pattern (Section 5)
|
+-- No error, but invoke never resolves (hangs forever)
|   --> Step 6: Deadlocks & Blocking (Section 6)

Section 1: Command Not Found

Symptoms

  • JavaScript error: command <name> not found
  • invoke('my_command') rejects immediately

Debugging Steps

Step 1.1: Verify the command is registered in generate_handler![]:

// src-tauri/src/lib.rs
tauri::Builder::default()
    .invoke_handler(tauri::generate_handler![
        my_command,           // Direct function
        commands::my_command, // Module-prefixed function
    ])

Step 1.2: Verify there is only ONE invoke_handler() call. Multiple calls do NOT merge -- only the last one takes effect:

// WRONG: first handler is silently overwritten
builder
    .invoke_handler(tauri::generate_handler![cmd_a])
    .invoke_handler(tauri::generate_handler![cmd_b]) // Only this one works

Step 1.3: Verify the function has the #[tauri::command] attribute:

#[tauri::command]  // REQUIRED -- without this, generate_handler! will fail to compile
fn my_command() -> String {
    "hello".into()
}

Step 1.4: Verify the command name matches. Rust uses snake_case function names. The frontend calls the same snake_case name:

// Command: fn get_user_data() -> ...
await invoke('get_user_data');  // CORRECT: snake_case
await invoke('getUserData');    // WRONG: command names are NOT auto-converted

Step 1.5: If the command is in a separate module, verify it is pub:

// src-tauri/src/commands.rs
pub fn my_command() { }  // MUST be pub when in a module

// src-tauri/src/lib.rs
fn my_command() { }       // MUST NOT be pub when in lib.rs directly

Section 2: Serialization & Type Mismatch

Symptoms

  • Error: invalid type: expected <X>, found <Y>
  • Error: missing field '<name>'
  • Error: unknown field '<name>'
  • Command returns null or undefined unexpectedly

Debugging Steps

Step 2.1: Check argument name casing. Rust parameters use snake_case. JavaScript arguments MUST use camelCase:

#[tauri::command]
fn save_file(file_path: String, file_size: u64) { }
// CORRECT: camelCase keys matching snake_case params
await invoke('save_file', { filePath: '/doc.txt', fileSize: 1024 });

// WRONG: snake_case keys -- causes "missing field" error
await invoke('save_file', { file_path: '/doc.txt', file_size: 1024 });

Step 2.2: Check type compatibility. Common mismatches:

Rust Type JavaScript Type Common Mistake
String string Passing number or null
u32 / i32 number Passing string like "42"
f64 number Passing NaN or Infinity (fails serde)
bool boolean Passing 0/1 instead of true/false
Vec<T> T[] Passing non-array iterable
Option<T> T | null Passing undefined (use null explicitly)
PathBuf string Passing object instead of path string

Step 2.3: Check that custom struct derives Deserialize (for arguments) and Serialize (for return types):

use serde::{Deserialize, Serialize};

#[derive(Deserialize)]  // REQUIRED for command arguments
struct CreateRequest {
    name: String,
    count: u32,
}

#[derive(Serialize)]    // REQUIRED for return types
struct CreateResponse {
    id: u64,
    created: bool,
}

#[tauri::command]
fn create(request: CreateRequest) -> CreateResponse { ... }

Step 2.4: Verify rename_all attribute if used:

// This changes the expected JS argument convention
#[tauri::command(rename_all = "snake_case")]
fn my_cmd(user_name: String) { }
// Now JS must use: invoke('my_cmd', { user_name: 'Alice' })

Section 3: Error Serialization Pattern

Symptoms

  • Rust command returns Result<T, E> but frontend receives unhelpful error
  • Error is "null" or "" or a raw Rust debug string
  • Compilation error: the trait Serialize is not implemented for <ErrorType>

The Required Pattern

The error type in Result<T, E> MUST implement Serialize. The idiomatic approach uses thiserror with a manual Serialize impl:

#[derive(Debug, thiserror::Error)]
enum Error {
    #[error(transparent)]
    Io(#[from] std::io::Error),
    #[error("database error: {0}")]
    Database(String),
    #[error("not found")]
    NotFound,
}

// REQUIRED: Manual Serialize impl -- serializes as the Display string
impl serde::Serialize for Error {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where S: serde::ser::Serializer {
        serializer.serialize_str(self.to_string().as_ref())
    }
}

#[tauri::command]
fn open_file(path: String) -> Result<String, Error> {
    let content = std::fs::read_to_string(path)?; // auto-converts via #[from]
    Ok(content)
}

Critical Rules

NEVER use Result<T, String> for anything beyond prototyping. It loses error type information and makes frontend error handling fragile.

NEVER derive Serialize on error enums that contain non-serializable types (like std::io::Error). Use the manual Serialize impl pattern above.

ALWAYS implement Display (via thiserror) AND Serialize on error types. Both are required for IPC.


Section 4: Async Command Panics

Symptoms

  • App crashes without returning an error to the frontend
  • Console shows thread 'tokio-runtime-worker' panicked
  • invoke() never resolves (hangs in the frontend)

Debugging Steps

Step 4.1: Check for .unwrap() or .expect() on fallible operations inside async commands. Replace with ? operator:

// WRONG: panics on error, crashes the tokio worker thread
#[tauri::command]
async fn read_data(path: String) -> String {
    std::fs::read_to_string(path).unwrap()  // PANICS on missing file
}

// CORRECT: returns error to frontend
#[tauri::command]
async fn read_data(path: String) -> Result<String, Error> {
    let content = std::fs::read_to_string(path)?;
    Ok(content)
}

Step 4.2: Check for borrowed references in async commands. Async commands cannot use &str directly:

// WRONG: compilation error or runtime panic
#[tauri::command]
async fn process(value: &str) -> String {
    value.to_uppercase()
}

// CORRECT: use owned types
#[tauri::command]
async fn process(value: String) -> String {
    value.to_uppercase()
}

// ALSO CORRECT: wrap return in Result to allow &str
#[tauri::command]
async fn process_ref(value: &str) -> Result<String, ()> {
    Ok(value.to_uppercase())
}

Step 4.3: Check for blocking calls inside async commands. Use tokio::task::spawn_blocking for CPU-heavy or blocking I/O work:

#[tauri::command]
async fn heavy_compute(data: Vec<u8>) -> Result<Vec<u8>, Error> {
    let result = tokio::task::spawn_blocking(move || {
        // CPU-intensive work here
        process_data(&data)
    }).await.map_err(|e| Error::Internal(e.to_string()))?;
    Ok(result)
}

Section 5: Structured Error Pattern

Symptoms

  • Frontend catches errors but cannot distinguish error types
  • All errors are plain strings, no programmatic handling possible

Solution: Tagged Enum Errors

For errors the frontend needs to handle differently by type, use a tagged serde enum:

#[derive(serde::Serialize)]
#[serde(tag = "kind", content = "message")]
#[serde(rename_all = "camelCase")]
enum AppError {
    Io(String),
    NotFound(String),
    Unauthorized(String),
    Validation(String),
}

#[tauri::command]
fn load_data(id: u64) -> Result<Data, AppError> {
    // ...
}

Frontend receives typed error objects:

try {
    await invoke('load_data', { id: 42 });
} catch (err: unknown) {
    const error = err as { kind: string; message: string };
    switch (error.kind) {
        case 'notFound':
            showNotFoundUI(error.message);
            break;
        case 'unauthorized':
            redirectToLogin();
            break;
        case 'validation':
            showValidationError(error.message);
            break;
        default:
            showGenericError(error.message);
    }
}

Section 6: Deadlocks & Blocking

Symptoms

  • invoke() hangs forever, never resolves or rejects
  • UI freezes completely
  • App becomes unresponsive

Debugging Steps

Step 6.1: Check for synchronous commands doing I/O. Sync commands run on the main thread and block the entire UI:

// WRONG: blocks main thread
#[tauri::command]
fn read_file(path: String) -> String {
    std::fs::read_to_string(path).unwrap()  // Blocks main thread
}

// CORRECT: async command runs on tokio runtime
#[tauri::command]
async fn read_file(path: String) -> Result<String, Error> {
    let content = tokio::fs::read_to_string(path).await?;
    Ok(content)
}

Step 6.2: Check for nested Mutex locks (deadlock):

// WRONG: if any code path locks the same mutex twice, deadlock
#[tauri::command]
fn update(state: tauri::State<'_, Mutex<AppData>>) {
    let mut data = state.lock().unwrap();
    // ... calls a function that also locks state ...
    helper(&state);  // DEADLOCK if helper also calls state.lock()
}

Step 6.3: Check for std::sync::Mutex held across .await:

// WRONG: std::sync::Mutex blocks the async runtime
#[tauri::command]
async fn bad(state: tauri::State<'_, std::sync::Mutex<Data>>) -> Result<(), Error> {
    let data = state.lock().unwrap();
    some_async_call().await;  // Holding std Mutex across await = potential deadlock
    Ok(())
}

// CORRECT: use tokio::sync::Mutex for locks held across await points
#[tauri::command]
async fn good(state: tauri::State<'_, tokio::sync::Mutex<Data>>) -> Result<(), Error> {
    let data = state.lock().await;
    some_async_call().await;
    Ok(())
}

Frontend Error Handling Pattern

ALWAYS wrap invoke() calls in try/catch. Unhandled rejections cause silent failures:

import { invoke } from '@tauri-apps/api/core';

// Minimal pattern
try {
    const result = await invoke<string>('my_command', { arg: 'value' });
    // handle success
} catch (error: unknown) {
    console.error('Command failed:', error);
    // handle error
}

// Typed wrapper pattern (recommended for reuse)
async function safeInvoke<T>(
    cmd: string,
    args?: Record<string, unknown>
): Promise<{ data: T; error: null } | { data: null; error: string }> {
    try {
        const data = await invoke<T>(cmd, args);
        return { data, error: null };
    } catch (err) {
        return { data: null, error: String(err) };
    }
}

Reference Links

Official Sources

Related skills
Installs
1
GitHub Stars
1
First Seen
Apr 2, 2026