Skip to main content

Command Palette

Search for a command to run...

Iced vs Tauri 2: We Built the Same App Twice in Rust

Pure Rust MVU vs Rust + React IPC — architecture, state management, and async patterns compared side-by-side through the same blockchain application.

Updated
23 min read
Iced vs Tauri 2: We Built the Same App Twice in Rust
B
Author of Rust Blockchain: A Full-Stack Implementation Guide, a 24-chapter, 696-page book covering Axum, Iced, Tauri 2, Tokio, SQLCipher, Docker, and Kubernetes through one cohesive project. I write about building real systems in Rust — from blockchain infrastructure to desktop applications. Currently exploring the move from JVM-heavy data platforms to a Rust-native Big Data stack as a path to 10x more efficient data pipelines, eliminate GC pauses and reduce infrastructure costs using Arrow and DataFusion.

Iced vs Tauri 2: We Built the Same Desktop App Twice in Rust

We built the same blockchain admin dashboard and wallet application twice — once with Iced (pure Rust, MVU architecture) and once with Tauri 2 (Rust backend + React frontend). Both apps connect to the same blockchain node, use the same API crate, and offer identical functionality. The only difference is the desktop framework.

This post walks through the architectural trade-offs we encountered, with real code from both implementations. If you're choosing between Iced and Tauri for a Rust desktop project, this should help you make an informed decision.

The Setup: One Blockchain, Two Desktop Clients

Both applications are admin dashboards for a Bitcoin-style blockchain built entirely in Rust. They let you inspect blocks, manage wallets, send transactions, check balances, and monitor node health. Behind the scenes, both talk to the same REST API via a shared bitcoin-api crate.

The difference is how they render UI and manage state.

Iced is a cross-platform GUI framework for Rust inspired by Elm. You write your entire application — state, logic, rendering — in Rust. There's no web layer, no JavaScript, no HTML. The framework provides widgets, layout primitives, and an event loop built around the Model-View-Update pattern.

