<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Build with Rust]]></title><description><![CDATA[Hands-on Rust for engineers who build real systems. Deep dives into blockchain, desktop applications, web APIs, big data, and full-stack architecture — with complete code, architectural walkthroughs, and production-ready patterns. Companion blog to the Build with Rust book series.]]></description><link>https://buildwithrust.com</link><image><url>https://cdn.hashnode.com/uploads/logos/69c733287cf2706510824caa/fad8fca3-b177-4692-bea1-fe008d2151e6.png</url><title>Build with Rust</title><link>https://buildwithrust.com</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 07 Apr 2026 17:40:16 GMT</lastBuildDate><atom:link href="https://buildwithrust.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Rust Axum REST API: Auth, CORS, Rate Limiting & OpenAPI]]></title><description><![CDATA[Building a Production REST API with Axum: Authentication, CORS, Rate Limiting, and OpenAPI
We built a complete REST API for a Bitcoin-style blockchain node using Axum, Rust's async web framework built]]></description><link>https://buildwithrust.com/rust-axum-rest-api-auth-cors-rate-limiting-openapi</link><guid isPermaLink="true">https://buildwithrust.com/rust-axum-rest-api-auth-cors-rate-limiting-openapi</guid><category><![CDATA[Rust]]></category><category><![CDATA[axum]]></category><category><![CDATA[Async Rust]]></category><category><![CDATA[tokio rust]]></category><category><![CDATA[rust lang]]></category><category><![CDATA[Rust Web]]></category><dc:creator><![CDATA[Bill Kunyiha]]></dc:creator><pubDate>Sun, 05 Apr 2026 23:35:48 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/0d6e9bd5-86b9-4893-aaf8-167683a835b9.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Building a Production REST API with Axum: Authentication, CORS, Rate Limiting, and OpenAPI</h1>
<p>We built a complete REST API for a Bitcoin-style blockchain node using <a href="https://github.com/tokio-rs/axum">Axum</a>, Rust's async web framework built on <a href="https://tokio.rs">Tokio</a> and <a href="https://github.com/tower-rs/tower">Tower</a>. The API handles wallet management, transaction submission, block queries, mining operations, and health monitoring — all with role-based authentication, CORS, rate limiting, and auto-generated OpenAPI documentation.</p>
<p>This post walks through the architecture and implementation patterns we used, with real code from the project. If you're building a REST API in Rust with Axum, these patterns transfer directly to any domain — not just blockchain.</p>
<h2>What We're Building</h2>
<p>The API serves as the primary interface to a blockchain node. Desktop clients (we built two — one in <a href="https://iced.rs">Iced</a>, one in <a href="https://tauri.app">Tauri 2</a>), web browsers, and programmatic clients all interact with the node through this single HTTP layer.</p>
<p>Here's the endpoint surface:</p>
<table>
<thead>
<tr>
<th>Group</th>
<th>Endpoints</th>
<th>Auth Required</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Blockchain</strong></td>
<td><code>GET /api/v1/blockchain</code>, <code>/blocks</code>, <code>/blocks/latest</code>, <code>/blocks/{hash}</code></td>
<td>Admin</td>
</tr>
<tr>
<td><strong>Wallet</strong></td>
<td><code>POST /wallet</code>, <code>GET /addresses</code>, <code>/{address}</code>, <code>/{address}/balance</code></td>
<td>Admin or Wallet</td>
</tr>
<tr>
<td><strong>Transactions</strong></td>
<td><code>POST /transactions</code>, <code>GET /transactions</code>, <code>/mempool</code>, <code>/address/{address}</code></td>
<td>Admin or Wallet</td>
</tr>
<tr>
<td><strong>Mining</strong></td>
<td><code>GET /mining/info</code>, <code>POST /mining/generatetoaddress</code></td>
<td>Admin</td>
</tr>
<tr>
<td><strong>Health</strong></td>
<td><code>GET /health</code>, <code>/health/live</code>, <code>/health/ready</code></td>
<td>None</td>
</tr>
<tr>
<td><strong>Docs</strong></td>
<td><code>GET /swagger-ui</code></td>
<td>None</td>
</tr>
</tbody></table>
<p>Every request passes through a middleware pipeline before reaching a handler. Let's start with the architecture.</p>
<h2>Architecture Overview</h2>
<p>The API follows a layered architecture where each component has a single responsibility. Routes map URLs to handlers. Handlers contain business logic. Middleware handles cross-cutting concerns. Models define request and response shapes. The server orchestrates everything.</p>
<pre><code>bitcoin/src/web/
├── server.rs           # Server initialization + middleware stack
├── routes/
│   ├── api.rs          # API route definitions
│   └── web.rs          # Swagger UI + static files
├── handlers/
│   ├── blockchain.rs   # Block and chain queries
│   ├── wallet.rs       # Wallet creation + balance
│   ├── transaction.rs  # Transaction submission + history
│   ├── mining.rs       # Block generation
│   └── health.rs       # Liveness + readiness probes
├── middleware/
│   ├── auth.rs         # API key authentication
│   ├── cors.rs         # CORS configuration
│   ├── logging.rs      # Structured logging
│   └── rate_limit.rs   # Token bucket rate limiting
├── models/
│   ├── requests.rs     # Typed request bodies
│   ├── responses.rs    # Typed response envelopes
│   └── errors.rs       # Error types + HTTP mapping
└── openapi.rs          # Utoipa OpenAPI spec
</code></pre>
<p>This structure is intentionally flat. Every handler file maps to one domain. Every middleware file handles one concern. You can find any piece of code in seconds.</p>
<h2>The Request Pipeline</h2>
<p>Every HTTP request flows through the same sequence of middleware layers before reaching a handler. Understanding this pipeline is essential for debugging and extending the API.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/34a4ee36-8bb9-4f4d-9bbf-9baa50931b7e.png" alt="Request Pipeline — each HTTP request flows through CORS, compression, error handling, rate limiting, routing, and authentication before reaching the handler" /></p>
<p>The order matters. CORS runs first because browsers send preflight <code>OPTIONS</code> requests that should be handled before any authentication check. Error handling wraps everything inside it so that even middleware failures produce clean JSON responses. Authentication runs per-route (not globally) because health checks are public.</p>
<h2>Server Setup</h2>
<p>The server is configured through a <code>WebServer</code> struct that holds configuration and a shared reference to the blockchain node:</p>
<pre><code class="language-rust">pub struct WebServer {
    config: WebServerConfig,
    node: Arc&lt;NodeContext&gt;,
}

