Skip to main content

Tauri vs Electron vs Neutralino: Desktop Apps with JavaScript 2026

·PkgPulse Team

Tauri vs Electron vs Neutralino: Desktop Apps with JavaScript 2026

TL;DR

Electron remains the most battle-tested choice — it ships Chromium and Node.js together, so it just works, and the ecosystem is enormous (VS Code, Slack, Figma, Discord all run on it). Tauri is the modern challenger — it uses the OS's native WebView and a Rust backend, resulting in 10–20x smaller bundles and dramatically lower memory usage. Neutralino is the lean option — no native backend runtime required at all, just a thin OS wrapper around a WebView.

Key Takeaways

  • Electron app bundles average 80–150 MB vs Tauri's 3–10 MB vs Neutralino's 1–2 MB (app logic only)
  • Memory at idle: Electron ~200MB, Tauri ~30MB, Neutralino ~20MB — Tauri's Rust backend is dramatically lighter
  • Electron has 115k+ GitHub stars — the most mature ecosystem with thousands of packages
  • Tauri reached 85k+ stars (2026) — fastest-growing desktop framework in the JS ecosystem
  • Neutralino has 7.5k stars — niche but solid for simple tools where Node.js APIs aren't needed
  • Tauri's security model is significantly stronger — Rust backend, allowlist-based API permissions, no Node.js attack surface
  • Electron has the largest plugin ecosystem — electron-builder, electron-updater, electron-store all have millions of downloads

Why Desktop Still Matters in 2026

Progressive Web Apps haven't killed desktop apps. They've clarified the niche: desktop wins when apps need deep OS integration (file system access, system tray, hardware devices, clipboard, global shortcuts), offline-first operation, or consistent behavior across OS versions without browser compatibility constraints.

The battle is no longer "native vs web" — it's about which JS-based desktop runtime gives you the right tradeoffs between bundle size, performance, security, and access to native APIs.


Electron: The Proven Standard

Electron bundles Chromium (the full browser engine) and Node.js into your app distribution. Every Electron app ships a complete browser — which explains both its strengths (full web API parity, any npm package works) and its weaknesses (file size, memory).

Project Setup

# Create with Electron Forge (the official scaffolding tool)
npm create electron-app@latest my-app -- --template=vite-typescript
cd my-app && npm start

Main Process (Node.js)

// main.ts — runs in Node.js context
import { app, BrowserWindow, ipcMain, dialog, shell } from "electron";
import { join } from "path";
import Store from "electron-store";
import { autoUpdater } from "electron-updater";

const store = new Store<{ windowBounds: { width: number; height: number } }>();

let mainWindow: BrowserWindow | null = null;

function createWindow() {
  const { width, height } = store.get("windowBounds", { width: 1200, height: 800 });

  mainWindow = new BrowserWindow({
    width,
    height,
    titleBarStyle: "hiddenInset",
    webPreferences: {
      preload: join(__dirname, "preload.js"),
      contextIsolation: true, // Security: never disable this
      nodeIntegration: false, // Security: never enable this
    },
  });

  if (process.env.NODE_ENV === "development") {
    mainWindow.loadURL("http://localhost:5173");
    mainWindow.webContents.openDevTools();
  } else {
    mainWindow.loadFile(join(__dirname, "renderer/index.html"));
    autoUpdater.checkForUpdatesAndNotify();
  }

  mainWindow.on("close", () => {
    store.set("windowBounds", mainWindow!.getBounds());
  });
}

app.whenReady().then(createWindow);

// IPC handlers — bridge between renderer and Node.js
ipcMain.handle("dialog:openFile", async () => {
  const { filePaths } = await dialog.showOpenDialog(mainWindow!, {
    properties: ["openFile"],
    filters: [{ name: "JSON Files", extensions: ["json"] }],
  });
  return filePaths[0];
});

ipcMain.handle("shell:openExternal", (_event, url: string) => {
  return shell.openExternal(url);
});

Preload Script (Security Bridge)