Tauri 2 takes a different approach: your backend is Rust, your frontend is a web app (in our case, React + TypeScript), and the two communicate over an IPC bridge. Tauri uses the OS native webview rather than bundling Chromium, which keeps binaries small (5–24 MB vs Electron's 150+ MB).

Architecture at a Glance

Aspect Iced Tauri 2
Languages Rust only Rust + TypeScript
UI paradigm Model-View-Update (Elm-style) React components + IPC
State management Single struct (AdminApp) Zustand (UI) + React Query (server)
Async model Manual spawn_on_tokio bridge Native async fn commands
Styling Built-in Iced widgets Tailwind CSS
Event vocabulary Message enum (~50 variants) React Router routes + query keys
Binary size Smaller Slightly larger
Ecosystem Growing, Rust-native Mature (npm + crates.io)

How Each Framework Is Structured

Before comparing behavior, it helps to see how each project is organized on disk and how data flows through the system.

Iced Project Structure

Everything is Rust. The entire application compiles into a single binary with no bundled web runtime:

bitcoin-desktop-ui-iced/
  src/
    main.rs     # Entry point: boots Tokio runtime, launches Iced app
    runtime.rs  # Tokio bridge: spawn_on_tokio() helper
    types.rs    # Message enum (all events) + Menu enum (navigation)
    app.rs      # AdminApp struct (the single state container)
    update.rs   # update() function: Message -> state mutation + Tasks
    view.rs     # view() function: &Model -> UI elements (pure, no mutation)
    api.rs      # Async HTTP calls to the blockchain node REST API

Data flows in a strict loop. There are no callbacks, no event listeners, no shared mutable references between components — just one cycle that repeats for every user interaction.

Iced MVU event loop (single Rust binary):

  (view)
    |
    | Message
    v
  (update) ---- Task::perform() ---> [Tokio runtime]
    ^                                 |
    | new state                        | HTTP
    |                                  v
    +-------------------------- [Blockchain node]
                                 (REST API)

The key insight: view() never mutates state. It reads &Model and returns a description of the UI. When the user clicks something, Iced creates a Message, feeds it to update(), which mutates the model and optionally kicks off async work. When that async work completes, it produces another Message, and the cycle repeats.

Tauri Project Structure

Tauri splits the project into two halves — a Rust backend and a React frontend — connected by IPC:

bitcoin-desktop-ui-tauri/
  src-tauri/                   # --- RUST BACKEND ---
    src/
      main.rs                  # Tauri app builder: registers commands + state
      config/mod.rs            # ApiConfig: base_url + api_key (behind RwLock)
      models/mod.rs            # Shared types: WalletAddress, BlockSummary, etc.
      services/bitcoin_api.rs  # HTTP client wrapping the bitcoin-api crate
      commands/                # #[tauri::command] handlers (the IPC surface)
        blockchain.rs          #   get_blockchain_info, get_block, etc.
        wallet.rs              #   create_wallet, get_balance, etc.
        transactions.rs        #   send_transaction, get_history, etc.
        mining.rs              #   mine_block, get_mining_status
        health.rs              #   health_check
        settings.rs            #   get_config, update_config
  src/                         # --- REACT FRONTEND ---
    main.tsx                   # React app entry point
    App.tsx                    # Router setup + layout
    lib/commands.ts            # invoke() wrappers — typed bridge to Rust
    store/useAppStore.ts       # Zustand: UI state (theme, sidebar, wallet)
    hooks/                     # React Query hooks for each data domain
    components/                # Shared UI: DataCard, DataTable, Sidebar, etc.
    pages/                     # 18 page components across 5 sections

The data flow crosses a language boundary at the IPC bridge:

  [Page component]
    | \
    |  \--> [Zustand store] (UI state)
    |
    v
  [React Query]
    |
    | invoke('command')  (JSON over IPC)
    v
  [#[tauri::command] async fn]
    | \
    |  \--> [Read config (RwLock)]
    |
    v
  [Service layer] -- HTTP --> [Blockchain node]
    ^
    |
  Result<T> (JSON back to React Query)

The key insight: the IPC bridge is a serialization boundary. Every value that crosses it must be JSON-serializable. The React frontend calls invoke('command_name'), Tauri routes it to the matching #[tauri::command] Rust function, and the result comes back as a JavaScript object. The frontend never touches the Tokio runtime, the service layer, or the HTTP client — it only sees typed Promises.

State Management: One Struct vs Two Layers

This is where the philosophies diverge most sharply.

Iced: Everything in One Place

In Iced, your entire application state lives in a single Rust struct. Every field is visible, every mutation flows through a single update function, and the compiler enforces it all.

pub struct AdminApp {
    // Navigation: which screen the user is looking at right now
    menu: Menu,

    // Cached API responses — Option<T> because they start empty,
    // then get populated when async fetches complete
    blockchain_info: Option<BlockchainInfo>,
    wallet_addresses: Vec<WalletAddress>,
    selected_block: Option<Block>,

    // UI feedback: shows success/error messages in the status bar
    status_message: String,

    // Form inputs: every text field the user can type into
    // lives here as owned Strings, not in the widget itself
    form_fields: FormState,

    // ... every piece of state in the entire app, right here
    // in one struct. Nothing is hidden.
}

When the user clicks a button, Iced produces a Message — a variant of a Rust enum that represents every possible event in the application. That message flows to the update function, which pattern-matches on it and returns the next state plus any async tasks to run:

// The Message enum is the complete vocabulary of things that can happen.
// Adding a new feature means adding a new variant — the compiler then
// forces you to handle it everywhere.
enum Message {
    FetchBlockchainInfo,                        // User clicked "Refresh"
    BlockchainInfoLoaded(Result<BlockchainInfo, Error>), // API responded
    MenuChanged(Menu),                          // User navigated
    WalletSelected(String),                     // User picked a wallet
    // ... ~50 total variants across all screens
}

// update() is the brain of the application.
// It receives the current state (&mut self) and a Message,
// performs any synchronous state changes, and returns async
// work to do (if any). The return type Task<Message> means
// "when this async work finishes, feed the result back as
// another Message."
fn update(&mut self, message: Message) -> Task<Message> {
    match message {
        // Step 1: User clicks "Refresh" -> we kick off an API call.
        // spawn_on_tokio bridges Iced's sync update loop into
        // our Tokio runtime (see the Async section below).
        // Message::BlockchainInfoLoaded is the "callback" —
        // Iced will call update() again with that variant
        // when the future completes.
        Message::FetchBlockchainInfo => {
            Task::perform(
                spawn_on_tokio(api::get_blockchain_info()),
                Message::BlockchainInfoLoaded,
            )
        }

        // Step 2: API call finished -> store the result in our model.
        // Task::none() means "no further async work needed."
        // Iced will call view() next to re-render with the new data.
        Message::BlockchainInfoLoaded(result) => {
            self.blockchain_info = result.ok();
            Task::none()
        }

        // ... pattern continues for every interaction in the app
    }
}

Every possible event in the application has a name and a type. Nothing is implicit. If you forget to handle a variant, the compiler tells you. This is Iced's core trade-off: more code upfront, but the type system guarantees you've handled every case.

Tauri: Split by Concern

Tauri splits state into two layers. Ephemeral UI state (which menu is open, light/dark theme, toast notifications) lives in a Zustand store on the React side:

// Zustand store: lightweight, React-external state container.
// This holds UI-only concerns — things that don't come from the server.
// Zustand is chosen over React Context because it doesn't cause
// unnecessary re-renders of unrelated components.
const useAppStore = create<AppState>((set) => ({
  theme: 'dark',           // UI preference — persists across page navigations
  sidebarOpen: true,       // Layout state — not worth fetching from Rust
  activeWallet: null,      // Currently selected wallet address (string | null)

  // Actions: plain functions that call set() to update state.
  // Any React component can call these without prop drilling.
  setActiveWallet: (wallet) => set({ activeWallet: wallet }),
}));

Server state (blockchain info, wallet balances, transaction history) is managed by React Query, which handles caching, background refetching, and loading states automatically:

// React Query: manages everything that comes from the Rust backend.
// You declare WHAT data you need; React Query handles WHEN to fetch,
// WHEN to refetch, and WHAT to show while loading.
const { data, isLoading, error } = useQuery({
  queryKey: ['blockchain-info'],    // Cache key — React Query deduplicates
                                    // requests with the same key
  queryFn: () => commands.getBlockchainInfo(),  // The actual IPC call to Rust
  refetchInterval: 30_000,          // Auto-refresh every 30 seconds
                                    // (blockchain data changes as blocks are mined)
});
// At this point:
//   - isLoading = true on first load, false after
//   - data = the BlockchainInfo object from Rust, or undefined
//   - error = any error from the invoke() call
// React Query also gives you: isFetching, isStale, refetch(), and more

The Rust backend holds only shared configuration behind a RwLock:

pub struct AppState {
    pub api_config: ApiConfig,      // base_url + api_key for the blockchain node
    pub active_wallet: Option<String>, // Currently selected wallet (synced from frontend)
}

// Tauri manages this as shared state across all command handlers.
// RwLock allows multiple commands to READ config concurrently
// (e.g., 3 parallel queries from React Query), while only one
// can WRITE at a time (e.g., when the user updates settings).
// This is Tauri's State extractor — injected automatically into
// any command that declares it as a parameter.
State<RwLock<AppState>>

This is less unified than Iced's single-struct model, but each layer does what it's best at: React Query handles cache invalidation and refetch logic that would require manual wiring in Iced, while Zustand provides ergonomic UI state without the ceremony of message enums.

The Async Boundary: Two Very Different Approaches

Async is where Iced requires the most manual plumbing and where Tauri's two-language architecture actually simplifies things.

Iced: The Tokio Bridge

Iced's event loop is synchronous. The update function can't be async. To make API calls, you need to bridge into a Tokio runtime:

// Problem: Iced's update() is synchronous, but our API calls need Tokio.
// Solution: Boot a Tokio runtime on a separate thread at startup,
// then hand futures to it from the synchronous update loop.

// Global handle to the Tokio runtime. OnceCell ensures it's
// initialized exactly once and then available everywhere.
static RUNTIME: OnceCell<Runtime> = OnceCell::new();

fn init_runtime() {
    // Spawn a dedicated OS thread for the Tokio runtime.
    // This thread lives for the entire application lifetime.
    std::thread::spawn(|| {
        let rt = Runtime::new().unwrap();
        RUNTIME.set(rt).unwrap();
        // park() blocks this thread forever without consuming CPU.
        // The runtime stays alive because the Runtime object isn't dropped.
        std::thread::park();
    });
}

// This is the bridge between Iced's sync world and Tokio's async world.
// Call it from update() like:
//   Task::perform(spawn_on_tokio(api::get_balance(addr)), Message::BalanceLoaded)
//
// What happens:
//   1. update() calls spawn_on_tokio(some_future)
//   2. spawn_on_tokio sends that future to the Tokio runtime thread
//   3. Tokio executes it (HTTP call, database query, etc.)
//   4. The result comes back as the return value
//   5. Iced wraps it in Message::SomeLoaded and calls update() again
async fn spawn_on_tokio<F, T>(future: F) -> T
where
    F: Future<Output = T> + Send + 'static,
    T: Send + 'static,
{
    let handle = RUNTIME.get().unwrap().handle().clone();
    handle.spawn(future).await.unwrap()
}

Every API call follows the same round-trip:

User clicks "Refresh"
  -> update() receives Message::FetchBlockchainInfo
    -> Task::perform(spawn_on_tokio(api_call), Message::Loaded)
      -> Tokio runtime executes the HTTP request
        -> Result arrives as Message::BlockchainInfoLoaded(Ok(data))
          -> update() stores data in self.blockchain_info
            -> view() re-renders with new data

This is explicit and traceable, but it's boilerplate you write for every async operation.

Tauri: Just Write async fn

Tauri commands are natively async. No runtime bridging, no message round-trips:

// This attribute is all Tauri needs to expose a Rust function to JavaScript.
// Tauri handles: async runtime, JSON serialization, IPC routing, error conversion.
#[tauri::command]
async fn get_blockchain_info(
    // Tauri injects this automatically — it's the shared AppState
    // we registered in main.rs with .manage(RwLock::new(AppState::default()))
    state: State<'_, RwLock<AppState>>,
) -> Result<Value, String> {
    // Step 1: Acquire a read lock on shared config.
    // Multiple commands can read concurrently (RwLock allows many readers).
    let config = state.read().await;

    // Step 2: Create a service instance with the current API configuration.
    // This is the same bitcoin-api crate that Iced uses — identical HTTP calls.
    let service = BitcoinApiService::new(&config.api_config);

    // Step 3: Make the HTTP call and convert any error to a String.
    // Tauri will serialize the Ok value to JSON and send it across IPC.
    // The frontend receives it as a resolved Promise<BlockchainInfo>.
    service.get_blockchain_info()
        .await
        .map_err(|e| e.to_string())
}

On the frontend, the entire call is one line — invoke() handles IPC serialization, async execution, and error propagation:

// commands.ts — thin typed wrappers around Tauri's invoke().
// The string 'get_blockchain_info' must exactly match the Rust function name.
// The generic <BlockchainInfo> tells TypeScript what the Promise resolves to.
export async function getBlockchainInfo(): Promise<BlockchainInfo> {
  return invoke<BlockchainInfo>('get_blockchain_info');
}

// For commands that take arguments, pass them as an object:
export async function getBlock(hash: string): Promise<Block> {
  return invoke<Block>('get_block', { hash });
  // Tauri deserializes { hash } into the Rust function's parameters
}

The entire Iced pattern — Message::Fetchspawn_on_tokioTask::performMessage::Loaded — collapses into one invoke() call that returns a Promise. That's not a minor ergonomic difference; across 22 commands in the admin UI alone, it eliminates roughly 44 message variants and their associated match arms.

The View Layer: Widgets vs JSX

Iced: Rust All the Way Down

Iced views are pure functions from &Model to Element<Message>. No mutation, no side effects — just a description of what the screen should look like:

// view() is a PURE function: it reads &self (immutable borrow) and
// returns a tree of UI elements. It never mutates state, never makes
// API calls, never has side effects. Iced calls it after every update().
//
// Each widget can produce a Message. For example, a button might produce
// Message::FetchBlockchainInfo when clicked. That message feeds back
// into update(), completing the MVU loop.
fn view(&self) -> Element<Message> {
    // Route to the correct screen based on current navigation state.
    // Each view_* method returns an Element<Message> — a subtree of widgets.
    let content = match self.menu {
        Menu::BlockchainInfo => self.view_blockchain_info(),
        Menu::WalletList => self.view_wallet_list(),
        Menu::Send => self.view_send_form(),
        // ... one arm per screen in the app
    };

    // Compose the layout: sidebar on the left, content in the center,
    // status bar at the bottom. column! stacks children vertically.
    // container wraps everything with padding and alignment.
    container(
        column![
            self.view_sidebar(),    // Navigation menu (produces MenuChanged messages)
            content,                // Active screen (produces screen-specific messages)
            self.view_status_bar(), // Shows self.status_message at the bottom
        ]
    ).into()
}

Styling is done through Iced's widget API. You compose layouts with row![] (horizontal), column![] (vertical), container (wrapper with padding/alignment), and apply spacing programmatically. It's powerful, but there's no CSS — if you want a specific visual treatment, you build it from widget primitives and theme overrides.

Tauri: React + Tailwind

The Tauri frontend is a standard React application. Pages are components, routing is React Router, and styling is Tailwind:

// A typical Tauri page component. This is standard React — nothing
// Tauri-specific except that commands.getBlockchainInfo() calls
// invoke() under the hood instead of fetch().
export function BlockchainInfoPage() {
  // useQuery handles the entire data lifecycle:
  //   1. First render: isLoading=true, fires queryFn
  //   2. queryFn calls invoke('get_blockchain_info') -> IPC -> Rust -> HTTP
  //   3. Result arrives: isLoading=false, data=BlockchainInfo object
  //   4. On re-mount or stale timeout: refetches silently in background
  const { data, isLoading } = useQuery({
    queryKey: ['blockchain-info'],
    queryFn: commands.getBlockchainInfo,
  });

  // Loading state — React Query gives you this for free.
  // In Iced, you'd need a boolean field in your model and
  // a Message variant to toggle it.
  if (isLoading) return <LoadingSpinner />;

  // Render the data using Tailwind utility classes.
  // p-6 = padding 1.5rem, space-y-4 = vertical gap between children.
  // DataCard is a shared component used across all pages.
  return (
    <div className="p-6 space-y-4">
      <h1 className="text-2xl font-bold">Blockchain Info</h1>
      <DataCard title="Height" value={data?.height} />
      <DataCard title="Difficulty" value={data?.difficulty} />
      {/* data?.height uses optional chaining — safe because
          React Query guarantees data exists when isLoading is false,
          but TypeScript doesn't know that without a type guard */}
    </div>
  );
}

This is familiar territory for anyone who has built a web app. The npm ecosystem provides component libraries, form validation (react-hook-form + zod), charting, and every other UI concern you can think of. The trade-off is that you're now maintaining two languages and a serialization boundary between them.

Where It Gets Interesting: The Wallet

The wallet application is where architectural differences become most consequential, because it introduces local persistence via SQLCipher — an encrypted SQLite database.

Both implementations use the same deterministic key generation to derive the database password from the local username, home directory, and application name. Same function, same output, same database file. The Iced wallet and Tauri wallet can even read each other's databases.

But the data flow is different.

Iced wallet operations are multi-step message chains. Creating a wallet means: Message::CreateWallet → API call → Message::WalletCreated(Result) → persist to SQLCipher → Message::WalletSaved → refresh wallet list → Message::WalletsLoaded. Each step is an explicit enum variant.

Tauri wallet operations collapse that chain into a single command:

#[tauri::command]
async fn create_wallet(
    state: State<'_, RwLock<AppState>>,
) -> Result<WalletAddress, String> {
    let config = state.read().await;
    let service = BitcoinApiService::new(&config.api_config);

    // PHASE 1: Remote — ask the blockchain node to generate a new wallet address.
    // This is an HTTP POST to the node's REST API via the bitcoin-api crate.
    // The node generates a keypair and returns the public address.
    let address = service.create_wallet().await.map_err(|e| e.to_string())?;

    // PHASE 2: Local — persist the new address in the encrypted SQLCipher database.
    // This ensures the wallet survives app restarts. The database is encrypted
    // with a deterministic key derived from the local username + home directory,
    // so no password prompt is needed.
    database::save_wallet_address(&address).map_err(|e| e.to_string())?;

    // The frontend receives a single WalletAddress object.
    // It has no idea that two separate operations happened — one remote,
    // one local. If either fails, the ? operator short-circuits and the
    // frontend gets an Err(String) which React Query surfaces as error state.
    Ok(address)
}

On the React side, the mutation is equally concise:

// React Query mutation — handles the invoke() call + cache invalidation.
// When create_wallet succeeds, invalidateQueries forces the wallet list
// to refetch, so the new wallet appears immediately without a manual refresh.
const createWallet = useMutation({
  mutationFn: () => commands.createWallet(),
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['wallets'] });
  },
});