#[derive(Debug, Clone)]
pub struct WebServerConfig {
    pub host: String,
    pub port: u16,
    pub enable_cors: bool,
    pub enable_rate_limiting: bool,
    pub rate_limit_requests_per_second: u32,
    pub rate_limit_burst_size: u32,
}
</code></pre>
<p>The <code>Arc&lt;NodeContext&gt;</code> is the key design decision. Every handler receives a reference-counted pointer to the same blockchain node. There's no global state, no mutex contention on the hot path, and the type system enforces that handlers can only read blockchain data through the <code>NodeContext</code> interface.</p>
<p>Building the application router combines routes and middleware in a single <code>create_app()</code> method:</p>
<pre><code class="language-rust">pub fn create_app(
    &amp;self,
) -&gt; Result&lt;Router, Box&lt;dyn std::error::Error + Send + Sync&gt;&gt; {
    let app = Router::new()
        .merge(create_all_api_routes())
        .merge(create_wallet_only_routes())
        .merge(create_web_routes())
        .with_state(self.node.clone());

    let mut app = app;

    // Rate limiting (conditional)
    if self.config.enable_rate_limiting {
        let rl_config = RateLimitConfig::default();
        if let Some(manager) = build_rate_limiter_manager(&amp;rl_config)? {
            app = app.layer(from_fn_with_state(
                manager,
                axum_rate_limiter::limiter::middleware,
            ));
        }
    }

    // CORS (conditional)
    if self.config.enable_cors {
        app = app.layer(cors::create_cors_layer());
    }

    // Compression + error handling (always on)
    app = app.layer(CompressionLayer::new());
    app = app.layer(axum::middleware::from_fn(handle_errors));

    Ok(app)
}
</code></pre>
<p>Three route sets are merged into one router: the main API endpoints, wallet-specific endpoints with their own auth, and web routes (Swagger UI). State is injected once with <code>.with_state()</code>, then middleware layers are applied conditionally based on configuration.</p>
<p>The server starts with graceful shutdown support — in-flight requests complete before the process exits:</p>
<pre><code class="language-rust">pub async fn start_with_shutdown(
    &amp;self,
) -&gt; Result&lt;(), Box&lt;dyn std::error::Error + Send + Sync&gt;&gt; {
    let app = self.create_app()?;
    let addr = SocketAddr::from(([0, 0, 0, 0], self.config.port));

    let listener = tokio::net::TcpListener::bind(addr).await?;

    let shutdown_signal = async {
        tokio::signal::ctrl_c()
            .await
            .expect("Failed to install CTRL+C signal handler");
        tracing::info!("Shutdown signal received");
    };

    axum::serve(
        listener,
        app.into_make_service_with_connect_info::&lt;SocketAddr&gt;(),
    )
    .with_graceful_shutdown(shutdown_signal)
    .await?;

    Ok(())
}
</code></pre>
<p>The <code>into_make_service_with_connect_info::&lt;SocketAddr&gt;()</code> call preserves client IP addresses through the service layer — essential for per-IP rate limiting.</p>
<h2>Route Organization</h2>
<p>Routes are organized into four groups, each with appropriate authentication:</p>
<pre><code class="language-rust">// Public API routes — full endpoint set, requires admin auth
pub fn create_api_routes() -&gt; Router&lt;Arc&lt;NodeContext&gt;&gt; {
    Router::new()
        .route("/blockchain", get(blockchain::get_blockchain_info))
        .route("/blockchain/blocks", get(blockchain::get_blocks))
        .route("/blockchain/blocks/latest", get(blockchain::get_latest_blocks))
        .route("/blockchain/blocks/{hash}", get(blockchain::get_block_by_hash))
        .route("/wallet", post(wallet::create_wallet))
        .route("/wallet/{address}/balance", get(wallet::get_balance))
        .route("/transactions", post(transaction::send_transaction))
        .route("/transactions/mempool", get(transaction::get_mempool))
        .route("/mining/info", get(mining::get_mining_info))
        .route("/mining/generatetoaddress", post(mining::generate_to_address))
}

// Admin routes — nests API routes under /api/admin with admin auth
pub fn create_admin_api_routes() -&gt; Router&lt;Arc&lt;NodeContext&gt;&gt; {
    Router::new()
        .nest("/api/admin", create_api_routes())
        .nest("/api/admin", create_monitor_api_routes())
        .layer(axum::middleware::from_fn(require_admin))
}

// Wallet routes — limited subset with wallet-level auth
pub fn create_wallet_only_routes() -&gt; Router&lt;Arc&lt;NodeContext&gt;&gt; {
    let wallet_only = Router::new()
        .route("/wallet", post(wallet::create_wallet))
        .route("/transactions", post(transaction::send_transaction));

    Router::new()
        .nest("/api/wallet", wallet_only)
        .layer(axum::middleware::from_fn(require_wallet))
}