// preload.ts — context bridge between renderer and main
import { contextBridge, ipcRenderer } from "electron";

contextBridge.exposeInMainWorld("electronAPI", {
  openFile: () => ipcRenderer.invoke("dialog:openFile"),
  openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
  onUpdateAvailable: (callback: () => void) =>
    ipcRenderer.on("update-available", callback),
});

// TypeScript declaration for renderer
declare global {
  interface Window {
    electronAPI: {
      openFile: () => Promise<string | undefined>;
      openExternal: (url: string) => Promise<void>;
      onUpdateAvailable: (callback: () => void) => void;
    };
  }
}

Renderer Process (React/Vue/etc)

// App.tsx — runs in Chromium
import { useState } from "react";

export function App() {
  const [file, setFile] = useState<string | null>(null);

  const handleOpenFile = async () => {
    const filePath = await window.electronAPI.openFile();
    if (filePath) setFile(filePath);
  };

  return (
    <div>
      <button onClick={handleOpenFile}>Open File</button>
      {file && <p>Selected: {file}</p>}
    </div>
  );
}

Auto-Update Configuration

// electron-builder.config.js
module.exports = {
  appId: "com.yourcompany.app",
  productName: "My App",
  publish: {
    provider: "github",
    owner: "your-org",
    repo: "your-app",
  },
  mac: { category: "public.app-category.developer-tools" },
  win: { target: "nsis" },
  linux: { target: ["AppImage", "deb"] },
};

Tauri: The Modern Challenger

Tauri uses the OS's built-in WebView (WebKit on macOS/Linux, WebView2 on Windows) for the frontend, and a Rust binary for the backend. No bundled Chromium means dramatically smaller apps — but it requires Rust toolchain for development.

Project Setup

# Prerequisites: Rust toolchain
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# Create Tauri app
npm create tauri-app@latest my-tauri-app
# Select: Vite + TypeScript + React (or Vue/Svelte)
cd my-tauri-app
npm install
npm run tauri dev

Rust Backend (src-tauri/src/main.rs)

// src-tauri/src/main.rs
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

use tauri::{Manager, State};
use std::sync::Mutex;

// Shared state across commands
struct AppState {
    counter: Mutex<u32>,
}

// Commands are type-safe functions exposed to the frontend
#[tauri::command]
async fn read_file(path: String) -> Result<String, String> {
    tokio::fs::read_to_string(&path)
        .await
        .map_err(|e| e.to_string())
}

#[tauri::command]
fn increment_counter(state: State<AppState>) -> u32 {
    let mut counter = state.counter.lock().unwrap();
    *counter += 1;
    *counter
}

#[tauri::command]
async fn fetch_data(url: String) -> Result<String, String> {
    // Tauri's fetch is separate from WebView's fetch
    // This runs in Rust — bypasses CORS restrictions
    let response = reqwest::get(&url)
        .await
        .map_err(|e| e.to_string())?
        .text()
        .await
        .map_err(|e| e.to_string())?;
    Ok(response)
}