The frontend sees one invoke() call, gets one result, and React Query handles cache invalidation. The two-phase nature (remote call + local save) is invisible to the UI layer.

This highlights a fundamental trade-off: Iced's message chain makes every step visible and debuggable at the type level, but it's verbose. Tauri's command encapsulation is concise, but the two-phase logic is hidden inside an opaque async function.

Parallel Data Loading

When the user selects a wallet, both apps need to fetch wallet info, balance, and transaction history simultaneously. The approaches reflect each framework's async model.

Iced uses Task::batch to fan out multiple async operations from a single message handler:

Message::WalletSelected(address) => {
    // Store the selected wallet in our model
    self.active_wallet = Some(address.clone());

    // Clear any stale data from the previously selected wallet
    self.wallet_info = None;
    self.balance = None;
    self.transactions = Vec::new();

    // Fire three API calls in parallel using Task::batch.
    // Each call runs independently on the Tokio runtime.
    // Each produces a different Message variant when it completes.
    // The UI will update incrementally as results arrive —
    // balance might render before transactions, for example.
    Task::batch([
        Task::perform(
            spawn_on_tokio(api::fetch_wallet_info(address.clone())),
            Message::WalletInfoLoaded,   // -> update() stores wallet metadata
        ),
        Task::perform(
            spawn_on_tokio(api::fetch_balance(address.clone())),
            Message::BalanceLoaded,      // -> update() stores balance amount
        ),
        Task::perform(
            spawn_on_tokio(api::fetch_transactions(address)),
            Message::TransactionsLoaded, // -> update() stores tx history
        ),
    ])
}

