DEV Community

Cover image for Why cross-platform desktop apps balloon to 200MB and how to slim them down
Alan West
Alan West

Posted on

Why cross-platform desktop apps balloon to 200MB and how to slim them down

The 200MB "Hello World"

I shipped my first cross-platform desktop app back in 2018. Markdown editor. Three buttons, a text area, syntax highlighting. The final installer was 187MB.

Every time I open Activity Monitor during dev work, I see a handful of Electron-based apps each parked at 300MB. That's well over a gigabyte of RAM for tools I'm not even actively using. A chat client, a code editor, a git GUI, a note-taker. The math finally caught up with me last month, and I started digging into what it would actually take to ship a desktop app that doesn't melt your laptop.

This post is about the root cause of that bloat and the path I've been walking to fix it. There's no magic, just a different architectural choice that systems languages like Zig and Rust have made cheap to take.

Root cause: every app ships its own browser

The popular bundler-based desktop frameworks all follow the same recipe: ship Chromium plus a Node.js runtime alongside your app, then load your HTML/CSS/JS inside it.

The catch is that "bundle Chromium" means a full Chromium. Not a stripped rendering engine. The whole browser, including:

  • V8 JavaScript engine
  • Blink rendering engine
  • A separate Node.js runtime alongside the renderer
  • Media codecs, sandboxing layers, GPU process, the lot

Five of those apps running = five copies of Chromium in memory. None of them share processes. None of them benefit when your OS gets a faster, more secure browser shipped by the vendor.

And it's not just RAM. Disk space, code-signing time, auto-update bandwidth, startup latency — they all scale with the runtime. You're paying for a browser you don't use.

The native webview approach

Every modern OS already exposes a system webview component:

  • Windows: WebView2, built on Edge (docs)
  • macOS / iOS: WKWebView, built on WebKit (docs)
  • Linux: WebKitGTK (docs)
  • Android: the platform WebView

These components are already loaded for other apps. Use them and your app doesn't ship a browser at all — it borrows one.

The trade-off: there's no unified API across platforms. You need a thin native shell that creates a window, embeds the OS webview into it, wires up message passing between native and JS, and exposes whatever OS APIs your app needs.

This is the part where systems languages with clean C interop become useful. The shell stays tiny because you're not implementing a browser — you're calling four or five C functions per platform. Zig fits this particularly well because its @cImport lets you pull in system headers directly without a binding-generation step.

Step 1: spawn a window with a webview

Here's the rough shape of the macOS shell in Zig. I'm using @cImport to grab the Objective-C runtime and message it directly. WebKit's classes are reachable the same way:

const std = @import("std");
const objc = @cImport({
    @cInclude("objc/objc.h");
    @cInclude("objc/message.h");
});

// Helper that wraps objc_msgSend with the right calling convention.
// objc_msgSend is variadic in C but we need typed Zig wrappers per signature.
fn cls(name: [:0]const u8) ?*anyopaque {
    return objc.objc_getClass(name.ptr);
}

fn sel(name: [:0]const u8) objc.SEL {
    return objc.sel_registerName(name.ptr);
}

pub fn run(url: [:0]const u8) !void {
    const NSApplication = cls("NSApplication").?;
    const app = msgSend(NSApplication, sel("sharedApplication"));

    // ... allocate NSWindow, attach WKWebView, navigate to url ...

    _ = msgSend(app, sel("run")); // Blocking event loop
}
Enter fullscreen mode Exit fullscreen mode

I left the WKWebView setup out because the post would balloon, but the pattern is mechanical. The full shell for one platform fits in a few hundred lines.

Step 2: IPC between native and JS

The webview exposes a message channel in both directions. On WKWebView, JS calls window.webkit.messageHandlers.<name>.postMessage(...) and your native code receives a callback. On WebView2 it's window.chrome.webview.postMessage(...). The shell normalizes these so the page sees one API.

Here's a small request/response wrapper I use on the JS side. Each call gets a UUID so replies can be routed back to the right promise:

// Injected once at page load by the native shell
window.__pending = new Map();

function callNative(method, params) {
  const id = crypto.randomUUID();
  return new Promise((resolve, reject) => {
    window.__pending.set(id, { resolve, reject });
    // bridge is the platform-specific postMessage hook
    window.bridge.postMessage(JSON.stringify({ id, method, params }));
  });
}

// Native side calls this back with { id, ok, value } once the work is done
window.__resolveNative = ({ id, ok, value }) => {
  const pending = window.__pending.get(id);
  if (!pending) return;
  window.__pending.delete(id);
  ok ? pending.resolve(value) : pending.reject(new Error(value));
};
Enter fullscreen mode Exit fullscreen mode

Step 3: put types on top

Raw string messages get painful fast. A small typed RPC layer on the TS side keeps things sane:

// Single source of truth for the bridge surface
interface NativeAPI {
  'fs.readDir':  (p: { path: string }) => Promise<string[]>;
  'fs.readFile': (p: { path: string }) => Promise<string>;
  'window.minimize': () => Promise<void>;
}

// Generic helper that preserves param + return types per method name
function rpc<K extends keyof NativeAPI>(
  method: K,
  params: Parameters<NativeAPI[K]>[0],
): ReturnType<NativeAPI[K]> {
  return callNative(method, params) as ReturnType<NativeAPI[K]>;
}

const files = await rpc('fs.readDir', { path: '~/Documents' });
Enter fullscreen mode Exit fullscreen mode

Mirror that interface in the native dispatcher and the compiler catches typos on both sides.

What you actually save

In a side-by-side I ran with a small clipboard manager last month:

  • Chromium-bundled version: ~142MB installed, ~180MB RAM at idle
  • Native-webview version: ~6MB installed, ~35MB RAM at idle

The 35MB is mostly the webview process the OS is already running. Subsequent webview-based apps add roughly 10–15MB each because they share the system component.

Prevention tips

If you're starting a new desktop or hybrid mobile app, here's what I'd weigh:

  • Audit what you actually need from the renderer. DRM, specific codecs, Chromium-only DevTools features — those will push you back to the bundled approach. Otherwise you're paying for capabilities you don't ship.
  • Treat the native shell as a port boundary. Keep it tiny. All business logic stays in JS or in clearly-scoped native modules. The shell should do windows, IPC, and OS APIs — nothing else.
  • Read existing open-source shells before you write your own. Tauri (Rust) and Wails (Go) both expose their shells as readable references. The patterns transfer to a Zig shell cleanly even if you don't use those projects directly.
  • Test on the slowest target machine you can find. WebKitGTK on a budget Linux box behaves nothing like WKWebView on Apple silicon. The differences are mostly in CSS edge cases and JS engine quirks, and you want to know about them before users do.
  • Don't pretend it's a free lunch. You'll write more native code than you would with the bundled-runtime approach. You'll juggle three slightly different webview APIs. The win is real, but it has a cost.

For most apps — most of them, honestly — the trade is worth it. Users get faster startup. Laptops stay cooler. Installers stop being half a gigabyte. The shell stays a few hundred lines per platform, which is something one person can actually own.

Top comments (0)