React 123 ⚛️ Desktop Apps with React and Electron / Tauri
React’s component model is not bound to the browser. The same reconciliation engine that renders to the DOM in a web application can render to a Chromium window embedded in a desktop process, or to the operating system’s native webview. The two dominant frameworks that make this possible are Electron and Tauri, and they represent opposite architectural decisions: Electron bundles a complete browser and Node.js runtime into every application, while Tauri delegates rendering to the operating system and provides a Rust backend for native capabilities.
This chapter covers both frameworks as React desktop runtimes. It begins with the architectural distinction that determines bundle size, memory footprint, and security surface. It then examines Electron’s main process and renderer process model, the preload script that bridges them, and the ecosystem maturity that has made it the default choice for large applications. The chapter then addresses Tauri’s system webview approach, its Rust command layer, and the cross-platform rendering inconsistency that is the cost of its efficiency. The final sections cover the decision criteria that separate the two, the runtime-neutral abstraction that preserves optionality, and the platform constraints that make desktop development a matter of explicit trade-offs.
Key point: Electron and Tauri are not competing implementations of the same idea. Electron trades resources for consistency and ecosystem; Tauri trades consistency for efficiency and security. The choice is which trade-off the application can afford.
Why desktop React is a runtime decision
The rendering target. In a web application, the browser is the runtime. In a desktop application, the runtime is whatever the application ships. Electron ships Chromium, which means the rendering engine is identical on macOS, Windows, and Linux . Tauri uses the operating system’s webview—WebKit on macOS, Edge WebView2 on Windows, WebKitGTK on Linux—which means the rendering engine varies by platform . The consistency of Electron and the efficiency of Tauri are direct consequences of this single decision.
The native capability problem. A desktop application needs to read files, show native menus, register global shortcuts, and interact with the system tray. Neither Electron nor Tauri provides these capabilities through React. Electron exposes them through Node.js in the main process and an IPC bridge to the renderer . Tauri exposes them through Rust commands that the webview invokes via an IPC layer . In both cases, the React code is the UI, and the native code is a separate layer.
The bundle size consequence. Electron’s installer size is typically 80–200 MB, with idle RAM consumption of 150–400 MB . Tauri’s installer is 2–10 MB, with idle RAM of 30–80 MB . For a utility application that users keep open all day, the difference is not cosmetic; it is the difference between a tool that feels lightweight and one that consumes a noticeable fraction of system resources .
The security surface. Electron’s renderer process has Node.js access unless contextIsolation is enabled and nodeIntegration is disabled. A cross-site scripting vulnerability in the renderer can become remote code execution on the host if those settings are wrong . Tauri’s capability model is deny-by-default: every OS API access must be explicitly declared per window, which limits the reach of a frontend compromise .
a. Electron: the main process, the renderer, and the preload bridge
Electron applications have two types of processes. The main process is a Node.js environment that creates windows, manages the application lifecycle, and accesses the file system. The renderer process is a Chromium instance that renders the React application. They communicate through inter-process communication (IPC), and the preload script is the controlled boundary between them .
The preload script runs in the renderer’s context but has access to a limited set of Node.js APIs. It uses contextBridge.exposeInMainWorld to expose specific functions to the React application, and ipcRenderer to send messages to the main process. The security best practice is to expose only the functions the UI needs, and to validate every argument on the main-process side .
// electron/preload.js — the controlled bridge
const { contextBridge, ipcRenderer } = require('electron');
// Expose only what the UI needs.
contextBridge.exposeInMainWorld('desktopAPI', {
readFile: (path) => ipcRenderer.invoke('read-file', path),
showNotification: (title, body) => ipcRenderer.invoke('notify', { title, body }),
});
// electron/main.js — the main process
const { app, BrowserWindow, ipcMain, Notification } = require('electron');
const fs = require('fs/promises');
function createWindow() {
const win = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true, // required for security
nodeIntegration: false, // required for security
},
});
win.loadURL('http://localhost:5173'); // dev server
}
// Handle the IPC call from the renderer.
ipcMain.handle('read-file', async (_event, path) => {
// Validate the path before reading.
return fs.readFile(path, 'utf-8');
});
The React component calls window.desktopAPI.readFile(path) and receives a promise. The component does not know about IPC, Node.js, or the main process. It knows about a function that returns a promise, and that is the correct boundary .
Electron’s ecosystem is its primary advantage. Visual Studio Code, Slack, Discord, and Notion are built on it . The npm registry contains packages for every common desktop need, and the debugging experience uses Chrome DevTools directly. The cost is the bundle size, the memory footprint, and the patch obligation for every Chromium vulnerability that ships in the runtime .
b. Tauri: the Rust command layer and the system webview
Tauri applications have a Rust core and a webview frontend. The Rust code lives in src-tauri/, and the React application lives in src/. The two communicate through commands: Rust functions annotated with #[tauri::command] are exposed to the frontend, and the frontend invokes them through the invoke function from @tauri-apps/api .
// src-tauri/src/lib.rs — a Tauri command
#[tauri::command]
fn read_file(path: String) -> Result<String, String> {
std::fs::read_to_string(&path).map_err(|e| e.to_string())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![read_file])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
// src/App.tsx — the React side
import { invoke } from '@tauri-apps/api/core';
async function handleRead(path: string) {
try {
const contents = await invoke<string>('read_file', { path });
console.log(contents);
} catch (error) {
console.error(error);
}
}
The tauri-specta crate generates TypeScript bindings from the Rust command signatures, so the frontend gets type-safe access to the backend without manual declaration . The capability system in src-tauri/capabilities/ declares which commands are available to which windows, and the default is deny .
Tauri’s advantage is the resource footprint. A Tauri application ships as a 2–10 MB installer and consumes 30–80 MB of idle RAM, because the webview is provided by the operating system rather than bundled . The security model is narrower: a frontend compromise cannot reach the file system unless the corresponding command is exposed and the capability is granted .
The cost is the rendering inconsistency. WebKitGTK on Linux lags behind the other engines, and CSS or JavaScript features that work on macOS and Windows may behave differently on Linux . The ecosystem is younger, some problems have no ready answer, and the Rust layer requires a skill set that not every frontend team has . The developer experience is real: a project that needs a custom native integration writes it in Rust, and the debugging tools are less familiar to web developers.
c. The decision and the runtime-neutral abstraction
The choice between Electron and Tauri follows from the application’s constraints, not from a general preference. Electron is the right choice when the team is frontend-only, when the application depends on Node.js packages, when rendering consistency across platforms is required, or when the project needs the largest ecosystem and the fastest time to market . Tauri is the right choice when the installer size and memory footprint are critical, when the application is a lightweight utility, when the team has or is willing to learn Rust, and when the security model of explicit capabilities is a requirement .
The runtime-neutral abstraction is the pattern that preserves optionality. A project that is not yet sure which runtime it needs can define a DesktopRuntime interface that wraps the native capabilities—file access, notifications, shortcuts—and implement that interface for both Electron and Tauri. The React components import the interface, not the runtime package. If the runtime decision changes, the components do not .
// desktop.ts — the runtime-neutral interface
export interface DesktopRuntime {
readFile(path: string): Promise<string>;
showNotification(title: string, body: string): Promise<void>;
}
// electron-runtime.ts
export const electronRuntime: DesktopRuntime = {
readFile: (path) => window.desktopAPI.readFile(path),
showNotification: (title, body) => window.desktopAPI.showNotification(title, body),
};
// tauri-runtime.ts
import { invoke } from '@tauri-apps/api/core';
export const tauriRuntime: DesktopRuntime = {
readFile: (path) => invoke('read_file', { path }),
showNotification: (title, body) => invoke('notify', { title, body }),
};
The guardrail is explicit: React components must never import electron or @tauri-apps/api directly. The native features go through the interface, and the interface is the seam that survives a runtime switch .
Complete Example Session
// ============================================
// PART 1: THE RUNTIME INTERFACE
// ============================================
// The abstraction that React components depend on.
export interface DesktopRuntime {
readFile(path: string): Promise<string>;
showNotification(title: string, body: string): Promise<void>;
}
// ============================================
// PART 2: THE ELECTRON IMPLEMENTATION
// ============================================
// The preload script exposes the API to the renderer.
// electron/preload.js
// contextBridge.exposeInMainWorld('desktopAPI', {
// readFile: (path) => ipcRenderer.invoke('read-file', path),
// showNotification: (title, body) => ipcRenderer.invoke('notify', { title, body }),
// });
// ============================================
// PART 3: THE ELECTRON MAIN PROCESS
// ============================================
// The main process handles the IPC calls.
// electron/main.js
// ipcMain.handle('read-file', async (_e, path) => fs.readFile(path, 'utf-8'));
// ipcMain.handle('notify', async (_e, { title, body }) => {
// new Notification({ title, body }).show();
// });
// ============================================
// PART 4: THE TAURI IMPLEMENTATION
// ============================================
// The Rust commands and the frontend invoke calls.
// src-tauri/src/lib.rs
// #[tauri::command]
// fn read_file(path: String) -> Result<String, String> {
// std::fs::read_to_string(&path).map_err(|e| e.to_string())
// }
//
// #[tauri::command]
// fn notify(title: String, body: String) {
// // platform notification
// }
//
// .invoke_handler(tauri::generate_handler![read_file, notify])
// ============================================
// PART 5: THE REACT COMPONENT
// ============================================
// The component imports the interface, not the runtime.
import { useState } from 'react';
import type { DesktopRuntime } from './desktop';
function FileViewer({ runtime }: { runtime: DesktopRuntime }) {
const [contents, setContents] = useState('');
const handleOpen = async () => {
const text = await runtime.readFile('/tmp/example.txt');
setContents(text);
};
return (
<div>
<button onClick={handleOpen}>Open File</button>
<pre>{contents}</pre>
</div>
);
}
// ============================================
// PART 6: THE ELECTRON ENTRY
// ============================================
// The renderer loads the React app and the runtime.
// electron-renderer.tsx
// import { electronRuntime } from './electron-runtime';
// ReactDOM.createRoot(document.getElementById('root')!).render(
// <FileViewer runtime={electronRuntime} />
// );
// ============================================
// PART 7: THE TAURI ENTRY
// ============================================
// The same component, a different runtime.
// tauri-renderer.tsx
// import { tauriRuntime } from './tauri-runtime';
// ReactDOM.createRoot(document.getElementById('root')!).render(
// <FileViewer runtime={tauriRuntime} />
// );
// ============================================
// PART 8: THE SECURITY CONFIGURATION
// ============================================
// Electron: contextIsolation true, nodeIntegration false.
// Tauri: capabilities declared in src-tauri/capabilities/.
// Electron webPreferences:
// { contextIsolation: true, nodeIntegration: false, preload: ... }
// Tauri capability:
// { "identifier": "main-capability", "windows": ["main"],
// "permissions": ["core:default", "my-app:allow-read-file"] }
// ============================================
// PART 9: THE BUILD
// ============================================
// Electron: electron-builder packages the app.
// Tauri: cargo tauri build produces the installer.
// ============================================
// PART 10: THE FULL PICTURE
// ============================================
// The runtime is a choice. The UI is the constant.
export default function App({ runtime }: { runtime: DesktopRuntime }) {
return <FileViewer runtime={runtime} />;
}
The ten parts build a runtime-neutral component that works in both Electron and Tauri. The component does not know which runtime it is running on; it knows the interface. The runtime implementations are separate, and the choice between them is a deployment decision rather than a code decision.
Quick Reference
Architecture Comparison
| Aspect | Electron | Tauri |
|---|---|---|
| Rendering engine | Bundled Chromium | System webview |
| Backend runtime | Node.js | Rust |
| Installer size | 80–200 MB | 2–10 MB |
| Idle RAM | 150–400 MB | 30–80 MB |
| Rendering consistency | Identical across platforms | Varies by platform |
| Security model | contextIsolation + preload | Capability-based, deny by default |
Electron Layers
| Layer | Purpose | File |
|---|---|---|
| Main process | Windows, lifecycle, Node.js | main.js |
| Preload script | Controlled IPC bridge | preload.js |
| Renderer | React application | App.tsx |
Tauri Layers
| Layer | Purpose | Directory |
|---|---|---|
| Rust core | Commands, capabilities | src-tauri/ |
| Frontend | React application | src/ |
| Capabilities | Permission declarations | src-tauri/capabilities/ |
Decision Criteria
| Choose Electron | Choose Tauri |
|---|---|
| Frontend-only team | Team has or will learn Rust |
| Node.js dependency | Lightweight utility |
| Rendering consistency required | Size and memory critical |
| Largest ecosystem | Security model matters |
| Fastest time to market | Performance-sensitive |
Best Practices
✅ Do This:
// Enable contextIsolation and disable nodeIntegration.
webPreferences: { contextIsolation: true, nodeIntegration: false } // ✅
// Abstract the runtime behind an interface.
interface DesktopRuntime { readFile(path: string): Promise<string>; } // ✅
// Declare Tauri capabilities explicitly.
// src-tauri/capabilities/main.json // ✅
// Import the interface, not the runtime package.
import type { DesktopRuntime } from './desktop'; // ✅
// Use electron-builder or cargo tauri build for distribution.
cargo tauri build // ✅
❌ Don’t Do This:
// Don't enable nodeIntegration without understanding the risk.
webPreferences: { nodeIntegration: true } // RCE surface // ❌
// Don't import Tauri or Electron directly in components.
import { invoke } from '@tauri-apps/api/core'; // leaks runtime // ❌
// Don't expose commands without capability restrictions.
#[tauri::command] fn delete_all() {} // no capability check // ❌
// Don't assume Tauri renders identically on Linux.
// WebKitGTK lags behind WebKit and WebView2. // ❌
// Don't expect Electron to be lightweight.
// It bundles Chromium and Node.js by design. // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Electron renderer cannot access Node | contextIsolation is enabled | Use the preload bridge |
| Tauri command not found | Capability not declared | Add the command to the capability permissions |
| Linux rendering differs | WebKitGTK is older | Test on Linux; avoid bleeding-edge CSS |
| Bundle too large | Electron bundles Chromium | Use Tauri for size-sensitive apps |
| Security vulnerability | nodeIntegration enabled | Set contextIsolation: true, nodeIntegration: false |
| Runtime switch requires rewrite | Components import runtime directly | Use the DesktopRuntime interface |
Real-World Examples
1. Electron Preload
contextBridge.exposeInMainWorld('api', { read: (p) => ipcRenderer.invoke('read', p) });
2. Electron Main
ipcMain.handle('read', (_e, p) => fs.readFile(p, 'utf-8'));
3. Tauri Command
#[tauri::command] fn read(p: String) -> Result<String, String> { std::fs::read_to_string(p).map_err(|e| e.to_string()) }
4. Tauri Invoke
const contents = await invoke<string>('read', { path });
5. Runtime Interface
interface DesktopRuntime { readFile(p: string): Promise<string>; }
6. Runtime Switch
<FileViewer runtime={electronRuntime} />
<FileViewer runtime={tauriRuntime} />
7. Electron Security
webPreferences: { contextIsolation: true, nodeIntegration: false }
8. Tauri Capability
{ "identifier": "main", "permissions": ["core:default"] }
9. Tauri Build
cargo tauri build
10. Electron Build
npm run electron:dist
Visual
The Architectural Split
┌─────────────────────────────────────────────────────────┐
│ ELECTRON │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Main Process (Node.js) │ │
│ │ windows, lifecycle, file system │ │
│ └───────────────────────────────────────────────────┘ │
│ │ IPC │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Preload Script (contextBridge) │ │
│ └───────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Renderer (Chromium + React) │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ Chromium is bundled. Same on every OS. │
│ │
│ ───────────────────────────────────────────────────── │
│ │
│ TAURI │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Rust Core │ │
│ │ commands, capabilities │ │
│ └───────────────────────────────────────────────────┘ │
│ │ IPC │
│ ┌───────────────────────────────────────────────────┐ │
│ │ System WebView (React) │ │
│ │ WebKit / WebView2 / WebKitGTK │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ WebView is provided by the OS. Varies by platform. │
└─────────────────────────────────────────────────────────┘
The Size and Memory Difference
┌─────────────────────────────────────────────────────────┐
│ ELECTRON TAURI │
│ │
│ Installer: 80–200 MB Installer: 2–10 MB │
│ Idle RAM: 150–400 MB Idle RAM: 30–80 MB │
│ Cold start: 1–5 s Cold start: sub-second │
│ │
│ Reason: Chromium bundled Reason: system webview │
│ │
│ VS Code (Electron) ≈ 350 MB Comparable Tauri ≈ 8 MB │
└─────────────────────────────────────────────────────────┘
The Security Boundary
┌─────────────────────────────────────────────────────────┐
│ ELECTRON │
│ Renderer has Node.js unless disabled. │
│ XSS → RCE if contextIsolation is off. │
│ The preload is the only safe bridge. │
│ │
│ ───────────────────────────────────────────────────── │
│ │
│ TAURI │
│ Deny by default. │
│ Every command must be declared in capabilities. │
│ A frontend compromise reaches only exposed commands. │
└─────────────────────────────────────────────────────────┘
The Decision Tree
┌─────────────────────────────────────────────────────────┐
│ Is installer size or memory critical? │
│ │ │
│ YES │ NO │
│ ▼ ▼ │
│ ┌──────────┐ Does the team know Rust? │
│ │ TAURI │ │ │
│ └──────────┘ YES │ NO │
│ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ │
│ │ TAURI │ │ ELECTRON │ │
│ └──────────┘ └──────────┘ │
│ │
│ Need Node.js packages? → Electron │
│ Need rendering consistency? → Electron │
│ Need explicit security capabilities? → Tauri │
└─────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Electron renderer | Bundled Chromium |
| Electron backend | Node.js main process |
| Electron bridge | Preload script + IPC |
| Tauri renderer | System webview |
| Tauri backend | Rust commands |
| Tauri bridge | invoke + capabilities |
| Electron size | 80–200 MB |
| Tauri size | 2–10 MB |
| Electron consistency | Identical across platforms |
| Tauri consistency | Varies by platform |
| Runtime-neutral pattern | DesktopRuntime interface |
Key takeaways:
- Electron bundles Chromium and Node.js. The rendering is identical on every platform, but the installer is 80–200 MB and the idle RAM is 150–400 MB .
- Tauri uses the system webview and a Rust backend. The installer is 2–10 MB and the idle RAM is 30–80 MB, but the rendering varies by platform and the ecosystem is younger .
- The security models are structurally different. Electron’s renderer has Node.js access unless
contextIsolationis enabled andnodeIntegrationis disabled. Tauri’s capabilities are deny-by-default, and every command must be explicitly granted . - The preload script is the Electron boundary. It exposes a limited API to the renderer through
contextBridge, and the main process handles the IPC calls . - The Tauri command is the Rust boundary. The
#[tauri::command]attribute exposes a Rust function, and the capability system controls which windows can invoke it . - The runtime-neutral interface preserves optionality. React components should not import
electronor@tauri-apps/apidirectly. The native features go through an interface, and the interface survives a runtime switch . - Electron is for ecosystem and consistency. Large commercial applications, frontend-only teams, and Node.js dependencies favor Electron. The maturity and the debugging experience are its strengths .
- Tauri is for efficiency and security. Lightweight utilities, size-sensitive distribution, and explicit capability models favor Tauri. The Rust requirement and the webview fragmentation are its costs .
Remember: React does not care which desktop runtime it is rendering to. The reconciliation engine is the same, and the component tree is the same. The difference is the shell: Electron ships a browser, and Tauri borrows the operating system’s. That single decision determines the size, the memory, the consistency, and the security surface. Choose the trade-off the application can afford, abstract the native capabilities behind an interface, and keep the React layer independent of the runtime that hosts it.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!