Three tasks launch in parallel. Each completes independently and produces its own message. You see exactly what's happening and in what order.

Tauri achieves the same parallelism through React Query — multiple useQuery hooks on the same page fire their queries independently:

// React Query hooks declared in the same component.
// React Query automatically deduplicates and parallelizes these —
// all three invoke() calls fire simultaneously when the component mounts.
// Each has its own loading/error/data state, so the UI can show
// partial results as they arrive.

const walletInfo = useQuery({
  queryKey: ['wallet', address],   // Cache key includes the address, so
  queryFn: () => commands.getWalletInfo(address),  // switching wallets refetches
});

const balance = useQuery({
  queryKey: ['balance', address],
  queryFn: () => commands.getBalance(address),
  refetchInterval: 15_000,         // Balance changes when transactions confirm —
                                   // poll every 15s to stay current
});

const history = useQuery({
  queryKey: ['history', address],
  queryFn: () => commands.getTransactions(address),
});

Same result, less ceremony. React Query manages concurrency, caching, and refetching — but that logic is implicit in the framework rather than explicit in your code.

Side-by-Side: The Same Operation in Both Frameworks

To make the architectural difference concrete, here's the complete code path for one operation — "user clicks Refresh to load blockchain info" — in both frameworks.

Iced: 6 touch points across 4 files

