Building Desktop Apps That Don't Suck
The DMG was 212 MB.
For an app that records audio and saves text files.
I shipped it once. Then I rewrote the whole thing.
NBP needed to feel instant because it records live audio. Startup lag breaks trust.
What NBP Is
NBP (No Bullshit Pipeline) is a macOS desktop app — audio recording, on-device transcription, local file storage. No cloud. No accounts. Just a native app that captures meetings and turns them into searchable text.
This is the final article in the series. The previous four covered privacy architecture, audio capture, file storage, and local Whisper transcription. This one is about how the app is packaged and why the first approach was wrong.
How It Started: Electron
I know Electron. Most web developers know Electron.
You write HTML, CSS, JavaScript. You call electron-builder. You get a .app.
That .app is 200 MB.
Not because your code is 200 MB. Your code is maybe 2 MB. The rest is Chromium. A full browser engine, bundled into every app, running on every user’s machine.
Memory at runtime: ~400 MB before you’ve done anything. Just the shell. The browser waiting for your JS to run.
Cold start: ~800ms on an M1. Perceptible. You notice it.
For a simple notes app, that’s acceptable. People ship it. VS Code ships it. Slack ships it.
For NBP — which needs to feel fast because it’s capturing live audio — it wasn’t acceptable.
The Rewrite: Tauri
Tauri is a framework for building desktop apps with a Rust backend and a web frontend.
The difference from Electron: instead of bundling Chromium, Tauri uses the OS WebView. On macOS, that’s WKWebView. It’s already installed. It’s already running for Safari. Your app doesn’t ship another browser.
Binary size: 15 MB.
Memory at runtime: ~40 MB.
Cold start: under 100ms. The app is open before you’ve blinked.
Same frontend — HTML, CSS, JavaScript. Same result on screen. Different infrastructure underneath.
The Rust Backend
Rust was already the right choice for NBP.
Audio capture on macOS means CoreAudio APIs. Calling them from JavaScript via Electron IPC is awkward. Latency shows up in the recording. The buffer math gets messy.
From Rust, it’s direct. coreaudio-rs binds to native APIs. The capture loop runs tight. No JavaScript event loop involved in the hot path.
Tauri’s model is: Rust handles the serious work. The frontend handles the display. They communicate via commands.
A button click in the UI calls invoke('start_recording'). Rust receives it. Rust starts the CoreAudio capture loop. Rust returns a status. The UI updates.
No abstraction layers between the system and the audio hardware.
The Frontend: Vanilla JS
I didn’t use React.
I didn’t use Vue. I didn’t use Svelte. I didn’t use anything.
HTML. CSS. JavaScript. document.querySelector. Event listeners. That’s it.
NBP’s UI is a few screens: recording controls, file list, settings. There’s no state management problem that requires a framework. There’s no component hierarchy complex enough to justify a bundler.
No node_modules folder with 40,000 files. No webpack config. No transpilation step.
cargo tauri build runs. It compiles the Rust. It packages the HTML/JS. It produces a signed .app and a .dmg. Done.
The Build Pipeline
cargo tauri build
That’s the command. One command.
Output:
target/release/bundle/macos/NBP.app # 15 MB
target/release/bundle/dmg/NBP_1.0.dmg # 17 MB
Notarization runs automatically via the Tauri config. Apple checks the binary. The user gets no “unverified developer” warning.
Signing requires an Apple Developer account. That’s $99/year. That’s the only ongoing cost.
No CI complexity. No Docker build environments. Local machine, one command, signed and notarized.
The Tauri 2 Migration
Tauri 2 released while I was building.
The API changed. Substantially.
In Tauri 1, you define commands in main.rs. You call them from the frontend with window.__TAURI__.invoke().
In Tauri 2, the same concepts exist. The import paths moved. The plugin system restructured. Permission scopes changed. The file system API became a plugin instead of a core feature.
Migration took three days.
Not because it was architecturally different — the concepts are the same. The friction was documentation lag. Tauri 2 shipped before the docs fully caught up. Stack Overflow had answers for Tauri 1. GitHub issues were the real docs.
I read a lot of GitHub issues.
It’s fine now. Tauri 2 is stable. The docs are current. Starting a new project today, there’s no friction.
The Numbers That Matter
| Electron | Tauri | |
|---|---|---|
| Binary size | ~200 MB | ~15 MB |
| RAM at idle | ~400 MB | ~40 MB |
| Cold start | ~800ms | <100ms |
| Requires Rust | No | Yes |
The tradeoff is real. Electron you can learn in an afternoon. Tauri requires knowing Rust before you start. Rust has a learning curve. The borrow checker is unforgiving.
If you’re building a quick prototype, Electron is fine. Ship fast. Figure out performance later.
If you’re building something people will use every day — something that lives in the menu bar or opens constantly — 400 MB of RAM and 800ms startup is a tax your users pay every time.
What Native WebView Means
The app uses WKWebView. Same engine as Safari.
That means CSS animations are smooth. Scrolling is native. Font rendering is correct. It feels like a Mac app because it uses Mac rendering.
With Electron, you get Chrome’s rendering. On macOS, that’s subtly wrong. Chrome doesn’t use the system font stack. Scrolling physics don’t match. The app feels like a webpage.
Small differences. Users don’t articulate them. They just say “this feels fast” or “this feels like a website.”
Tauri apps feel like the first thing.
The Result
NBP is 15 MB.
It opens in under 100ms. It records audio with no perceptible latency. It transcribes locally. It stores plain files.
And it does all of that in 15 MB because it doesn’t carry a browser.
The right tool for the job isn’t the familiar tool. It’s the one that doesn’t apologize for its size.
Next: Transcription Is Step One..