// Health checks — public, no auth
pub fn create_monitor_api_routes() -&gt; Router&lt;Arc&lt;NodeContext&gt;&gt; {
    Router::new()
        .route("/health", get(health::health_check))
        .route("/health/live", get(health::liveness))
        .route("/health/ready", get(health::readiness))
}
</code></pre>
<p>The separation between admin and wallet routes is deliberate. Wallet applications get a limited-privilege API key that can only create wallets and submit transactions. Admin tools get a separate key with full access. This follows the principle of least privilege — a compromised wallet key can't dump the entire blockchain or trigger mining operations.</p>
<h2>Handler Patterns</h2>
<p>Every handler follows the same structure: extract inputs, process the request through <code>NodeContext</code>, and return a typed JSON response. Here's the blockchain info handler:</p>
<pre><code class="language-rust">pub async fn get_blockchain_info(
    State(node): State&lt;Arc&lt;NodeContext&gt;&gt;,
) -&gt; Result&lt;Json&lt;ApiResponse&lt;BlockchainInfoResponse&gt;&gt;, StatusCode&gt; {
    let height = node.get_blockchain_height().await
        .map_err(|e| {
            error!("Failed to get blockchain height: {}", e);
            StatusCode::INTERNAL_SERVER_ERROR
        })?;

    let last_block = node.blockchain().get_last_block().await
        .map_err(|e| {
            error!("Failed to get last block: {}", e);
            StatusCode::INTERNAL_SERVER_ERROR
        })?;

    let mempool_size = node.get_mempool_size()
        .map_err(|e| {
            error!("Failed to get mempool size: {}", e);
            StatusCode::INTERNAL_SERVER_ERROR
        })?;

    let info = BlockchainInfoResponse {
        height,
        difficulty: 1,
        total_blocks: height,
        mempool_size,
        last_block_hash: last_block.get_hash().to_string(),
        last_block_timestamp: chrono::Utc::now(),
    };

    Ok(Json(ApiResponse::success(info)))
}
</code></pre>
<p>The <code>State(node)</code> extractor gives the handler a reference to the blockchain node. Axum handles the extraction at compile time — if the type doesn't match what <code>.with_state()</code> provided, you get a compile error, not a runtime panic.</p>
<p>For endpoints that take path parameters, Axum can deserialize directly into custom types:</p>
<pre><code class="language-rust">pub async fn get_balance(
    State(node): State&lt;Arc&lt;NodeContext&gt;&gt;,
    Path(address): Path&lt;WalletAddress&gt;,  // Custom type, not String
) -&gt; Result&lt;Json&lt;ApiResponse&lt;BalanceResponse&gt;&gt;, StatusCode&gt; {
    let balance = node.get_balance(&amp;address).await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let utxo_set = UTXOSet::new(node.blockchain().clone());
    let utxo_count = utxo_set.utxo_count(&amp;address).await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(ApiResponse::success(BalanceResponse {
        address: address.as_string(),
        balance,
        utxo_count,
        updated_at: chrono::Utc::now(),
    })))
}
</code></pre>
<p><code>Path(address): Path&lt;WalletAddress&gt;</code> extracts the <code>{address}</code> segment and deserializes it into a <code>WalletAddress</code>. If the path segment doesn't parse as a valid wallet address, Axum returns a 400 before the handler even runs.</p>
<p>For POST endpoints, the <code>Json</code> extractor handles request body deserialization:</p>
<pre><code class="language-rust">pub async fn send_transaction(
    State(node): State&lt;Arc&lt;NodeContext&gt;&gt;,
    Json(request): Json&lt;SendTransactionRequest&gt;,
) -&gt; Result&lt;Json&lt;ApiResponse&lt;SendBitCoinResponse&gt;&gt;, StatusCode&gt; {
    let txid = node.btc_transaction(
        &amp;request.from_address,
        &amp;request.to_address,
        request.amount
    ).await.map_err(|e| {
        error!("Failed to create transaction: {}", e);
        StatusCode::BAD_REQUEST
    })?;

    info!(txid = %txid, "Transaction submitted successfully");

    Ok(Json(ApiResponse::success(SendBitCoinResponse {
        txid,
        timestamp: chrono::Utc::now(),
    })))
}
</code></pre>
<p>A note on the error mapping: in this handler, <code>btc_transaction</code> failures typically indicate client errors (invalid address, insufficient funds), so <code>BAD_REQUEST</code> is reasonable. A production API with more error granularity would use a custom error enum that maps different failure modes to appropriate status codes — <code>400</code> for validation failures, <code>422</code> for business logic rejections, and <code>500</code> for system errors. We cover that pattern in the book's error handling chapter.</p>
<p>The pattern is consistent across every handler: extract, process, respond. This consistency makes the codebase predictable — once you've read one handler, you can read them all.</p>
<h2>Authentication: Role-Based API Keys</h2>
<p>Authentication is implemented as Axum middleware that checks the <code>X-API-Key</code> header and determines the caller's role:</p>
<pre><code class="language-rust">pub async fn require_role(
    mut req: axum::http::Request&lt;axum::body::Body&gt;,
    required: Role,
    next: axum::middleware::Next,
) -&gt; Result&lt;axum::response::Response, StatusCode&gt; {
    // Extract API key from header
    let key = req.headers().get("X-API-Key")
        .and_then(|h| h.to_str().ok());

    // Determine caller's role
    let caller_role = match key {
        Some(k) if is_admin_key(k) =&gt; Role::Admin,
        Some(k) if is_wallet_key(k) =&gt; Role::Wallet,
        _ =&gt; return Err(StatusCode::UNAUTHORIZED),
    };

    // Check authorization (admin can access wallet routes)
    let allowed = caller_role == required
        || (caller_role == Role::Admin &amp;&amp; required == Role::Wallet);

    if !allowed {
        return Err(StatusCode::FORBIDDEN);
    }

    // Attach role to request extensions for downstream handlers
    req.extensions_mut().insert(caller_role);

    Ok(next.run(req).await)
}
</code></pre>
<p>The role hierarchy is simple: Admin has full access, Wallet has limited access, and unauthenticated users can only reach health checks. The <code>req.extensions_mut().insert(caller_role)</code> line attaches the resolved role to the request so handlers can inspect it if needed — for example, to filter response data based on privilege level.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/152ccb7b-f150-4cc3-a8f2-04517a6ffcd7.png" alt="Authentication Flow — API key validation with decision points for key presence, key matching, and role authorization" /></p>
<p>Convenience wrappers make it clean to apply per-route:</p>
<pre><code class="language-rust">pub async fn require_admin(
    req: axum::http::Request&lt;axum::body::Body&gt;,
    next: axum::middleware::Next,
) -&gt; Result&lt;axum::response::Response, StatusCode&gt; {
    require_role(req, Role::Admin, next).await
}

pub async fn require_wallet(
    req: axum::http::Request&lt;axum::body::Body&gt;,
    next: axum::middleware::Next,
) -&gt; Result&lt;axum::response::Response, StatusCode&gt; {
    require_role(req, Role::Wallet, next).await
}
</code></pre>
<p>Keys are validated against environment variables (<code>BITCOIN_API_ADMIN_KEY</code> and <code>BITCOIN_API_WALLET_KEY</code>), with development defaults as fallbacks. In production, you'd set strong keys and ideally use a key management service.</p>
<h2>CORS: Development vs Production</h2>
<p>CORS configuration is split into two modes. Development allows everything:</p>
<pre><code class="language-rust">pub fn create_cors_layer() -&gt; CorsLayer {
    CorsLayer::new()
        .allow_origin(Any)
        .allow_methods(Any)
        .allow_headers(Any)
        .expose_headers(Any)
        .max_age(std::time::Duration::from_secs(86400))
}
</code></pre>
<p>Production restricts to specific origins:</p>
<pre><code class="language-rust">pub fn create_cors_layer_with_origins(origins: Vec&lt;String&gt;) -&gt; CorsLayer {
    let mut cors = CorsLayer::new()
        .allow_methods(Any)
        .allow_headers(Any)
        .expose_headers(Any)
        .max_age(std::time::Duration::from_secs(86400));

    for origin in origins {
        if let Ok(parsed) = origin.parse::&lt;axum::http::HeaderValue&gt;() {
            cors = cors.allow_origin(parsed);
        }
    }

    cors
}
</code></pre>
<p>The 24-hour <code>max_age</code> caches preflight responses so browsers don't re-send <code>OPTIONS</code> requests for every API call. This matters for performance — our desktop clients make dozens of requests per second when polling for blockchain updates.</p>
<h2>Rate Limiting: Token Bucket with Redis</h2>
<p>Rate limiting uses a Redis-backed token bucket algorithm. Each client gets a bucket of tokens; every request consumes one. Tokens refill at a configured rate. When the bucket is empty, the server responds with <code>429 Too Many Requests</code>.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/280ce0e1-b445-43b3-ac32-5177e0729926.png" alt="Token Bucket Rate Limiting — requests consume tokens from a per-client bucket, with 429 responses when empty" /></p>
<p>Configuration lives in a TOML file, allowing per-endpoint tuning:</p>
<pre><code class="language-toml">[rate_limiter]
redis_addr = "127.0.0.1:6379"
ip_whitelist = ["127.0.0.1"]

# Global per-IP limit: burst of 20 requests, refill 1 token every 6 seconds
[[rate_limiter.limiter]]
strategy = "ip"
global_bucket = { tokens_count = 20, add_tokens_every = 6 }