// --- types.rs: Define the event ---
enum Message {
    FetchBlockchainInfo,                                // 1. Event name
    BlockchainInfoLoaded(Result<BlockchainInfo, Error>), // 2. Completion event
    // ...
}

// --- api.rs: Define the async call ---
pub async fn get_blockchain_info() -> Result<BlockchainInfo, Error> {
    let client = AdminClient::new(&base_url, &api_key);  // 3. HTTP call
    client.get_blockchain_info().await
}

// --- update.rs: Wire the event to the async call ---
Message::FetchBlockchainInfo => {                         // 4. Dispatch
    Task::perform(
        spawn_on_tokio(api::get_blockchain_info()),
        Message::BlockchainInfoLoaded,
    )
}
Message::BlockchainInfoLoaded(result) => {                // 5. Handle result
    self.blockchain_info = result.ok();
    Task::none()
}

// --- view.rs: Render a button that produces the event ---
button("Refresh")                                         // 6. UI trigger
    .on_press(Message::FetchBlockchainInfo)

Tauri: 3 touch points across 3 files

// --- commands/blockchain.rs: Define the command ---
#[tauri::command]
async fn get_blockchain_info(                             // 1. Command handler
    state: State<'_, RwLock<AppState>>,
) -> Result<Value, String> {
    let config = state.read().await;
    let service = BitcoinApiService::new(&config.api_config);
    service.get_blockchain_info().await.map_err(|e| e.to_string())
}
// --- lib/commands.ts: Typed invoke wrapper ---
export async function getBlockchainInfo() {               // 2. IPC bridge
  return invoke<BlockchainInfo>('get_blockchain_info');
}

