Build Ultra-Lightweight Desktop Apps with Tauri 2.0 and React: Replace Electron with Rust

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Quick Start (5 min)

Electron revolutionized desktop apps for web developers, but shipping a 120MB Chromium binary just to run a lightweight utility is wasteful. Tauri 2.0 solves this by ditching the bundled browser engine. Instead, it pairs React with a fast, memory-safe Rust backend and leverages your operating system’s built-in webview.

You can bootstrap a working desktop application in about five minutes. Start by checking off three prerequisites on your machine:

  • Node.js: version 18 or newer (verify with node -v)
  • Rust: installed via rustup (Rust 1.77.2+)
  • Platform toolchains: Visual Studio C++ Build Tools on Windows, Xcode Command Line Tools on macOS (xcode-select --install), or WebKitGTK and build tools on Linux (sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libayatana-appindicator3-dev librsvg2-dev)

Run the official scaffolding CLI in your terminal:

npm create tauri-app@latest

Select these options when prompted:

  1. Project name: my-desktop-app
  2. Frontend language: TypeScript / JavaScript
  3. Package manager: npm (or pnpm / yarn)
  4. UI template: React
  5. Flavor: TypeScript

Jump into the generated folder, install your packages, and start the development server:

cd my-desktop-app
npm install
npm run tauri dev

Cargo compiles the underlying Rust dependencies on the first run, which takes about 60 to 90 seconds. Once compiled, an independent native window renders your React interface. Hot module replacement runs immediately: tweak any component in src/, and the native window updates in milliseconds without resetting application state.

Deep Dive

Building reliable desktop tools requires a clear grasp of Tauri’s underlying runtime model and how messages pass between the UI thread and native system code.

The Tauri 2.0 Architecture

Slack, VS Code, and Discord rely on Electron. Because each Electron application bundles its own Chromium browser engine and Node.js runtime, installer files routinely top 120MB. Idle RAM consumption frequently spikes past 200MB per window.

Tauri takes a radically leaner approach. It offloads HTML, CSS, and JavaScript rendering to the webview engine already maintained and patched by the host operating system:

  • Windows: Microsoft Edge WebView2 (Evergreen runtime)
  • macOS: WKWebView (WebKit)
  • Linux: WebKitGTK 4.1

Your business logic, system calls, and security policies execute inside compiled Rust. Because you don’t ship a redundant browser engine, production installers shrink to 8MB–15MB. Idle memory drops dramatically, typically resting between 25MB and 45MB of RAM.

Project Anatomy

Your workspace is cleanly split between frontend code and native logic:

  • src/: Holds your standard Vite + React application, including JSX files, styles, hooks, and static assets.
  • src-tauri/: Houses the Rust application. Key files include Cargo.toml (crate dependencies), tauri.conf.json (window bounds, security identifiers, bundle settings), and src/lib.rs (the entry point where Tauri 2.0 initializes plugins and registers IPC commands).

Command IPC: Connecting React to Rust

Your React interface talks to the Rust process through asynchronous Inter-Process Communication (IPC). Native functions exposed to the webview are called Commands.

A common pitfall for web developers new to desktop development is running heavy computational tasks in JavaScript. Offloading file parsing, encryption, or system queries to Rust keeps the 60 FPS user interface completely butter-smooth.

Open src-tauri/src/lib.rs. Here is a custom command that validates an input string, gathers host CPU metrics, and returns structured data:

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
pub struct SystemStats {
    os_name: String,
    cpu_cores: usize,
    status: String,
}

#[tauri::command]
fn get_system_stats(user_label: &str) -> Result<SystemStats, String> {
    if user_label.trim().is_empty() {
        return Err("Label cannot be empty".into());
    }

    Ok(SystemStats {
        os_name: std::env::consts::OS.to_string(),
        cpu_cores: std::thread::available_parallelism()
            .map(|n| n.get())
            .unwrap_or(1),
        status: format!("Active (Profile: {})", user_label),
    })
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_opener::init())
        .invoke_handler(tauri::generate_handler![get_system_stats])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Switch to the React layer. Import invoke from @tauri-apps/api/core to dispatch calls directly to your Rust handler:

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

interface SystemStats {
  os_name: string;
  cpu_cores: number;
  status: string;
}

export function SystemMonitor() {
  const [stats, setStats] = useState<SystemStats | null>(null);
  const [label, setLabel] = useState('Production Workstation');
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  async function fetchStats() {
    setLoading(true);
    setError(null);
    try {
      const result = await invoke<SystemStats>('get_system_stats', {
        userLabel: label,
      });
      setStats(result);
    } catch (err) {
      setError(String(err));
    } finally {
      setLoading(false);
    }
  }

  return (
    <div style={{ padding: '1.5rem', fontFamily: 'sans-serif' }}>
      <h3>Desktop System Monitor</h3>
      <input
        value={label}
        onChange={(e) => setLabel(e.target.value)}
        placeholder="Enter profile label"
        style={{ marginRight: '8px', padding: '6px' }}
      />
      <button onClick={fetchStats} disabled={loading}>
        {loading ? 'Fetching...' : 'Query Rust Backend'}
      </button>

      {error && <p style={{ color: 'red' }}>Error: {error}</p>}

      {stats && (
        <div style={{ marginTop: '1rem', background: '#f4f4f5', padding: '1rem', borderRadius: '6px' }}>
          <p><strong>OS:</strong> {stats.os_name}</p>
          <p><strong>Available Cores:</strong> {stats.cpu_cores}</p>
          <p><strong>Status:</strong> {stats.status}</p>
        </div>
      )}
    </div>
  );
}