fn main() {
    tauri::Builder::default()
        .manage(AppState { counter: Mutex::new(0) })
        .invoke_handler(tauri::generate_handler![
            read_file,
            increment_counter,
            fetch_data,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

TypeScript Frontend Integration

// Tauri's invoke is fully type-safe with @tauri-apps/api
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
import { emit, listen } from "@tauri-apps/api/event";

// Call Rust commands
const count = await invoke<number>("increment_counter");
console.log("Counter:", count);

// File operations via official plugins
const filePath = await open({
  multiple: false,
  filters: [{ name: "JSON", extensions: ["json"] }],
});

if (filePath) {
  const contents = await readTextFile(filePath as string);
  console.log("File contents:", contents);
}

// Listen to events from Rust backend
const unlisten = await listen<{ message: string }>("backend-event", (event) => {
  console.log("Event from Rust:", event.payload.message);
});

// Emit events to Rust backend
await emit("frontend-action", { action: "save", data: "..." });

Tauri Permissions Model (tauri.conf.json)

{
  "app": {
    "security": {
      "csp": "default-src 'self'; img-src 'self' data: https:"
    }
  },
  "plugins": {
    "fs": {
      "scope": {
        "allow": ["$APPDATA/**", "$DOCUMENT/**"],
        "deny": ["$APPDATA/sensitive/**"]
      }
    },
    "dialog": {
      "all": true
    },
    "http": {
      "scope": ["https://api.yourservice.com/**"]
    }
  }
}

Tauri Plugin System

# Official plugin ecosystem
npm install @tauri-apps/plugin-fs
npm install @tauri-apps/plugin-dialog
npm install @tauri-apps/plugin-notification
npm install @tauri-apps/plugin-shell
npm install @tauri-apps/plugin-store      # Persistent key-value storage
npm install @tauri-apps/plugin-updater    # Auto-updates
npm install @tauri-apps/plugin-sql        # SQLite via sqlx

Neutralino: The Minimal Option

Neutralino provides a native OS window (or system tray) around a WebView, exposing a minimal set of OS APIs via a built-in REST/WebSocket server. No Rust, no Node.js — just a tiny binary and your web app.

Project Setup

# Install Neutralino CLI
npm install -g @neutralinojs/neu

# Create project
neu create my-app --template neutralinojs/neutralinojs-minimal
cd my-app && neu run

Configuration (neutralino.config.json)

{
  "applicationId": "js.neutralino.myapp",
  "version": "1.0.0",
  "defaultMode": "window",
  "port": 0,
  "documentRoot": "/resources/",
  "url": "/",
  "enableServer": true,
  "enableNativeAPI": true,
  "tokenSecurity": "one-time",
  "logging": {
    "enabled": true,
    "writeToLogFile": true
  },
  "nativeAllowList": [
    "app.*",
    "os.*",
    "filesystem.*",
    "storage.*",
    "window.*"
  ],
  "modes": {
    "window": {
      "title": "My Neutralino App",
      "width": 1200,
      "height": 800,
      "minWidth": 800,
      "minHeight": 600,
      "resizable": true,
      "maximize": false
    }
  },
  "cli": {
    "binaryName": "myapp",
    "resourcesPath": "/resources/",
    "extensionsPath": "/extensions/",
    "frontendLibrary": {
      "patchFile": "/resources/index.html",
      "devUrl": "http://localhost:5173"
    }
  }
}

Native API Usage

// Neutralino's built-in API — no npm packages needed
import Neutralino from "@neutralinojs/lib";

Neutralino.init();

// File system
const data = await Neutralino.filesystem.readFile("./data/config.json");
const parsed = JSON.parse(data);

await Neutralino.filesystem.writeFile(
  "./data/output.json",
  JSON.stringify({ result: "ok" }, null, 2)
);

// OS operations
const info = await Neutralino.os.getEnv("HOME");
const pid = await Neutralino.os.execCommand("ls -la /tmp", {
  background: false,
  stdIn: "",
});
console.log(pid.stdOut);

// Storage (simple key-value, no npm needed)
await Neutralino.storage.setData("user-preferences", JSON.stringify({ theme: "dark" }));
const prefs = await Neutralino.storage.getData("user-preferences");

// Window management
await Neutralino.window.setTitle("Updated Title");
await Neutralino.window.setSize({ width: 1400, height: 900 });
await Neutralino.window.center();

Extensions (Custom Backend Logic)

// extensions/backend/main.js — Node.js or Python process you spawn
// Neutralino doesn't have a built-in backend runtime
// For complex logic, write extensions:
const NeutralinoExtension = require("@neutralinojs/neu-extension");

const ext = new NeutralinoExtension();

ext.on("initExtension", () => {
  console.log("Extension initialized");
});

ext.on("customLogic.processData", async (event) => {
  const { data } = event.detail;
  // Do compute-intensive work here
  const result = heavyComputation(data);
  await ext.sendMessage("customLogic.result", { result });
});

ext.init();

Feature Comparison

FeatureElectronTauriNeutralino
Bundle size80–150 MB3–10 MB1–2 MB
Memory at idle~200 MB~30 MB~20 MB
Backend runtimeNode.jsRustNone (extensions possible)
WebViewBundled ChromiumOS WebViewOS WebView
Cross-platform✅ Win/Mac/Linux✅ Win/Mac/Linux✅ Win/Mac/Linux
Auto-update✅ electron-updater✅ plugin-updater❌ (manual)
TypeScript support✅ (frontend)✅ (frontend)
NPM packages✅ All packages✅ Frontend only✅ Frontend only
Native APIs depth⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Security modelMedium (IPC bridge)Strong (Rust, allowlist)Medium (token-based)
Build toolingelectron-builderCargo + Viteneu CLI
Dev experience⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
Ecosystem maturity⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
GitHub stars115k+85k+7.5k+

Performance Benchmarks

Real-world measurements from community benchmarks (2025–2026):

MetricElectronTauriNeutralino
Cold start1.8–3s0.4–0.8s0.3–0.6s
Memory (idle)150–250 MB25–50 MB15–30 MB
CPU (idle)1–3%0.1–0.5%0.1–0.3%
Min bundle (macOS)120 MB5 MB1.5 MB
File I/O speedFast (Node.js streams)Fast (Rust async)Moderate
App launch (warm)600ms200ms150ms

Benchmarks vary by hardware and app complexity. Values are typical ranges from community testing.


When to Use Each

Choose Electron if:

  • Your team knows JavaScript/Node.js deeply and doesn't want to learn Rust
  • You need the entire npm ecosystem accessible in your backend logic
  • Bundle size doesn't matter (internal tools, B2B apps)
  • You're building something like a code editor, database GUI, or design tool with complex native integrations
  • The mature electron-builder, electron-updater, and electron-store ecosystem matters

Choose Tauri if:

  • Bundle size and memory usage are important to end users (consumer-facing apps)
  • You want strong security by default — Rust's memory safety, no global require in renderer
  • Your team can invest in learning Rust (or has Rust expertise)
  • You're building a productivity tool, file manager, or anything where startup speed matters
  • Cross-compilation support is important (Tauri can compile for all platforms from CI)

Choose Neutralino if:

  • You're building a simple utility or internal tool with minimal native API needs
  • Bundle size is paramount (distributing a tiny CLI-wrapper with a UI)
  • You don't need Node.js or Rust at all — just a WebView window around a web app
  • You already have a backend service and just need an OS window

Skip all three if:

  • Your users are always online and a PWA with navigator APIs is sufficient
  • You only need one OS (use Swift/AppKit for macOS, WinUI for Windows)

Ecosystem Signals

Tauri's trajectory is remarkable: from 25k stars in 2022 to 85k in 2026, with an active plugin ecosystem and strong Rust community backing. The v2 release stabilized the API and improved Windows support (finally using WebView2 reliably). Enterprise adoption is growing — Tauri is increasingly used for internal tools at companies that care about security posture.

Electron isn't going anywhere. VS Code alone justifies its existence. But new projects starting in 2026 should have a strong reason to pick Electron over Tauri if bundle size or memory matters.

Neutralino is a legitimate niche tool. Don't dismiss it — for the specific case of "I want my web app to open in a native OS window with some file access," it's the right tool.


Methodology

Data from GitHub repositories, official documentation, npm download statistics, and community benchmarks from the Tauri Discord and Electron community forums. Star counts as of February 2026. Bundle sizes measured with minimal hello-world apps on macOS Sequoia. Memory benchmarks measured with Activity Monitor on M2 MacBook Pro. Pricing: all three frameworks are free and open source.


For cross-platform mobile (not desktop), see React Native vs Expo vs Capacitor. For build tooling comparisons, explore our Vite vs Webpack vs esbuild analysis.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.