// --- pages/BlockchainInfoPage.tsx: UI + data in one place ---
const { data, isLoading, refetch } = useQuery({           // 3. Fetch + render
  queryKey: ['blockchain-info'],
  queryFn: commands.getBlockchainInfo,
});
// ...
<button onClick={() => refetch()}>Refresh</button>

The Iced version is more code, but every step is named and typed. The Tauri version is more concise, but the async lifecycle (loading states, error handling, caching) is managed by React Query rather than your own code.

When to Choose Which

After building both implementations side-by-side, here's our take:

Choose Iced when:

  • You want a single-language codebase with no JavaScript dependency

  • Compile-time type safety across the entire application matters to you

  • You prefer explicit, auditable control flow over implicit framework behavior

  • Your team is strong in Rust but less experienced with web frontends

  • Binary size is a priority (Iced produces smaller executables)

Choose Tauri 2 when:

  • You want to leverage the React/npm ecosystem for UI

  • Your team has frontend web experience alongside Rust skills

  • You need rapid UI iteration (hot reload, CSS utilities, component libraries)

  • Async operations are central to your app and you want ergonomic handling

  • You're building something visually complex where CSS and web layout shine

Choose both when:

  • You're writing a book about desktop development in Rust and want to give readers the full picture

Neither framework is objectively better. Iced trades ecosystem breadth for language unity and type-level guarantees. Tauri trades language unity for ergonomics and access to the web platform. The right choice depends on your team, your project, and what you value most.

