| |

React 123 ⚛️ Desktop Apps with React and Electron / Tauri

⏱️ Estimated read: 14 min | |

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

AspectElectronTauri
Rendering engineBundled ChromiumSystem webview
Backend runtimeNode.jsRust
Installer size80–200 MB2–10 MB
Idle RAM150–400 MB30–80 MB
Rendering consistencyIdentical across platformsVaries by platform
Security modelcontextIsolation + preloadCapability-based, deny by default

Electron Layers

LayerPurposeFile
Main processWindows, lifecycle, Node.jsmain.js
Preload scriptControlled IPC bridgepreload.js
RendererReact applicationApp.tsx

Tauri Layers

LayerPurposeDirectory
Rust coreCommands, capabilitiessrc-tauri/
FrontendReact applicationsrc/
CapabilitiesPermission declarationssrc-tauri/capabilities/

Decision Criteria

Choose ElectronChoose Tauri
Frontend-only teamTeam has or will learn Rust
Node.js dependencyLightweight utility
Rendering consistency requiredSize and memory critical
Largest ecosystemSecurity model matters
Fastest time to marketPerformance-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

PitfallWhy It HappensFix
Electron renderer cannot access NodecontextIsolation is enabledUse the preload bridge
Tauri command not foundCapability not declaredAdd the command to the capability permissions
Linux rendering differsWebKitGTK is olderTest on Linux; avoid bleeding-edge CSS
Bundle too largeElectron bundles ChromiumUse Tauri for size-sensitive apps
Security vulnerabilitynodeIntegration enabledSet contextIsolation: true, nodeIntegration: false
Runtime switch requires rewriteComponents import runtime directlyUse 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

ItemValue
Electron rendererBundled Chromium
Electron backendNode.js main process
Electron bridgePreload script + IPC
Tauri rendererSystem webview
Tauri backendRust commands
Tauri bridgeinvoke + capabilities
Electron size80–200 MB
Tauri size2–10 MB
Electron consistencyIdentical across platforms
Tauri consistencyVaries by platform
Runtime-neutral patternDesktopRuntime 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 contextIsolation is enabled and nodeIntegration is 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 electron or @tauri-apps/api directly. 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.


Was this helpful?


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!