# Per-URL limits with tighter control on expensive endpoints
[[rate_limiter.limiter]]
strategy = "url"
global_bucket = { tokens_count = 60, add_tokens_every = 1 }
buckets_per_value = [
  { value = "/api/mining/generate", tokens_count = 2, add_tokens_every = 60 },
]
</code></pre>
<p>The mining endpoint gets only 2 requests per minute — generating blocks is CPU-intensive and should be throttled aggressively. General endpoints get 60 requests per second, which handles normal client polling comfortably.</p>
<h2>Response Envelope: One Shape for Everything</h2>
<p>Every API response uses the same generic envelope:</p>
<pre><code class="language-rust">#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ApiResponse&lt;T&gt; {
    pub success: bool,
    pub data: Option&lt;T&gt;,
    pub error: Option&lt;String&gt;,
    pub timestamp: DateTime&lt;Utc&gt;,
}

impl&lt;T&gt; ApiResponse&lt;T&gt; {
    pub fn success(data: T) -&gt; Self {
        Self {
            success: true,
            data: Some(data),
            error: None,
            timestamp: Utc::now(),
        }
    }

    pub fn error(error: String) -&gt; Self {
        Self {
            success: false,
            data: None,
            error: Some(error),
            timestamp: Utc::now(),
        }
    }
}
</code></pre>
<p>Clients always know the shape of the response. Parse <code>success</code> first — if <code>true</code>, <code>data</code> is present and typed. If <code>false</code>, <code>error</code> explains what went wrong. The <code>timestamp</code> field is useful for debugging latency and cache staleness.</p>
<p>This consistency pays off in client code. Our Tauri desktop app has a single <code>invoke()</code> wrapper that handles the envelope:</p>
<pre><code class="language-typescript">const response = await invoke&lt;ApiResponse&lt;T&gt;&gt;('api_call', { params });
if (!response.success) throw new Error(response.error);
return response.data;
</code></pre>
<h2>Error Handling: Safe for Clients, Detailed for Operators</h2>
<p>The error handling middleware catches all errors and ensures clients never see stack traces or internal paths:</p>
<pre><code class="language-rust">async fn handle_errors(
    request: axum::http::Request&lt;axum::body::Body&gt;,
    next: axum::middleware::Next,
) -&gt; Result&lt;axum::response::Response, StatusCode&gt; {
    let response = next.run(request).await;

    if response.status().is_server_error() || response.status().is_client_error() {
        let (parts, body) = response.into_parts();
        let body_bytes = axum::body::to_bytes(body, usize::MAX)
            .await.unwrap_or_default();

        // Log the full error for operators
        tracing::error!(
            "[handle_errors]: Error response ({}): {}",
            parts.status,
            String::from_utf8_lossy(&amp;body_bytes)
        );

        // Sanitize internal server errors for clients
        if parts.status == StatusCode::INTERNAL_SERVER_ERROR {
            let safe_error = ErrorResponse {
                error: "Internal Server Error".to_string(),
                message: "An unexpected error occurred".to_string(),
                status_code: 500,
                timestamp: chrono::Utc::now(),
            };
            return Ok(Json(ApiResponse::&lt;()&gt;::error(
                serde_json::to_string(&amp;safe_error)
                    .unwrap_or_else(|_| "Unknown error".to_string()),
            )).into_response());
        }
    }

    Ok(response)
}
</code></pre>
<p>The split is intentional: operators see the full error context in logs (including the original error message, status code, and request context). Clients see a sanitized message that doesn't reveal anything about the internal architecture.</p>
<h2>Health Checks: Three Endpoints, Three Purposes</h2>
<p>We expose three health endpoints, each serving a different consumer:</p>
<pre><code class="language-rust">// Full health check with metrics — for monitoring dashboards
pub async fn health_check(
    State(node): State&lt;Arc&lt;NodeContext&gt;&gt;,
) -&gt; Result&lt;Json&lt;ApiResponse&lt;HealthResponse&gt;&gt;, StatusCode&gt; {
    let height = node.get_blockchain_height().await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    let connected_peers = node.get_peer_count().unwrap_or(0);

    Ok(Json(ApiResponse::success(HealthResponse {
        status: "healthy".to_string(),
        version: env!("CARGO_PKG_VERSION").to_string(),
        blockchain_height: height,
        connected_peers,
        // ... additional metrics
    })))
}

// Liveness probe — for Kubernetes "is the process alive?"
pub async fn liveness() -&gt; Result&lt;Json&lt;ApiResponse&lt;String&gt;&gt;, StatusCode&gt; {
    Ok(Json(ApiResponse::success("alive".to_string())))
}

// Readiness probe — for Kubernetes "can this pod accept traffic?"
pub async fn readiness(
    State(node): State&lt;Arc&lt;NodeContext&gt;&gt;,
) -&gt; Result&lt;Json&lt;ApiResponse&lt;String&gt;&gt;, StatusCode&gt; {
    match node.get_blockchain_height().await {
        Ok(_) =&gt; Ok(Json(ApiResponse::success("ready".to_string()))),
        Err(_) =&gt; Err(StatusCode::SERVICE_UNAVAILABLE),
    }
}
</code></pre>
<p>Kubernetes uses <code>/health/live</code> to decide whether to restart a pod and <code>/health/ready</code> to decide whether to route traffic to it. The full <code>/health</code> endpoint gives monitoring systems (Prometheus, Datadog, etc.) detailed metrics. This three-endpoint pattern is a production best practice that works well beyond blockchain — any service deployed to containers benefits from it.</p>
<h2>OpenAPI: Documentation That Can't Drift</h2>
<p>The API specification is generated from Rust types using <a href="https://github.com/juhaku/utoipa">Utoipa</a>. Every response model derives <code>ToSchema</code>, and the OpenAPI definition references them directly:</p>
<pre><code class="language-rust">#[derive(OpenApi)]
#[openapi(
    paths(
        health::health_check,
        blockchain::get_blockchain_info,
        wallet::create_wallet,
        transaction::send_transaction,
        mining::get_mining_info,
        // ... all endpoints
    ),
    components(schemas(
        BlockchainInfoResponse,
        WalletResponse,
        SendTransactionRequest,
        // ... all models
    )),
    tags(
        (name = "Health", description = "Health check endpoints"),
        (name = "Blockchain", description = "Blockchain data and queries"),
        (name = "Wallet", description = "Wallet management"),
        (name = "Transactions", description = "Transaction operations"),
        (name = "Mining", description = "Block generation"),
    ),
    info(
        title = "Blockchain API",
        version = "0.1.0",
        description = "A comprehensive blockchain node REST API"
    ),
)]
pub struct ApiDoc;
</code></pre>
<p>Swagger UI is served automatically at <code>/swagger-ui</code>:</p>
<pre><code class="language-rust">pub fn create_swagger_ui() -&gt; SwaggerUi {
    SwaggerUi::new("/swagger-ui")
        .url("/api-docs/openapi.json", ApiDoc::openapi())
}
</code></pre>
<p>Because the spec is generated from the same types the handlers use, it can't drift out of sync. If you add a field to <code>BlockchainInfoResponse</code>, it appears in the OpenAPI spec automatically. If you add a new endpoint, you add it to the <code>paths()</code> list and the compiler reminds you if you forget the schema. This is a significant advantage over maintaining a separate OpenAPI YAML file.</p>
<h2>Structured Logging with Tracing</h2>
<p>The <code>tracing</code> crate is initialized once at server startup — typically in <code>main()</code> with a subscriber that formats output as structured JSON in production or human-readable text in development:</p>
<pre><code class="language-rust">tracing_subscriber::fmt()
    .with_env_filter(EnvFilter::from_default_env())  // RUST_LOG=info,bitcoin=debug
    .json()  // Structured JSON for production log aggregation
    .init();
