Prerequisites
Before starting this tutorial, ensure you have:
- Bun 1.2+ installed (
curl -fsSL https://bun.sh/install | bash) - Solid TypeScript knowledge (interfaces, generics, async/await)
- Familiarity with React (hooks, state, effects)
- A code editor — VS Code with the Bun extension is recommended
- macOS 13+, Windows 10+, or Ubuntu 22.04+
What You'll Build
You'll create NoteFlow — a minimal markdown note-taking desktop app that:
- Creates, edits, and saves notes to your local file system
- Lists notes in a sidebar with single-click switching
- Uses typed RPC so the UI and backend share one type definition
- Bundles to under 20 MB — compared to 200 MB+ for an equivalent Electron app
By the end, you'll understand Electrobun's two-process model well enough to apply it to any desktop application you need to build.
Why Electrobun? The Developer Case
Electron revolutionized desktop development, but it ships an entire Chromium browser engine with every app. A "Hello World" Electron app is roughly 140 MB. Tauri solved the size problem by writing the backend in Rust — but now you need two languages.
Electrobun takes a different approach:
| Feature | Electron | Tauri | Electrobun |
|---|---|---|---|
| Runtime | Node.js + Chromium | Rust + system WebView | Bun + system WebView |
| Minimum bundle | 140 MB | 3 MB | 12 MB |
| Backend language | JavaScript / TypeScript | Rust | TypeScript |
| IPC style | Event strings | Rust commands | Typed async RPC |
| Differential updates | Manual | Built-in | Built-in (4 KB patches) |
| Learning curve | Low | High | Low |
The trade-off: no access to Rust plugins or WebAssembly-heavy native modules — but for the vast majority of business apps, NLP tools, or developer utilities, Electrobun's stack is more than sufficient.
Step 1: Install Bun
Electrobun requires Bun as its runtime. If you haven't installed it:
# macOS / Linux
curl -fsSL https://bun.sh/install | bash
# Windows (PowerShell)
powershell -c "irm bun.sh/install.ps1|iex"
# Verify
bun --version # should output 1.2.x or higherStep 2: Scaffold the Project
Electrobun ships a project generator that sets up the correct folder layout and wires up the build config:
bunx electrobun initAnswer the prompts:
- Project name:
noteflow - Template:
react - Package manager:
bun
Then enter the directory and install dependencies:
cd noteflow
bun installYour project structure looks like this:
noteflow/
├── electrobun.config.ts # App identity + build targets + update channel
├── package.json
├── bun.lockb
├── src/
│ ├── main/ # Main process — runs in Bun, has full OS access
│ │ └── index.ts
│ ├── shared/ # Types shared between main and webview
│ └── views/ # WebView UI — one folder per view
│ └── mainview/
│ ├── index.html
│ ├── index.tsx
│ └── style.css
└── public/ # Static assets copied into the bundle
Step 3: Understand the Two-Process Architecture
Electrobun splits your application into two isolated processes — the same model as Electron, but faster:
Main process (src/main/) runs in Bun:
- Creates and manages native windows (
BrowserWindow) - Reads and writes the file system via Bun's native APIs
- Builds native menus, system tray, dialogs
- Runs any privileged operation the WebView cannot do
WebView process (src/views/) renders your UI using the OS-native browser engine:
- macOS: WebKit (the same engine as Safari)
- Windows: Edge WebView2 (Chromium-based, ships with Windows 10+)
- Linux: WebKitGTK
The two processes talk via typed RPC — Electrobun's most important feature. You define a shared interface once and both sides get full TypeScript autocomplete and type-checking at compile time.
Step 4: Define the Shared RPC Contract
Create src/shared/rpc.ts:
import { defineRpc } from "electrobun/bun";
// Calls made FROM the WebView TO the main process
export const mainRpc = defineRpc({
listNotes: async (): Promise<string[]> => [],
readNote: async (filename: string): Promise<string> => "",
writeNote: async (filename: string, content: string): Promise<void> => {},
deleteNote: async (filename: string): Promise<void> => {},
openFilePicker: async (): Promise<string | null> => null,
});
// Calls made FROM the main process TO the WebView (push events)
export const webviewRpc = defineRpc({
onNoteChanged: async (filename: string): Promise<void> => {},
});defineRpc introspects the function signatures at build time. When the WebView calls mainRpc.call.readNote("my-note.md"), TypeScript enforces the exact parameter and return types — no stringly-typed event buses.
Step 5: Implement the Main Process
Replace src/main/index.ts with the full backend implementation:
import { BrowserWindow, app, dialog } from "electrobun/bun";
import { mainRpc } from "../shared/rpc";
import { join } from "path";
import { homedir } from "os";
import { mkdirSync } from "fs";
const NOTES_DIR = join(homedir(), ".noteflow", "notes");
mkdirSync(NOTES_DIR, { recursive: true });
// Register all RPC handlers before the window opens
mainRpc.handle({
async listNotes() {
const glob = new Bun.Glob("*.md");
const files: string[] = [];
for await (const file of glob.scan(NOTES_DIR)) {
files.push(file);
}
return files.sort();
},
async readNote(filename) {
const path = join(NOTES_DIR, filename);
const file = Bun.file(path);
if (!(await file.exists())) return "";
return file.text();
},
async writeNote(filename, content) {
await Bun.write(join(NOTES_DIR, filename), content);
},
async deleteNote(filename) {
await Bun.file(join(NOTES_DIR, filename)).delete();
},
async openFilePicker() {
const result = await dialog.showOpenDialog({
filters: [{ name: "Markdown", extensions: ["md", "txt"] }],
properties: ["openFile"],
});
return result.canceled ? null : result.filePaths[0];
},
});
app.on("ready", () => {
const win = new BrowserWindow({
title: "NoteFlow",
url: "views://mainview/index.html",
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
styleMask: ["titled", "closable", "miniaturizable", "resizable"],
});
win.show();
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});Two things to notice:
- RPC handlers are registered before
app.on("ready"). The webview can start calling them the instant it loads. dialog.showOpenDialogis a native OS file picker — no web permission prompts.
Step 6: Build the React WebView
Replace src/views/mainview/index.tsx:
import React, { useState, useEffect, useCallback, useRef } from "react";
import { createRoot } from "react-dom/client";
import { mainRpc } from "../../shared/rpc";
type Note = { filename: string; title: string };
function noteTitle(filename: string) {
return filename.replace(/\.md$/, "").replace(/-/g, " ");
}
function App() {
const [notes, setNotes] = useState<Note[]>([]);
const [active, setActive] = useState<string | null>(null);
const [content, setContent] = useState("");
const [dirty, setDirty] = useState(false);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
refresh();
}, []);
async function refresh() {
const files = await mainRpc.call.listNotes();
const noteList = files.map((f) => ({ filename: f, title: noteTitle(f) }));
setNotes(noteList);
if (noteList.length > 0 && active === null) {
await openNote(noteList[0].filename);
}
}
async function openNote(filename: string) {
if (dirty && active) await save(active, content);
const text = await mainRpc.call.readNote(filename);
setActive(filename);
setContent(text);
setDirty(false);
}
function handleChange(value: string) {
setContent(value);
setDirty(true);
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
if (active) save(active, value);
}, 1500);
}
const save = useCallback(async (filename: string, text: string) => {
await mainRpc.call.writeNote(filename, text);
setDirty(false);
}, []);
async function newNote() {
const timestamp = new Date().toISOString().split("T")[0];
const filename = `note-${timestamp}-${Date.now()}.md`;
await mainRpc.call.writeNote(filename, `# New Note\n\n`);
await refresh();
await openNote(filename);
}
async function deleteActive() {
if (!active || !confirm(`Delete "${noteTitle(active)}"?`)) return;
await mainRpc.call.deleteNote(active);
setActive(null);
setContent("");
setDirty(false);
await refresh();
}
return (
<div className="app">
<aside className="sidebar">
<div className="sidebar-header">
<span className="brand">NoteFlow</span>
<button className="icon-btn" onClick={newNote} title="New note">+</button>
</div>
<ul className="note-list">
{notes.map((n) => (
<li
key={n.filename}
className={n.filename === active ? "active" : ""}
onClick={() => openNote(n.filename)}
>
{n.title}
</li>
))}
</ul>
</aside>
<main className="editor-area">
{active ? (
<>
<div className="toolbar">
<span className="status">{dirty ? "Unsaved…" : "Saved"}</span>
<button onClick={() => active && save(active, content)}>Save</button>
<button className="danger" onClick={deleteActive}>Delete</button>
</div>
<textarea
className="editor"
value={content}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
if (active) save(active, content);
}
}}
spellCheck={false}
/>
</>
) : (
<div className="empty-state">
<p>Click + to create your first note.</p>
</div>
)}
</main>
</div>
);
}
const el = document.getElementById("app");
if (el) createRoot(el).render(<App />);Key patterns here:
- Auto-save after 1.5 seconds of inactivity — no need to press Save every time
Cmd+S/Ctrl+Striggers an immediate save- Opening a dirty note auto-saves before switching
Step 7: Add CSS
Replace src/views/mainview/style.css:
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #1a1a2e;
--surface: #16213e;
--border: #0f3460;
--accent: #e94560;
--text: #e0e0e0;
--muted: #7a7a9a;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
overflow: hidden;
}
.app {
display: grid;
grid-template-columns: 240px 1fr;
height: 100vh;
}
.sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 12px;
border-bottom: 1px solid var(--border);
}
.brand {
font-weight: 600;
font-size: 15px;
letter-spacing: 0.5px;
}
.icon-btn {
background: var(--accent);
color: white;
border: none;
border-radius: 50%;
width: 26px;
height: 26px;
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.note-list {
list-style: none;
flex: 1;
overflow-y: auto;
padding: 8px;
}
.note-list li {
padding: 8px 10px;
border-radius: 5px;
cursor: pointer;
font-size: 13px;
color: var(--muted);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
margin-bottom: 2px;
}
.note-list li:hover {
background: var(--border);
color: var(--text);
}
.note-list li.active {
background: var(--border);
color: var(--text);
font-weight: 500;
}
.editor-area {
display: flex;
flex-direction: column;
height: 100vh;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.toolbar .status {
flex: 1;
color: var(--muted);
}
.toolbar button {
padding: 4px 12px;
background: var(--border);
color: var(--text);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.toolbar button.danger {
background: transparent;
color: var(--accent);
}
.editor {
flex: 1;
background: var(--bg);
color: var(--text);
border: none;
padding: 28px 32px;
font-size: 15px;
font-family: "JetBrains Mono", "Fira Code", "Menlo", monospace;
line-height: 1.75;
resize: none;
outline: none;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--muted);
font-size: 15px;
}Step 8: Run in Development Mode
Start the development server:
bun startElectrobun launches the Bun main process and opens a native window showing your WebView. Changes to the WebView source hot-reload the UI automatically. Main process changes require a restart (Ctrl+C, then bun start).
You should see a dark sidebar on the left and an editor area on the right. Click + to create your first note.
Step 9: Add a Native Menu
Add a proper macOS/Windows app menu in src/main/index.ts:
import { Menu, MenuItem } from "electrobun/bun";
function buildMenu(win: BrowserWindow) {
const menu = new Menu();
const fileMenu = new MenuItem({
label: "File",
submenu: [
new MenuItem({
label: "New Note",
accelerator: "CmdOrCtrl+N",
click: () => win.webContents.executeScript("window.__newNote?.()"),
}),
new MenuItem({ type: "separator" }),
new MenuItem({ role: "quit" }),
],
});
const editMenu = new MenuItem({
label: "Edit",
submenu: [
new MenuItem({ role: "undo" }),
new MenuItem({ role: "redo" }),
new MenuItem({ type: "separator" }),
new MenuItem({ role: "cut" }),
new MenuItem({ role: "copy" }),
new MenuItem({ role: "paste" }),
new MenuItem({ role: "selectAll" }),
],
});
menu.append(fileMenu);
menu.append(editMenu);
Menu.setApplicationMenu(menu);
}
// Call buildMenu(win) inside app.on("ready") after creating the windowStep 10: Build for Production
When your app is ready to ship:
bunx electrobun build --env=stableOutput locations:
- macOS:
dist/NoteFlow.app(zip for distribution:dist/NoteFlow-mac-arm64.zip) - Windows:
dist/NoteFlow-win-x64.exe - Linux:
dist/NoteFlow-linux-x64.AppImage
The macOS .app bundle will be roughly 14–64 MB depending on which npm packages you pulled in — no Chromium, no Node.js, no Electron runtime.
Step 11: Enable Differential Updates
Electrobun's update system generates binary diffs between releases. A typical update is 4–14 KB instead of a full reinstall.
Configure electrobun.config.ts:
import { defineConfig } from "electrobun/config";
export default defineConfig({
app: {
name: "NoteFlow",
version: "1.0.0",
identifier: "tn.noqta.noteflow",
},
updates: {
provider: "github",
owner: "your-org",
repo: "noteflow",
channel: "stable",
},
build: {
targets: ["mac-arm64", "mac-x64", "win-x64", "linux-x64"],
},
});Check for updates from the main process:
import { autoUpdater } from "electrobun/bun";
autoUpdater.on("update-downloaded", () => {
autoUpdater.quitAndInstall();
});
await autoUpdater.checkForUpdates();Pair this with a GitHub Actions release workflow and your users get seamless, fast updates every time you push a new tag.
Testing Your Implementation
After bun start, verify these checkpoints:
- The window opens with the correct title NoteFlow
- Clicking + creates a new
.mdfile inside~/.noteflow/notes/ - Typing in the editor auto-saves after 1.5 seconds (check the status text)
Cmd+S/Ctrl+Striggers an immediate save- Switching notes auto-saves the previous note before loading the next
- After
bunx electrobun build, the output bundle is under 100 MB
Troubleshooting
Blank white window on launch
Check that src/views/mainview/index.html has <div id="app"></div> and that index.tsx imports style.css. Run bun run build:webview separately to surface bundle errors.
RPC call hangs forever
Ensure mainRpc.handle(...) is called before app.on("ready"). If the handler is registered after the window opens, the first RPC call from the WebView may arrive before the handler exists.
Linux: blank WebView on Ubuntu 22.04
Install the WebKit dependency: sudo apt install webkit2gtk-4.1. On Ubuntu 24.04+, this is installed by default.
Build output is larger than expected
Use Bun's bundler --external flag in electrobun.config.ts to mark large packages as external and bundle only what you need. Check for accidental inclusions with bunx electrobun analyze.
macOS Gatekeeper warning on first launch
For distribution outside the Mac App Store, sign and notarize your app using Apple Developer certificates. Electrobun's build command accepts a --sign flag that integrates with xcrun notarytool.
Next Steps
Once NoteFlow is working, here are natural extensions to explore:
- Markdown preview — add a split pane using the
markedlibrary for live HTML rendering - System tray — use Electrobun's tray API to keep NoteFlow accessible without a window
- Multiple windows — create a second
BrowserWindowfor a preferences panel - iCloud / file sync — point
NOTES_DIRat an iCloud Drive folder on macOS for transparent sync - GitHub Actions release pipeline — automate build and notarization on push to
main
Conclusion
You've built a fully functional desktop app in pure TypeScript — no Rust required, no bundled browser engine, and a bundle under 20 MB. Electrobun's typed RPC bridges the main and WebView processes cleanly, and its differential update system means your users never download a full reinstall.
Electrobun is still maturing, but v1 is production-ready for internal tools, developer utilities, and business apps. If you're comfortable with TypeScript and want to ship native desktop software without learning Rust, Electrobun is the most ergonomic path available in 2026.