writing/tutorial/2026/06
TutorialJun 5, 2026·25 min read

Build Ultra-Fast Desktop Apps with Electrobun and TypeScript (2026)

Learn how to build fast, lightweight cross-platform desktop applications using Electrobun — the TypeScript-native framework that produces apps up to 5× smaller than Electron. This tutorial covers project setup, typed IPC, file system access, native menus, and production packaging.

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:

FeatureElectronTauriElectrobun
RuntimeNode.js + ChromiumRust + system WebViewBun + system WebView
Minimum bundle140 MB3 MB12 MB
Backend languageJavaScript / TypeScriptRustTypeScript
IPC styleEvent stringsRust commandsTyped async RPC
Differential updatesManualBuilt-inBuilt-in (4 KB patches)
Learning curveLowHighLow

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 higher

Step 2: Scaffold the Project

Electrobun ships a project generator that sets up the correct folder layout and wires up the build config:

bunx electrobun init

Answer the prompts:

  • Project name: noteflow
  • Template: react
  • Package manager: bun

Then enter the directory and install dependencies:

cd noteflow
bun install

Your 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:

  1. RPC handlers are registered before app.on("ready"). The webview can start calling them the instant it loads.
  2. dialog.showOpenDialog is 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+S triggers 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 start

Electrobun 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 window

Step 10: Build for Production

When your app is ready to ship:

bunx electrobun build --env=stable

Output 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:

  1. The window opens with the correct title NoteFlow
  2. Clicking + creates a new .md file inside ~/.noteflow/notes/
  3. Typing in the editor auto-saves after 1.5 seconds (check the status text)
  4. Cmd+S / Ctrl+S triggers an immediate save
  5. Switching notes auto-saves the previous note before loading the next
  6. 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 marked library for live HTML rendering
  • System tray — use Electrobun's tray API to keep NoteFlow accessible without a window
  • Multiple windows — create a second BrowserWindow for a preferences panel
  • iCloud / file sync — point NOTES_DIR at 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.