</code></pre>
<p>With that in place, every handler logs operations with structured key-value pairs:</p>
<pre><code class="language-rust">use tracing::{error, info, instrument};

// Structured logging with context
info!(
    txid = %txid,
    from = %from_address,
    to = %to_address,
    amount = amount,
    "Transaction submitted successfully"
);

// Error logging preserves full context
error!(
    error = %e,
    txid = %txid,
    from = %from_address,
    "Failed to process transaction"
);

// Spans automatically add context to all nested logs
#[instrument]
async fn process_transaction(txid: String) {
    info!("Processing transaction");
    // ↑ This log automatically includes txid from the span
}
</code></pre>
<p>Structured logs are queryable. In production, you can filter for all failed transactions, all requests from a specific address, or all operations that took longer than a threshold — without parsing log strings.</p>
<h2>Request Validation with Derive Macros</h2>
<p>Request models use the <code>validator</code> crate for declarative validation:</p>
<pre><code class="language-rust">#[derive(Debug, Serialize, Deserialize, Validate, ToSchema)]
pub struct SendTransactionRequest {
    pub from_address: WalletAddress,
    pub to_address: WalletAddress,
    #[validate(range(min = 1, message = "Amount must be greater than 0"))]
    pub amount: i32,
}

#[derive(Debug, Serialize, Deserialize, Validate, ToSchema)]
pub struct BlockQuery {
    #[validate(range(min = 0, message = "Page must be 0 or greater"))]
    pub page: Option&lt;u32&gt;,
    #[validate(range(min = 1, max = 100, message = "Limit must be 1-100"))]
    pub limit: Option&lt;u32&gt;,
    pub hash: Option&lt;String&gt;,
}
</code></pre>
<p>Validation rules are declared next to the fields they constrain. The <code>Validate</code> derive macro generates a <code>.validate()</code> method that checks all rules at once. In a handler, you call it before processing:</p>
<pre><code class="language-rust">pub async fn send_transaction(
    State(node): State&lt;Arc&lt;NodeContext&gt;&gt;,
    Json(request): Json&lt;SendTransactionRequest&gt;,
) -&gt; Result&lt;Json&lt;ApiResponse&lt;SendBitCoinResponse&gt;&gt;, StatusCode&gt; {
    // Validate before processing
    request.validate().map_err(|_| StatusCode::BAD_REQUEST)?;

    // ... proceed with validated request
}
</code></pre>
<p>Combined with <code>ToSchema</code>, the validation constraints also appear in the OpenAPI documentation — clients can see the allowed ranges before making requests.</p>
<h2>Putting It All Together</h2>
<p>Here's a <code>curl</code> session that exercises the main endpoints:</p>
<pre><code class="language-bash"># Health check (no auth required)
curl http://localhost:8080/api/admin/health

# Get blockchain info (admin auth)
curl -H "X-API-Key: admin-secret" \
     http://localhost:8080/api/admin/blockchain

# Create a wallet (wallet auth is sufficient)
curl -X POST \
     -H "X-API-Key: wallet-secret" \
     -H "Content-Type: application/json" \
     -d '{"name": "my-wallet"}' \
     http://localhost:8080/api/wallet/wallet

# Send a transaction
curl -X POST \
     -H "X-API-Key: wallet-secret" \
     -H "Content-Type: application/json" \
     -d '{"from_address": "...", "to_address": "...", "amount": 50}' \
     http://localhost:8080/api/wallet/transactions

# Mine a block (admin only)
curl -X POST \
     -H "X-API-Key: admin-secret" \
     -H "Content-Type: application/json" \
     -d '{"address": "...", "nblocks": 1}' \
     http://localhost:8080/api/admin/mining/generatetoaddress

