Deno Desktop Tutorial: Build a Native App From TypeScript, No Electron or Rust
A hands-on build of a real app with Deno 2.9's deno desktop command: Deno.serve() integration, window.bind() calls, WebView vs CEF, and packaging for macOS, Windows, and Linux.
On this page
For years, shipping a web app as a desktop app meant picking your pain. Electron gives you the whole Chromium engine and the entire npm ecosystem, and most projects balloon past 100MB before a single feature is written. Tauri gets you a much smaller binary by using the system's native webview, but the moment you need real backend logic, you are writing Rust.
Deno 2.9 (June 2026) added a third option: deno desktop, a canary command that compiles an existing Deno web project into a native binary with no Electron and no Rust, just TypeScript. This is a hands-on walkthrough of building a small real app with it: a local note-taking tool with a working save button, a direct binding call from the UI into Deno, and a packaged binary at the end.
If you have already adopted Deno or Bun for the backend, this pairs naturally with the built-in database clients covered in Bun 1.3's SQL and Redis clients. And if you are new to server-side JavaScript runtimes generally, the Node.js fundamentals guide is a useful primer before diving into runtime-specific features like this one.
The Desktop Packaging Problem
Electron's pitch has always been simple: ship a full Chromium instance alongside your app so rendering is identical everywhere, and write your backend logic in the same Node.js you already know. The cost is size. Even a minimal Electron app ships a full browser engine, which is why Electron binaries routinely start above 100MB before you have added any real functionality.
Tauri answers the size problem by using the operating system's own webview instead of bundling Chromium, which gets binaries down into single-digit megabytes. The tradeoff shows up on the backend: Tauri's application logic is written in Rust. For a team that has standardized on TypeScript across the frontend and backend, that is a real barrier, not just a new syntax to learn.
Neither option has felt like a natural fit for a team that is all-in on TypeScript and does not want to either bloat the binary or pick up a new systems language just to ship a desktop build.
What deno desktop Actually Does
The core idea is almost suspiciously simple. You already have a Deno.serve() handler. Point deno desktop at it, and it starts your server locally, opens a native OS window, and points that window's webview at the server. Deno.serve() called inside a desktop entrypoint automatically binds to the port the webview opens, so there is no manual port wiring to do.
Deno.serve(() =>
new Response("<!DOCTYPE html><h1>Hello from Deno Desktop</h1>", {
headers: { "content-type": "text/html" },
})
);deno desktop main.tsThat opens a real native window rendering the response. No build step and no config file required for something this simple. deno desktop . also works and will auto-detect a framework (Next.js, Astro, SvelteKit, and Vite projects are all supported) if you are pointing it at an existing project instead of a single script.
Building a Real App: A Local Note-Taking Tool
A hello-world does not tell you much about how this feels to actually build with, so here is a small note-saving app with a real save button and a request that round-trips through the Deno backend.
const notes: string[] = [];
Deno.serve((req) => {
const url = new URL(req.url);
if (url.pathname === "/save" && req.method === "POST") {
return req.text().then((note) => {
notes.push(note);
return new Response("saved");
});
}
return new Response(
`<!DOCTYPE html>
<textarea id="note"></textarea>
<button onclick="save()">Save</button>
<script>
async function save() {
const text = document.getElementById('note').value;
await fetch('/save', { method: 'POST', body: text });
}
</script>`,
{ headers: { "content-type": "text/html" } },
);
});This is a plain HTTP round trip through fetch(), and it works exactly like a normal web app would. It is also not the most interesting part of building on deno desktop, because the backend and the UI here are running in the same process on the same machine. There is a faster path that skips the HTTP layer entirely.
Calling Deno Directly With window.bind()
Because the UI and the backend share a single process under deno desktop, there is no inter-process communication boundary to cross the way there is between Electron's main and renderer processes. Deno exposes this directly: you bind a function in the entrypoint with window.bind(), and it becomes callable from page JavaScript through a bindings namespace, no fetch() call and no serialized HTTP request involved.
const notes: string[] = [];
const window = new Deno.BrowserWindow({ title: "Notes" });
window.bind("saveNote", (note: string) => {
notes.push(note);
return { saved: true, total: notes.length };
});
window.load(
`<!DOCTYPE html>
<textarea id="note"></textarea>
<button onclick="save()">Save</button>
<script>
async function save() {
const text = document.getElementById('note').value;
const result = await bindings.saveNote(text);
console.log(\`Saved. Total notes: \${result.total}\`);
}
</script>`,
);bindings.saveNote(text) calls straight into the Deno function and gets the return value back as a resolved promise, no request body to serialize and no response to parse. For anything beyond a trivial CRUD form, this is the pattern worth reaching for first: it removes an entire network hop that exists purely because Electron and Tauri need it to cross a process boundary that deno desktop does not have.
Native Desktop APIs: Windows, Tray, and Dock
Beyond the window and binding basics, Deno exposes a small set of desktop-specific namespaces: Deno.BrowserWindow for window properties, menus, and DevTools access; Deno.Tray for a system tray icon; and Deno.Dock for macOS dock integration.
const window = new Deno.BrowserWindow({
title: "Notes",
width: 480,
height: 640,
});
const tray = new Deno.Tray({ tooltip: "Notes app" });
tray.onClick(() => window.show());These are the same building blocks Electron and Tauri each expose in their own way (Electron's Tray class and Tauri's tray-icon plugin cover the same ground), just accessed directly off the Deno global instead of through a separate package.
The practical benefit is fewer moving parts. In Electron, a tray icon, a custom menu, and a DevTools toggle each typically mean pulling in a small helper package or writing main-process boilerplate to wire them up. Here, they are calls on objects you already have: the window you created for the app and a tray you construct next to it, both running in the same process as your application logic.
WebView vs CEF: Which Backend to Pick
deno desktop supports two rendering backends, selected with the --backend flag. The default, WebView, delegates to the operating system's own engine: WebView2 on Windows, WebKit on macOS and Linux. This keeps the binary small and launch fast since it is not shipping its own rendering engine, but your UI can render slightly differently across machines depending on how current each platform's system webview is.
CEF (Chromium Embedded Framework) bundles an actual copy of Chromium, which guarantees identical rendering across every platform at the cost of a much larger download, closer to what Electron ships.
# Default: OS-native WebView (small binary, launch fast)
deno desktop main.ts
# CEF: bundled Chromium (consistent rendering, larger binary)
deno desktop --backend cef main.ts| WebView (default) | CEF | |
|---|---|---|
| Rendering engine | OS-native (WebKit / WebView2) | Bundled Chromium |
| Binary size | Small | Large, closer to Electron |
| Rendering consistency | Varies by OS webview version | Identical across platforms |
| Best for | Internal tools, side projects | Customer-facing UI where visual consistency matters |
If your app is an internal tool where a little rendering variance across machines does not matter, default to WebView. If you are shipping something customer-facing where visual consistency is a requirement, CEF is the safer call even with the larger download.
Packaging and Distributing the Binary
This is the part that actually removes the CI-matrix headache developers usually build around Electron and Tauri: deno desktop can cross-compile output binaries for macOS, Windows, and Linux from a single machine, without a separate Windows or Mac runner.
# Build for the host platform, output format inferred from the extension
deno desktop --output MyApp.dmg main.ts
# Cross-compile to a specific target
deno desktop --target x86_64-pc-windows-msvc main.ts
# Build every supported target from one machine
deno desktop --all-targets main.ts
# Bundle the runtime and UI as a self-extracting archive
deno desktop --compress main.tsOutput formats are inferred from the extension you pass to --output: .dmg and .app for macOS, .exe and .msi for Windows, .AppImage, .deb, and .rpm for Linux. --all-targets currently covers five targets: Linux x64 and arm64, Windows x64, and macOS x64 and arm64.
How This Compares to Electron and Tauri
| Electron / Tauri | Deno Desktop | |
|---|---|---|
| Backend language | JS/TS/Node (Electron) or Rust (Tauri) | TypeScript, same as the rest of a Deno app |
| Bundle size | Large (Electron, 100MB+) / small (Tauri) | Small with WebView, large with CEF |
| Rendering consistency | Consistent (Electron bundles Chromium) / varies by OS (Tauri) | Varies with WebView, consistent with CEF |
| UI-to-backend calls | IPC across a main/renderer process boundary | Direct window.bind() calls, single shared process |
| Cross-platform packaging | Needs per-OS build runners in most setups | Cross-compiles for macOS, Windows, and Linux from one machine |
| Maturity | Very mature (both) | Experimental, canary-only in Deno 2.9 |
None of these are strictly better across the board. Electron's maturity and rendering consistency still matter for a lot of production apps, and Tauri's Rust backend is a real strength if you need systems-level performance. Deno Desktop's pitch is narrower: if your team already lives in TypeScript end to end and does not want to learn Rust or ship an Electron-sized binary, this is now a real third option rather than just a demo.
If your backend already runs on a lightweight TypeScript framework, the patterns in the Hono.js REST API tutorial (routing, middleware, validation) carry over directly, since Hono runs on Deno as well as Cloudflare Workers and Node.js.
Should You Build on This Today?
Yes, for internal tools, prototypes, and side projects, where the experimental label is a reasonable risk to accept. I would hold off putting this under a customer-facing production app until it moves out of canary status in a future Deno release, since the API surface (the exact shape of window.bind(), the desktop namespaces, and the CLI flags) is still explicitly described as stabilizing.
The team building an internal admin panel or a local dev tool has the least to lose from an experimental label: the audience is small, the stakes of a rough edge are low, and the TypeScript-only workflow is a genuine productivity win over learning Rust for a Tauri backend. A team shipping a paid desktop product to thousands of customers has the most to lose from a breaking API change between canary releases, and should watch this space rather than build on it yet.
Frequently Asked Questions
What is deno desktop and how does it work?
deno desktop is a command shipped in Deno 2.9 (canary) that compiles an existing Deno web project, including a plain Deno.serve() script or a supported framework like Next.js or Astro, into a native desktop binary. It starts your server locally, opens a native OS window pointed at it, and runs the UI through a webview while the application logic runs directly in Deno, with no Electron and no Rust required.
How is deno desktop different from Electron?
Electron bundles a full Chromium instance with every app, which guarantees consistent rendering but pushes most binaries past 100MB. deno desktop's default WebView backend uses the operating system's own rendering engine instead, producing a much smaller binary, though rendering can vary slightly by OS. deno desktop also has no main/renderer process split: the UI and backend share one process, so calls between them go through window.bind() instead of Electron's IPC layer.
How is deno desktop different from Tauri?
Tauri also uses the OS-native webview to keep binaries small, but its application backend is written in Rust. deno desktop gives you the same small-binary approach while keeping the backend in TypeScript, which is the main reason to reach for it if your team has already standardized on TypeScript and does not want to introduce Rust just for a desktop build.
Is deno desktop production ready?
No, not yet. It ships as a canary feature in Deno 2.9, requires opting in with deno upgrade canary, and the Deno team explicitly describes the API surface as still stabilizing. It is a reasonable choice for internal tools, prototypes, and side projects today, but worth waiting on before shipping a customer-facing production app on top of it.
How do I call a Deno function directly from the webview without HTTP?
Bind the function in your entrypoint with window.bind("name", fn), then call it from page JavaScript through the bindings namespace as await bindings.name(...). This skips the HTTP round trip entirely since the UI and backend run in the same process.
const window = new Deno.BrowserWindow();
window.bind("ping", () => "pong");const result = await bindings.ping();
console.log(result); // "pong"Should I use WebView or CEF?
Use the default WebView backend for internal tools and side projects where a small binary matters more than pixel-perfect rendering consistency. Switch to --backend cef when you are shipping a customer-facing app and need identical rendering across every user's machine, accepting a larger binary in exchange. If you are unsure, start with WebView since switching later is a one-flag change, not a rewrite.
Does deno desktop work with frameworks like Next.js or Astro?
Yes. Running deno desktop . inside a project directory auto-detects the framework, with support currently covering common frameworks including Next.js, Astro, SvelteKit, and plain Vite projects, in addition to a bare Deno.serve() script. In each case, deno desktop reads the project's existing dev or build configuration rather than requiring a separate desktop-specific config file, so an app that already runs with next dev or astro dev typically needs no code changes to open in a native window.
Can I build the Windows and Linux binaries from a Mac?
Yes. deno desktop --all-targets main.ts cross-compiles output binaries for macOS, Windows, and Linux (five targets in total: Linux x64/arm64, Windows x64, macOS x64/arm64) from a single machine, without needing separate OS-specific build runners the way most Electron and Tauri CI pipelines do.
Related Articles
Bun 1.3's Built-in SQL and Redis Clients: Do You Still Need pg, mysql2, and ioredis?
Bun 1.3 ships built-in Postgres, MySQL, SQLite, and Redis clients. Side-by-side code vs pg, mysql2, and ioredis, plus when migrating actually makes sense.
30 Node.js Interview Questions and Answers (2026)
30 Node.js interview questions with full answers: event loop, streams, clustering, worker threads, memory leaks, and security. Updated for 2026.