What We Learned

Building the same application twice was instructive in ways we didn't expect. The Message enum in Iced, which initially felt like boilerplate, became a comprehensive protocol of everything the application can do — a property that's genuinely valuable for debugging and reasoning about state. Meanwhile, Tauri's invoke() pattern, which felt like it was hiding complexity, turned out to produce code that was easier to onboard new contributors to.

The most surprising finding: the backend code was nearly identical. Both apps use the same bitcoin-api crate, the same SQLCipher database schema, and the same deterministic key derivation. The framework choice only affects the presentation layer and the plumbing between it and the business logic. If you design your Rust backend as a clean service layer, you can put any frontend on top of it.

Explore the Source Code

The complete source code for both implementations is open source. You can clone the repo and run either application locally:

Full repository: github.com/bkunyiha/rust-blockchain

Iced implementations:

Tauri implementations:

Compare the two approaches side-by-side in the same workspace — both consume the shared bitcoin-api crate, so the backend boundary is identical.


This post is adapted from Rust Blockchain: A Full-Stack Implementation Guide — a 33-chapter book covering Axum, Iced, Tauri 2, Tokio, SQLCipher, Docker, and Kubernetes through one cohesive project.

Available now on Amazon (paperback + Kindle + hardcover), Gumroad (PDF + EPUB), and Leanpub (pay what you want).

Free resource: Clone the Starter Template

Follow buildwithrust.com for weekly posts on building production systems in Rust.


Tags: rust, blockchain, iced, tauri, desktop-development, framework-comparison