# Interactive API docs
open http://localhost:8080/swagger-ui
</code></pre>
<h2>Key Takeaways</h2>
<p>After building this API, a few patterns stand out as universally applicable:</p>
<p><strong>Use Axum's type system aggressively.</strong> Custom types for path parameters (<code>WalletAddress</code> instead of <code>String</code>), typed state extraction, and the <code>ApiResponse&lt;T&gt;</code> envelope catch entire categories of bugs at compile time. The initial investment in types pays off in fewer runtime errors and more self-documenting code.</p>
<p><strong>Separate auth by route group, not globally.</strong> Health checks should always be public. Admin and wallet operations need different privilege levels. Axum's <code>.layer()</code> on nested routers makes this natural — you apply middleware where it belongs, not everywhere.</p>
<p><strong>Rate limit by endpoint, not just by IP.</strong> A flat per-IP limit doesn't account for the fact that mining operations are 100x more expensive than health checks. Per-endpoint configuration lets you protect expensive operations without throttling cheap ones.</p>
<p><strong>Generate documentation from code.</strong> Any documentation that exists separately from the implementation will drift. Utoipa's derive macros guarantee that the OpenAPI spec matches the actual types and endpoints. Swagger UI gives you interactive testing for free.</p>
<p><strong>Three health endpoints, not one.</strong> Kubernetes needs liveness and readiness as separate signals. Monitoring dashboards need detailed metrics. One endpoint can't serve all three consumers well.</p>
<h2>Explore the Source Code</h2>
<p>The complete source code for the API layer is open source:</p>
<p><strong>Full repository:</strong> <a href="https://github.com/bkunyiha/rust-blockchain">github.com/bkunyiha/rust-blockchain</a></p>
<p><strong>API source:</strong> <a href="https://github.com/bkunyiha/rust-blockchain/tree/main/bitcoin/src/web">bitcoin/src/web/</a></p>
<p>The same API serves both the Iced and Tauri desktop clients — you can see how they consume it in the <a href="https://github.com/bkunyiha/rust-blockchain/tree/main/bitcoin-desktop-ui-iced">Iced admin UI</a> and <a href="https://github.com/bkunyiha/rust-blockchain/tree/main/bitcoin-desktop-ui-tauri">Tauri admin UI</a>.</p>
<hr />
<p><em>This post is adapted from <strong>Rust Blockchain: A Full-Stack Implementation Guide</strong> — a 33-chapter, 716-page book covering Axum, Iced, Tauri 2, Tokio, SQLCipher, Docker, and Kubernetes through one cohesive project.</em></p>
<p><em>Coming mid April on <a href="https://amazon.com">Amazon</a> (paperback + Kindle), <a href="https://gumroad.com">Gumroad</a> (PDF + source code), and <a href="https://leanpub.com">Leanpub</a> (pay what you want).</em></p>
<p><em>Free resources: <a href="https://gumroad.com">Download the Rust+Blockchain Cheat Sheet</a> | <a href="https://github.com/bkunyiha/rust-blockchain">Clone the Starter Template</a></em></p>
<p><em>Follow <a href="https://buildwithrust.com">buildwithrust.com</a> for weekly posts on building production systems in Rust.</em></p>
]]></content:encoded></item><item><title><![CDATA[Iced vs Tauri 2: We Built the Same App Twice in Rust ]]></title><description><![CDATA[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 Taur]]></description><link>https://buildwithrust.com/iced-vs-tauri-2-we-built-the-same-app-twice-in-rust</link><guid isPermaLink="true">https://buildwithrust.com/iced-vs-tauri-2-we-built-the-same-app-twice-in-rust</guid><category><![CDATA[Tauri]]></category><category><![CDATA[Rust]]></category><category><![CDATA[iced]]></category><category><![CDATA[desktop development]]></category><category><![CDATA[rust desktop]]></category><category><![CDATA[rust ui]]></category><category><![CDATA[UI In Rust]]></category><category><![CDATA[Rust Desktop Apps]]></category><dc:creator><![CDATA[Bill Kunyiha]]></dc:creator><pubDate>Sat, 28 Mar 2026 04:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69c733287cf2706510824caa/552a26b6-f00e-4484-a471-33c1e9b1cc82.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Iced vs Tauri 2: We Built the Same Desktop App Twice in Rust</h1>
<p>We built the same blockchain admin dashboard and wallet application twice — once with <a href="https://iced.rs">Iced</a> (pure Rust, MVU architecture) and once with <a href="https://tauri.app">Tauri 2</a> (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.</p>
<p>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.</p>
<h2>The Setup: One Blockchain, Two Desktop Clients</h2>
<p>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 <code>bitcoin-api</code> crate.</p>
<p>The difference is how they render UI and manage state.</p>
<p><strong>Iced</strong> 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.</p>
<p><strong>Tauri 2</strong> 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).</p>
<h2>Architecture at a Glance</h2>
<table>
<thead>
<tr>
<th>Aspect</th>
<th>Iced</th>
<th>Tauri 2</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Languages</strong></td>
<td>Rust only</td>
<td>Rust + TypeScript</td>
</tr>
<tr>
<td><strong>UI paradigm</strong></td>
<td>Model-View-Update (Elm-style)</td>
<td>React components + IPC</td>
</tr>
<tr>
<td><strong>State management</strong></td>
<td>Single struct (<code>AdminApp</code>)</td>
<td>Zustand (UI) + React Query (server)</td>
</tr>
<tr>
<td><strong>Async model</strong></td>
<td>Manual <code>spawn_on_tokio</code> bridge</td>
<td>Native <code>async fn</code> commands</td>
</tr>
<tr>
<td><strong>Styling</strong></td>
<td>Built-in Iced widgets</td>
<td>Tailwind CSS</td>
</tr>
<tr>
<td><strong>Event vocabulary</strong></td>
<td><code>Message</code> enum (~50 variants)</td>
<td>React Router routes + query keys</td>
</tr>
<tr>
<td><strong>Binary size</strong></td>
<td>Smaller</td>
<td>Slightly larger</td>
</tr>
<tr>
<td><strong>Ecosystem</strong></td>
<td>Growing, Rust-native</td>
<td>Mature (npm + crates.io)</td>
</tr>
</tbody></table>
<h2>How Each Framework Is Structured</h2>
<p>Before comparing behavior, it helps to see how each project is organized on disk and how data flows through the system.</p>
<h3>Iced Project Structure</h3>
<p>Everything is Rust. The entire application compiles into a single binary with no bundled web runtime:</p>
<pre><code class="language-plaintext">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 -&gt; state mutation + Tasks
    view.rs     # view() function: &amp;Model -&gt; UI elements (pure, no mutation)
    api.rs      # Async HTTP calls to the blockchain node REST API
</code></pre>
<p>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.</p>
<p><strong>Iced MVU event loop (single Rust binary):</strong></p>
<pre><code class="language-text">  (view)
    |
    | Message
    v
  (update) ---- Task::perform() ---&gt; [Tokio runtime]
    ^                                 |
    | new state                        | HTTP
    |                                  v
    +-------------------------- [Blockchain node]
                                 (REST API)