Tauri manages the field conversions automatically. You pass camelCase keys in JavaScript (userLabel), and Serde deserializes them into snake_case identifiers in Rust (user_label).

Advanced Usage

Bidirectional Event Streaming

Single request-response invocations work well for fast lookups. But long-running operations—like streaming a 2GB download, tracking an archive unpack, or polling hardware sensors—demand real-time updates pushed from Rust to React.

Tauri’s event system handles this cleanly. In Rust, emit events continuously using the Emitter trait:

use tauri::Emitter;
use tokio::time::{sleep, Duration};

#[tauri::command]
async fn start_progress_task(app: tauri::AppHandle) -> Result<(), String> {
    tauri::async_runtime::spawn(async move {
        for step in 1..=100 {
            sleep(Duration::from_millis(30)).await;
            let _ = app.emit("task-progress", step);
        }
    });
    Ok(())
}

Register start_progress_task inside tauri::generate_handler![] in lib.rs. On the frontend, attach an event listener inside a standard React hook:

import { useEffect, useState } from 'react';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';

export function ProgressBar() {
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    let unlisten: (() => void) | undefined;

    async function setupListener() {
      unlisten = await listen<number>('task-progress', (event) => {
        setProgress(event.payload);
      });
    }

    setupListener();
    return () => {
      if (unlisten) unlisten();
    };
  }, []);

  return (
    <div>
      <button onClick={() => invoke('start_progress_task')}>Start Worker</button>
      <div style={{ background: '#e2e8f0', width: '240px', height: '16px', margin: '10px 0', borderRadius: '4px', overflow: 'hidden' }}>
        <div style={{ background: '#2563eb', width: `${progress}%`, height: '100%', transition: 'width 0.1s linear' }} />
      </div>
      <span>Progress: {progress}%</span>
    </div>
  );
}

Tauri 2.0 Security and Capabilities

Tauri 2.0 replaces the legacy v1 allowlist with an explicit Capabilities model. The webview starts completely sandboxed. It cannot access files, trigger system dialogs, or execute terminal commands unless you explicitly authorize those actions in JSON configuration files.

Audit the capabilities definition under src-tauri/capabilities/default.json:

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability permissions for main application window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "opener:default"
  ]
}

This zero-trust architecture shields your users. Even if an attacker finds an XSS vulnerability in a frontend dependency, they remain trapped inside the webview sandbox without access to host system APIs.

Practical Tips

1. Aggressive Rust Release Profile

Default release profiles favor fast compilation over smallest binary size. By adding five optimization directives to src-tauri/Cargo.toml, you can shave substantial overhead off the compiled binary:

[profile.release]
opt-level = "z"     # Optimize aggressively for binary size
lto = true          # Enable Link Time Optimization across all crates
codegen-units = 1   # Reduce parallel code generation units to improve LTO
panic = "abort"     # Strip unwinding landing pads and tables
strip = true        # Strip symbols and debug info automatically

These flags shrink the raw native binary from roughly 18MB down to around 4.5MB on Windows and macOS with zero impact on runtime execution speed.

2. Frontend Bundle Hygiene

Vite compresses your production web assets and embeds them straight into the compiled binary. Keep your frontend footprint lean:

  • Profile your bundle output using rollup-plugin-visualizer to catch bloated npm packages before release.
  • Swap utility libraries like lodash or moment.js for native JavaScript features such as Intl.DateTimeFormat and modern array methods.
  • Lazy-load heavy views (such as settings dashboards or charting libraries) with React.lazy() and Suspense to keep initial window launch times below 300ms.

3. In-App Webview Debugging

Debugging desktop webviews feels just like browser development. Hit F12 or right-click and choose Inspect Element on Windows and Linux to open DevTools. On macOS, set "devtools": true in your tauri.conf.json window configuration, or launch the app with TAURI_DEVTOOLS=1 cargo tauri dev to access WebKit Web Inspector.

4. Packaging Native Installers

When you are ready to publish, run a single terminal command:

npm run tauri build

Tauri compiles the Vite bundle, builds the Rust executable with full optimizations, and outputs signed platform packages into src-tauri/target/release/bundle/. You get clean .msi or .exe installers for Windows, .dmg or .app bundles for macOS, and .deb or .AppImage archives for Linux. Instead of forcing users to download a massive 100MB+ installer, you deliver a fast, responsive 12MB binary that launches in a fraction of a second.

Share: