Sik.limited Logo

Electron Alternative Framework: Criteria for Choosing Tauri·Wails·Neutralinojs

We've summarized the criteria you should look at before abandoning Electron or choosing Tauri. We compare Tauri·Wails·Neutralinojs focusing on WebView consistency, native boundaries, team language, security model, and deployment/operation costs rather than bundle size.

Sik ·

The reasons for looking for an alternative to Electron usually start with one thing: The app file is large. Uses a lot of memory. I want to write native code in a language other than Node.js. Or, a demand arose: “I want to publish the web code base on the desktop.” However, if you choose one framework name as the answer, you will usually encounter other costs after release.

Tauri, Wails, and Neutralinojs can all create screens with HTML, CSS, and JavaScript, and leave operating system functions to a separate native layer. However, the language for designing the boundary, security model, execution environment, and debugging method when a problem occurs are quite different. A small installation file may be a good thing, but it is not a good starting point for selection.

This article does not repeat the conclusion that “Electron is heavy and Tauri is light”. First, we'll sort out which product renderer consistency is more important for, which operating language is better between Rust and Go, and whether you're ready to accept a system WebView. The selection criteria for Electron itself is Tauri vs Electron: SvelteKit desktop app selection criteria, and the issue of expanding one code base to mobile is [Capacitor vs Tauri: Choosing to extend one web code base to mobile and desktop apps] Standard](https://sik.limited/thinking/capacitor-vs-tauri-web-mobile-desktop-app).

First, name the cost you want to throw away.

The term “replace” Electron is a mix of four different needs.

actual needsQuestions to check firststrong direction
Installation files and initial memoryShould the browser engine be bundled with each app?Tauri·Wails·Neutralinojs
Processing performance of native functionsWhich language and library will you use to do the heavy lifting?Tauri(Rust)·Wails(Go)
Front-end rendering consistencyIs it really necessary for Chromium to behave the same on all OSes?Electron Sustainability
Node dependency reductionWhat can I move the logic outside of the renderer to?Tauri·Wails·Neutralinojs

Here the first and third often come into conflict. Electron bundles Chromium and Node.js together, so the app package can be large. Instead, the browser engine the app runs on is relatively consistent. Conversely, the three alternatives utilize the operating system's WebView, which has the advantage of not including a browser engine in the app. However, differences in the WebView version and engine that the user actually has on Windows, macOS, and Linux may affect product quality.

So the unit of comparison is not the “average benchmark of the framework” but the risk of my product. Are there any features that are sensitive to engine differences, such as WebRTC, advanced Canvas, modern CSS, web-based editors, or browser extension APIs? Conversely, if you're just a utility to quickly process local files, an in-house input tool, or a small productivity app, the consistency of bundled Chromium may be worth the cost.

The three frameworks are not the same WebView app

ItemTauriWailsNeutralinojs
Main language of native layerRustGoC++ core + extended language freedom
screen engineSystem WebViewSystem WebViewSystem WebView or Chrome Mode
Frontend → Native Connectioncommand·plugin·capabilityGo method binding·runtimeWebSocket-based Native API·extension
A team that fits wellA team that embraces Rust and values ​​permissioned designTeam with strong Go service/backend experienceTeam quickly experimenting with small desktop tools centered around JS
Risks to be verified firstWebView and capability settings for each OSbinding surface and Go execution modelnative API allowlist and extension·WebSocket boundaries

The table is a sequence of questions, not a conclusion. All three can use React, Svelte, Vue or pure web on the front end. The real difference occurs outside the UI. It depends on what types of file system, tray, auto-update, DB, hardware, long tasks, and OS events you want to call and test.

Tauri: When you want to handle the permission model as a product code

Tauri bridges the JavaScript frontend and Rust core. A key feature of Tauri v2 is that the API exposed to the front end can be limited by capabilities and permissions. Basically, assuming the scope of access to the bundled app code, you can configure permissions to be granted for each window, WebView, and origin.

Gathering permissions in one file can be cumbersome at first. However, as soon as the remote content window and the main app window are mixed or a plug-in is added, “which screens can this feature be exposed to?” can be subject to code review.

// src-tauri/capabilities/main.json
{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "main-window",
  "description": "메인 앱 창에만 필요한 최소 권한",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "dialog:allow-open",
    "dialog:allow-save",
    {
      "identifier": "fs:allow-read-text-file",
      "allow": [{ "path": "$HOME/Documents/**" }]
    }
  ]
}

For specific permission identifiers and schema paths, you must check the values ​​generated in the official documentation according to the Tauri·plugin version you are using. The purpose of the example is not to memorize grammar, but to get into the habit of expressing scope, such as “reading text in the user's documents folder” rather than “the entire file system.”

Rust commands are also designed as product APIs that the renderer can call. It is better to not allow a single command to become a general-purpose shell or general-purpose file API.

// src-tauri/src/lib.rs
#[tauri::command]
fn normalize_note_title(title: String) -> Result<String, String> {
    let trimmed = title.trim();
    if trimmed.is_empty() {
        return Err("제목을 입력해 주세요.".into());
    }

    Ok(trimmed.chars().take(80).collect())
}

pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![normalize_note_title])
        .run(tauri::generate_context!())
        .expect("error while running Tauri application");
}
// src/lib/native/notes.ts
import { invoke } from '@tauri-apps/api/core'

export function normalizeNoteTitle(title: string) {
  return invoke<string>('normalize_note_title', { title })
}

The reason Tauri is right isn't just one sentence: Rust is faster. It's compelling when native operations like file hashing, image processing, and local synchronization are core to your product, you want to narrowly manage permissions, and you can run reviews, builds, and debugging of Rust code. Conversely, if a team can't maintain Rust at all and chooses based solely on install size, the native boundary may become a bottleneck.

Wails: When you want to bring the Go service model to your desktop.

Wails creates bindings so that Go methods can be called from the front end. This is a natural choice if you already run your sync engine, local servers, encryption/file processing, and domain logic in Go, or if your backend team is familiar with Go. The frontend is easy to understand as a model that “communicates with the Go backend.”

// app.go
package main

import (
  "context"
  "fmt"
  "strings"
)

type App struct {
  ctx context.Context
}

func NewApp() *App { return &App{} }

func (a *App) startup(ctx context.Context) {
  a.ctx = ctx
}

func (a *App) NormalizeNoteTitle(title string) (string, error) {
  value := strings.TrimSpace(title)
  if value == "" {
    return "", fmt.Errorf("제목을 입력해 주세요")
  }
  runes := []rune(value)
  if len(runes) > 80 {
    runes = runes[:80]
  }
  return string(runes), nil
}

It is convenient for the UI to use the binding created in Wails development mode directly. However, rather than scattering the product path throughout the UI, wrapping it in a small adapter makes framework replacement and testing easier.

// frontend/src/lib/native/notes.ts
import { NormalizeNoteTitle } from '../../wailsjs/go/main/App'

export async function normalizeNoteTitle(title: string) {
  return NormalizeNoteTitle(title)
}

Just because you have Go binding doesn't mean you need to expose all exported methods to the UI. Since the App structure can be the surface of the front-end API, it is safer to place methods such as database handle, operational diagnosis, and environment variable access in separate internal services and narrow down only the UI methods. For UI calls, bring the same habits from the server API, such as input validation, checking the current window/user state, and limiting file paths.

Wails is an unfortunate choice if you just look at it as “Tauri, but Go instead of Rust.” If Go's goroutines, standard libraries, existing service code, and deployment tools are your team's strengths, the desktop app's local layer can also take over. On the other hand, WebView's OS-specific differences, distribution signatures, and platform-specific verification of native functions still need to be done once you leave Electron.

Neutralinojs: For sharpening the boundaries of small and simple tools.

Neutralinojs uses a small core and system WebView, and has a built-in JavaScript client that calls native API through a local WebSocket. If necessary, the extension can be written in another language. They're attractive when you only need the UI and a few OS features, such as small desktop utilities, internal tools, or light experiments.

However, this does not mean that “designing permissions is simple because only JavaScript is used.” The native API can include powerful features such as file systems, processes, and windows. allowlist and blocklist must be specified according to product requirements.

// neutralino.config.json
{
  "applicationId": "com.example.notes",
  "defaultMode": "window",
  "enableNativeAPI": true,
  "tokenSecurity": "one-time",
  "nativeAllowList": [
    "app.*",
    "window.*",
    "filesystem.readFile",
    "filesystem.writeFile",
    "os.showOpenDialog",
    "os.showSaveDialog"
  ],
  "nativeBlockList": [
    "os.execCommand",
    "extensions.*"
  ]
}

One thing to note about this configuration is that it blocks os.execCommand. The request that the user wants to “save and open files” does not mean that the “renderer can execute arbitrary commands.” Turn on only the necessary native APIs, and even when extensions are needed, the extension's input, authentication, and logs must be designed separately.

// resources/js/notes.ts
import '@neutralinojs/lib'

export async function saveNote(name: string, content: string) {
  const safeName = name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 80)
  const selected = await Neutralino.os.showSaveDialog('메모 저장', {
    defaultPath: `${safeName}.md`
  })

  if (!selected) return { saved: false }
  await Neutralino.filesystem.writeFile(selected, content)
  return { saved: true }
}

Here too, the file path and extension/size restrictions of the actual product must be reviewed separately. The point is that choosing Neutralinojs does not automatically guarantee safe defaults. Local WebSocket, token, and native API allowlist are the product's permission models.


System WebView changes position without eliminating cost

The fact that Tauri·Wails·Neutralinojs does not bundle the browser engine with each app is a clear advantage. However, the system WebView moves with the user's OS updates. Depending on the operating system, WebView provider, and Linux distribution, you may encounter differences in CSS, input, media, GPU, Web API, and debugging tools.

There is no need to exaggerate this difference. Common forms, lists, settings, and local CRUD screens usually work well. However, if the core experience of the product relies on the latest features of the web engine or pixel-level consistency, the actual OS combination must be confirmed in the PoC.

FeaturesThings to do in PoC
Korean, Japanese, Chinese inputCheck cancellation, focus movement, and shortcut conflict during combination using the actual keyboard
Drag and drop filesCheck path/permission/large file/cancel by OS
Audio·Video·WebRTCCheck permission pop-up, switch devices, return to background/sleep
Canvas·EditorMeasure long documents, high DPI, zoom, GPU fallback
Automatic updateInstall signed release→Update→Rollback on each platform
Offline StartCheck home screen and local data recovery while disconnected from network

Before changing the framework, try performing the above task as a representative screen rather than “hello world package size”. This is because it is the test that most exposes the product's native boundaries and WebView boundaries. Electron may be a choice that reduces this verification, and an alternative framework may be a choice that reduces installation/update costs in return.

Migration does not move from the renderer

The most risky approach when moving an existing Electron app to Tauri or Wails is to translate the main process code all at once. If you transfer a general-purpose API like ipcRenderer.invoke('fs:write') to a Tauri command or Go method, only the framework changes and the boundary remains the same.

First, organize Electron's bridge into a product verb. If you already have a narrow window.desktop.saveExport() and window.desktop.openSettings(), you can just replace the adapter with the renderer barely touching anything.

// renderer가 의존할 공통 계약
export interface DesktopPlatform {
  saveExport(input: { name: string; content: string }): Promise<{ saved: boolean }>
  getAppVersion(): Promise<string>
  openDocumentation(): Promise<void>
}
// electron-platform.ts
export const electronPlatform: DesktopPlatform = {
  saveExport: (input) => window.desktop.saveExport(input),
  getAppVersion: () => window.desktop.getAppVersion(),
  openDocumentation: () => window.desktop.openDocumentation()
}
// tauri-platform.ts
import { invoke } from '@tauri-apps/api/core'
import { openUrl } from '@tauri-apps/plugin-opener'

export const tauriPlatform: DesktopPlatform = {
  saveExport: (input) => invoke('save_export', { input }),
  getAppVersion: () => invoke('app_version'),
  openDocumentation: () => openUrl('https://docs.example.com')
}

The advantage of this structure is not portability but verifiability. You'll know when the list of desktop features your UI actually requires is revealed, and the framework-specific implementations outgrow that list. This is especially useful for products that require PWA, Capacitor, Tauri, and Electron to operate together.

Minimum scope for a 4-week decision PoC

PoC is not a project to use the CLI of a new framework. The task is to proactively find the most expensive failures after deployment.

  1. First week — Pick one representative flow. Implement one flow that is important in the actual product: logging in, opening/saving local files, long list/editor, checking for updates.
  2. Week 2 — Create native boundaries. Attach at least two of the following to actual code: file permissions, keychain, tray, deep links, and OS notifications. It is not passed as a mock.
  3. Week 3 — Run the operating system matrix. Verify installation, offline startup, sleep recovery, input method, and log collection on supported Windows, macOS, and Linux combinations.
  4. Fourth week — Practice deployment and failure. Run signature, update, crash report, migration of existing data, downgrade or rollback once.

Performance indicators are also determined in advance. Don't just measure cold starts, idle memory, typical operation times, and package size; also record build times, CI times, bug reproduction difficulty, number of fixes per platform, and the time it takes for a new team member to add a single native feature. These numbers better describe the long-term costs.

Short decision tree for selection

모든 OS에서 Chromium 기반의 같은 렌더링이 핵심인가?
├─ 예 → Electron을 유지하거나 별도 Chromium 전략을 검토한다.
└─ 아니오 → 시스템 WebView의 PoC를 한다.
    │
    ├─ Rust로 로컬 작업을 다루고 capability 기반 권한을 명시하고 싶은가?
    │  └─ 예 → Tauri
    │
    ├─ Go 서비스 코드·인력이 있고 Go binding이 자연스러운가?
    │  └─ 예 → Wails
    │
    └─ 작은 JS 중심 도구이며 제한된 native API만 필요한가?
       └─ 예 → Neutralinojs

This tree is not an absolute prescription. Tauri allows you to use less Rust, Wails allows you to create complex native apps, and Neutralinojs can be extended with extensions. However, it shows the path to obtain the least advantage of the framework. The more aligned the core product capabilities are with the operational capabilities required by the framework, the greater the benefits are than the smaller installation files.

Conclusion: Choosing a boundary that can be operated rather than lightweight

There may be plenty of reasons to leave Electron. However, if you define a “light app” in terms of the number of MB per package, you will discover WebView differences, permission model, build toolchain, and deployment responsibilities later.

Tauri is a great fit for teams that want to create permissioned native boundaries with Rust and capabilities. Wails is suitable for teams that want to naturally connect service capabilities already built in Go to the desktop. Neutralinojs' strengths lie in creating small-featured, well-bounded desktop tools centered around JS. And if Chromium consistency is the core of your product, then Electron is not a failure that needs to be replaced, but a stable foundation that you can choose with cost awareness.

Good choices come not from which framework is the “winner,” but from actually verifying my app’s most risky screens and strongest OS permissions before release.

Official Documentation

Latest posts