</code></pre>
<p>The key insight: <code>view()</code> never mutates state. It reads <code>&amp;Model</code> and returns a description of the UI. When the user clicks something, Iced creates a <code>Message</code>, feeds it to <code>update()</code>, which mutates the model and optionally kicks off async work. When that async work completes, it produces another <code>Message</code>, and the cycle repeats.</p>
<h3>Tauri Project Structure</h3>
<p>Tauri splits the project into two halves — a Rust backend and a React frontend — connected by IPC:</p>
<pre><code class="language-plaintext">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
</code></pre>
<p>The data flow crosses a language boundary at the IPC bridge:</p>
<pre><code class="language-text">  [Page component]
    | \
    |  \--&gt; [Zustand store] (UI state)
    |
    v
  [React Query]
    |
    | invoke('command')  (JSON over IPC)
    v
  [#[tauri::command] async fn]
    | \
    |  \--&gt; [Read config (RwLock)]
    |
    v
  [Service layer] -- HTTP --&gt; [Blockchain node]
    ^
    |
  Result&lt;T&gt; (JSON back to React Query)
</code></pre>
<p>The key insight: the IPC bridge is a serialization boundary. Every value that crosses it must be JSON-serializable. The React frontend calls <code>invoke('command_name')</code>, Tauri routes it to the matching <code>#[tauri::command]</code> 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.</p>
<h2>State Management: One Struct vs Two Layers</h2>
<p>This is where the philosophies diverge most sharply.</p>
<h3>Iced: Everything in One Place</h3>
<p>In Iced, your entire application state lives in a single Rust struct. Every field is visible, every mutation flows through a single <code>update</code> function, and the compiler enforces it all.</p>
<pre><code class="language-rust">pub struct AdminApp {
    // Navigation: which screen the user is looking at right now
    menu: Menu,

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

    // 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.
}
</code></pre>
<p>When the user clicks a button, Iced produces a <code>Message</code> — a variant of a Rust enum that represents every possible event in the application. That message flows to the <code>update</code> function, which pattern-matches on it and returns the next state plus any async tasks to run:</p>
<pre><code class="language-rust">// 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&lt;BlockchainInfo, Error&gt;), // 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 (&amp;mut self) and a Message,
// performs any synchronous state changes, and returns async
// work to do (if any). The return type Task&lt;Message&gt; means
// "when this async work finishes, feed the result back as
// another Message."
fn update(&amp;mut self, message: Message) -&gt; Task&lt;Message&gt; {
    match message {
        // Step 1: User clicks "Refresh" -&gt; 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 =&gt; {
            Task::perform(
                spawn_on_tokio(api::get_blockchain_info()),
                Message::BlockchainInfoLoaded,
            )
        }

        // Step 2: API call finished -&gt; 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) =&gt; {
            self.blockchain_info = result.ok();
            Task::none()
        }

        // ... pattern continues for every interaction in the app
    }
}
</code></pre>
<p>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.</p>
<h3>Tauri: Split by Concern</h3>
<p>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:</p>
<pre><code class="language-typescript">// 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&lt;AppState&gt;((set) =&gt; ({
  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) =&gt; set({ activeWallet: wallet }),
}));
</code></pre>
<p>Server state (blockchain info, wallet balances, transaction history) is managed by React Query, which handles caching, background refetching, and loading states automatically:</p>
<pre><code class="language-typescript">// 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: () =&gt; 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
</code></pre>
<p>The Rust backend holds only shared configuration behind a <code>RwLock</code>:</p>
<pre><code class="language-rust">pub struct AppState {
    pub api_config: ApiConfig,      // base_url + api_key for the blockchain node
    pub active_wallet: Option&lt;String&gt;, // 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&lt;RwLock&lt;AppState&gt;&gt;
</code></pre>
<p>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.</p>
<h2>The Async Boundary: Two Very Different Approaches</h2>
<p>Async is where Iced requires the most manual plumbing and where Tauri's two-language architecture actually simplifies things.</p>
<h3>Iced: The Tokio Bridge</h3>
<p>Iced's event loop is synchronous. The <code>update</code> function can't be <code>async</code>. To make API calls, you need to bridge into a Tokio runtime:</p>
<pre><code class="language-rust">// 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&lt;Runtime&gt; = 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&lt;F, T&gt;(future: F) -&gt; T
where
    F: Future&lt;Output = T&gt; + Send + 'static,
    T: Send + 'static,
{
    let handle = RUNTIME.get().unwrap().handle().clone();
    handle.spawn(future).await.unwrap()
}
</code></pre>
<p>Every API call follows the same round-trip:</p>
<pre><code class="language-plaintext">User clicks "Refresh"
  -&gt; update() receives Message::FetchBlockchainInfo
    -&gt; Task::perform(spawn_on_tokio(api_call), Message::Loaded)
      -&gt; Tokio runtime executes the HTTP request
        -&gt; Result arrives as Message::BlockchainInfoLoaded(Ok(data))
          -&gt; update() stores data in self.blockchain_info
            -&gt; view() re-renders with new data
</code></pre>
<p>This is explicit and traceable, but it's boilerplate you write for every async operation.</p>
<h3>Tauri: Just Write <code>async fn</code></h3>
<p>Tauri commands are natively async. No runtime bridging, no message round-trips:</p>
<pre><code class="language-rust">// 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&lt;'_, RwLock&lt;AppState&gt;&gt;,
) -&gt; Result&lt;Value, String&gt; {
    // 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(&amp;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&lt;BlockchainInfo&gt;.
    service.get_blockchain_info()
        .await
        .map_err(|e| e.to_string())
}
</code></pre>
<p>On the frontend, the entire call is one line — <code>invoke()</code> handles IPC serialization, async execution, and error propagation:</p>
<pre><code class="language-typescript">// commands.ts — thin typed wrappers around Tauri's invoke().
// The string 'get_blockchain_info' must exactly match the Rust function name.
// The generic &lt;BlockchainInfo&gt; tells TypeScript what the Promise resolves to.
export async function getBlockchainInfo(): Promise&lt;BlockchainInfo&gt; {
  return invoke&lt;BlockchainInfo&gt;('get_blockchain_info');
}

// For commands that take arguments, pass them as an object:
export async function getBlock(hash: string): Promise&lt;Block&gt; {
  return invoke&lt;Block&gt;('get_block', { hash });
  // Tauri deserializes { hash } into the Rust function's parameters
}
</code></pre>
<p>The entire Iced pattern — <code>Message::Fetch</code> → <code>spawn_on_tokio</code> → <code>Task::perform</code> → <code>Message::Loaded</code> — collapses into one <code>invoke()</code> call that returns a <code>Promise</code>. 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.</p>
<h2>The View Layer: Widgets vs JSX</h2>
<h3>Iced: Rust All the Way Down</h3>
<p>Iced views are pure functions from <code>&amp;Model</code> to <code>Element&lt;Message&gt;</code>. No mutation, no side effects — just a description of what the screen should look like:</p>
<pre><code class="language-rust">// view() is a PURE function: it reads &amp;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(&amp;self) -&gt; Element&lt;Message&gt; {
    // Route to the correct screen based on current navigation state.
    // Each view_* method returns an Element&lt;Message&gt; — a subtree of widgets.
    let content = match self.menu {
        Menu::BlockchainInfo =&gt; self.view_blockchain_info(),
        Menu::WalletList =&gt; self.view_wallet_list(),
        Menu::Send =&gt; 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()
}
</code></pre>
<p>Styling is done through Iced's widget API. You compose layouts with <code>row![]</code> (horizontal), <code>column![]</code> (vertical), <code>container</code> (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.</p>
<h3>Tauri: React + Tailwind</h3>
<p>The Tauri frontend is a standard React application. Pages are components, routing is React Router, and styling is Tailwind:</p>
<pre><code class="language-tsx">// 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') -&gt; IPC -&gt; Rust -&gt; 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 &lt;LoadingSpinner /&gt;;

  // 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 (
    &lt;div className="p-6 space-y-4"&gt;
      &lt;h1 className="text-2xl font-bold"&gt;Blockchain Info&lt;/h1&gt;
      &lt;DataCard title="Height" value={data?.height} /&gt;
      &lt;DataCard title="Difficulty" value={data?.difficulty} /&gt;
      {/* 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 */}
    &lt;/div&gt;
  );
}
</code></pre>
<p>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.</p>
<h2>Where It Gets Interesting: The Wallet</h2>
<p>The wallet application is where architectural differences become most consequential, because it introduces local persistence via SQLCipher — an encrypted SQLite database.</p>
<p>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.</p>
<p>But the data flow is different.</p>
<p><strong>Iced wallet operations</strong> are multi-step message chains. Creating a wallet means: <code>Message::CreateWallet</code> → API call → <code>Message::WalletCreated(Result)</code> → persist to SQLCipher → <code>Message::WalletSaved</code> → refresh wallet list → <code>Message::WalletsLoaded</code>. Each step is an explicit enum variant.</p>
<p><strong>Tauri wallet operations</strong> collapse that chain into a single command:</p>
<pre><code class="language-rust">#[tauri::command]
async fn create_wallet(
    state: State&lt;'_, RwLock&lt;AppState&gt;&gt;,
) -&gt; Result&lt;WalletAddress, String&gt; {
    let config = state.read().await;
    let service = BitcoinApiService::new(&amp;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(&amp;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)
}
</code></pre>
<p>On the React side, the mutation is equally concise:</p>
<pre><code class="language-typescript">// 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: () =&gt; commands.createWallet(),
  onSuccess: () =&gt; {
    queryClient.invalidateQueries({ queryKey: ['wallets'] });
  },
});
</code></pre>
<p>The frontend sees one <code>invoke()</code> call, gets one result, and React Query handles cache invalidation. The two-phase nature (remote call + local save) is invisible to the UI layer.</p>
<p>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.</p>
<h2>Parallel Data Loading</h2>
<p>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.</p>
<p><strong>Iced</strong> uses <code>Task::batch</code> to fan out multiple async operations from a single message handler:</p>
<pre><code class="language-rust">Message::WalletSelected(address) =&gt; {
    // 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,   // -&gt; update() stores wallet metadata
        ),
        Task::perform(
            spawn_on_tokio(api::fetch_balance(address.clone())),
            Message::BalanceLoaded,      // -&gt; update() stores balance amount
        ),
        Task::perform(
            spawn_on_tokio(api::fetch_transactions(address)),
            Message::TransactionsLoaded, // -&gt; update() stores tx history
        ),
    ])
}
</code></pre>
<p>Three tasks launch in parallel. Each completes independently and produces its own message. You see exactly what's happening and in what order.</p>
<p><strong>Tauri</strong> achieves the same parallelism through React Query — multiple <code>useQuery</code> hooks on the same page fire their queries independently:</p>
<pre><code class="language-typescript">// 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: () =&gt; commands.getWalletInfo(address),  // switching wallets refetches
});

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

const history = useQuery({
  queryKey: ['history', address],
  queryFn: () =&gt; commands.getTransactions(address),
});
</code></pre>
<p>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.</p>
<h2>Side-by-Side: The Same Operation in Both Frameworks</h2>
<p>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.</p>
<h3>Iced: 6 touch points across 4 files</h3>
<pre><code class="language-rust">// --- types.rs: Define the event ---
enum Message {
    FetchBlockchainInfo,                                // 1. Event name
    BlockchainInfoLoaded(Result&lt;BlockchainInfo, Error&gt;), // 2. Completion event
    // ...
}

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

// --- update.rs: Wire the event to the async call ---
Message::FetchBlockchainInfo =&gt; {                         // 4. Dispatch
    Task::perform(
        spawn_on_tokio(api::get_blockchain_info()),
        Message::BlockchainInfoLoaded,
    )
}
Message::BlockchainInfoLoaded(result) =&gt; {                // 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)
</code></pre>
<h3>Tauri: 3 touch points across 3 files</h3>
<pre><code class="language-rust">// --- commands/blockchain.rs: Define the command ---
#[tauri::command]
async fn get_blockchain_info(                             // 1. Command handler
    state: State&lt;'_, RwLock&lt;AppState&gt;&gt;,
) -&gt; Result&lt;Value, String&gt; {
    let config = state.read().await;
    let service = BitcoinApiService::new(&amp;config.api_config);
    service.get_blockchain_info().await.map_err(|e| e.to_string())
}
</code></pre>
<pre><code class="language-typescript">// --- lib/commands.ts: Typed invoke wrapper ---
export async function getBlockchainInfo() {               // 2. IPC bridge
  return invoke&lt;BlockchainInfo&gt;('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,
});
// ...
&lt;button onClick={() =&gt; refetch()}&gt;Refresh&lt;/button&gt;
</code></pre>
<p>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.</p>
<h2>When to Choose Which</h2>
<p>After building both implementations side-by-side, here's our take:</p>
<p><strong>Choose Iced when:</strong></p>
<ul>
<li><p>You want a single-language codebase with no JavaScript dependency</p>
</li>
<li><p>Compile-time type safety across the entire application matters to you</p>
</li>
<li><p>You prefer explicit, auditable control flow over implicit framework behavior</p>
</li>
<li><p>Your team is strong in Rust but less experienced with web frontends</p>
</li>
<li><p>Binary size is a priority (Iced produces smaller executables)</p>
</li>
</ul>
<p><strong>Choose Tauri 2 when:</strong></p>
<ul>
<li><p>You want to leverage the React/npm ecosystem for UI</p>
</li>
<li><p>Your team has frontend web experience alongside Rust skills</p>
</li>
<li><p>You need rapid UI iteration (hot reload, CSS utilities, component libraries)</p>
</li>
<li><p>Async operations are central to your app and you want ergonomic handling</p>
</li>
<li><p>You're building something visually complex where CSS and web layout shine</p>
</li>
</ul>
<p><strong>Choose both when:</strong></p>
<ul>
<li>You're writing a book about desktop development in Rust and want to give readers the full picture</li>
</ul>
<p>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.</p>
<h2>What We Learned</h2>
<p>Building the same application twice was instructive in ways we didn't expect. The <code>Message</code> 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 <code>invoke()</code> pattern, which felt like it was hiding complexity, turned out to produce code that was easier to onboard new contributors to.</p>
<p>The most surprising finding: the backend code was nearly identical. Both apps use the same <code>bitcoin-api</code> 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.</p>
<h2>Explore the Source Code</h2>
<p>The complete source code for both implementations is open source. You can clone the repo and run either application locally:</p>
<p><strong>Full repository:</strong> <a href="https://github.com/bkunyiha/rust-blockchain">github.com/bkunyiha/rust-blockchain</a></p>
<p><strong>Iced implementations:</strong></p>
<ul>
<li><p><a href="https://github.com/bkunyiha/rust-blockchain/tree/main/bitcoin-desktop-ui-iced">Desktop Admin UI (Iced)</a></p>
</li>
<li><p><a href="https://github.com/bkunyiha/rust-blockchain/tree/main/bitcoin-wallet-ui-iced">Wallet UI (Iced)</a></p>
</li>
</ul>
<p><strong>Tauri implementations:</strong></p>
<ul>
<li><p><a href="https://github.com/bkunyiha/rust-blockchain/tree/main/bitcoin-desktop-ui-tauri">Desktop Admin UI (Tauri)</a></p>
</li>
<li><p><a href="https://github.com/bkunyiha/rust-blockchain/tree/main/bitcoin-wallet-ui-tauri">Wallet UI (Tauri)</a></p>
</li>
</ul>
<p>Compare the two approaches side-by-side in the same workspace — both consume the shared <code>bitcoin-api</code> crate, so the backend boundary is identical.</p>
<hr />
<p><em>This post is adapted from an upcoming book, <strong>Rust Blockchain: A Full-Stack Implementation Guide</strong> — 24 chapters covering Axum, Iced, Tauri 2, Tokio, SQLCipher, Docker, and Kubernetes through one cohesive project. Follow <a href="https://buildwithrust.com">buildwithrust.com</a> for updates on the release.</em></p>
<hr />
<p><em>Tags: rust, blockchain, iced, tauri, desktop-development, framework-comparison</em></p>
]]></content:encoded></item></channel></rss>