<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Quinn Millican</title>
    <description>The latest articles on DEV Community by Quinn Millican (@syke99).</description>
    <link>https://dev.to/syke99</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4126681%2F5a41fd50-d260-41c4-aa64-3a6f9d75dca7.png</url>
      <title>DEV Community: Quinn Millican</title>
      <link>https://dev.to/syke99</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/syke99"/>
    <language>en</language>
    <item>
      <title>The UI frameworks local AI desktop apps actually ship</title>
      <dc:creator>Quinn Millican</dc:creator>
      <pubDate>Mon, 21 Sep 2026 14:29:40 +0000</pubDate>
      <link>https://dev.to/syke99/the-ui-frameworks-local-ai-desktop-apps-actually-ship-3pni</link>
      <guid>https://dev.to/syke99/the-ui-frameworks-local-ai-desktop-apps-actually-ship-3pni</guid>
      <description>&lt;p&gt;Every local AI tool with a GUI has to answer the same question: what do you wrap the UI in? The answers are not what I expected, and you do not have to take anyone's word for them. The answer is sitting in the installer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The method
&lt;/h2&gt;

&lt;p&gt;You can identify a desktop app's UI framework from its Linux package without installing it, and usually without downloading the whole thing.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;.deb&lt;/code&gt; is an &lt;code&gt;ar&lt;/code&gt; archive with three members in a fixed order: &lt;code&gt;debian-binary&lt;/code&gt;, &lt;code&gt;control.tar.*&lt;/code&gt;, then &lt;code&gt;data.tar.*&lt;/code&gt;. The control archive is small and sits near the front, so an HTTP range request for the first megabyte is normally enough to get all of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sL&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; 0-1048575 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; head.bin
ar t head.bin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside &lt;code&gt;control.tar.xz&lt;/code&gt; are two files that answer the question outright:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;control&lt;/code&gt;, whose &lt;code&gt;Depends:&lt;/code&gt; line names the runtime libraries&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;md5sums&lt;/code&gt;, which lists every file in the package&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An Electron app built with electron-builder declares a distinctive dependency set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;Depends&lt;/span&gt;: &lt;span class="n"&gt;libgtk&lt;/span&gt;-&lt;span class="m"&gt;3&lt;/span&gt;-&lt;span class="m"&gt;0&lt;/span&gt;, &lt;span class="n"&gt;libnotify4&lt;/span&gt;, &lt;span class="n"&gt;libnss3&lt;/span&gt;, &lt;span class="n"&gt;libxtst6&lt;/span&gt;, &lt;span class="n"&gt;xdg&lt;/span&gt;-&lt;span class="n"&gt;utils&lt;/span&gt;, &lt;span class="n"&gt;libatspi2&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;-&lt;span class="m"&gt;0&lt;/span&gt;, &lt;span class="n"&gt;libuuid1&lt;/span&gt;, &lt;span class="n"&gt;libsecret&lt;/span&gt;-&lt;span class="m"&gt;1&lt;/span&gt;-&lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;Recommends&lt;/span&gt;: &lt;span class="n"&gt;libappindicator3&lt;/span&gt;-&lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;libnss3&lt;/code&gt; is Chromium's Network Security Services. A Tauri app declares &lt;code&gt;libwebkit2gtk-4.1-0&lt;/code&gt; instead, which is a different and famously painful dependency. The file list confirms it either way: Electron ships &lt;code&gt;LICENSE.electron.txt&lt;/code&gt;, &lt;code&gt;resources/app.asar&lt;/code&gt;, &lt;code&gt;chrome-sandbox&lt;/code&gt;, &lt;code&gt;icudtl.dat&lt;/code&gt;, &lt;code&gt;v8_context_snapshot.bin&lt;/code&gt; and &lt;code&gt;libffmpeg.so&lt;/code&gt;. Tauri ships none of those.&lt;/p&gt;

&lt;p&gt;For open source projects you can skip all of this and just look: &lt;code&gt;src-tauri/&lt;/code&gt; in the repo root means Tauri, an &lt;code&gt;app.asar&lt;/code&gt; in the packaged output means Electron.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;.rpm&lt;/code&gt; is even better if you want sizes, because its header carries per-file sizes and also sits at the front of the file. That is where the size split below comes from.&lt;/p&gt;

&lt;h2&gt;
  
  
  The results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;UI shell&lt;/th&gt;
&lt;th&gt;How it was confirmed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LM Studio 0.4.25&lt;/td&gt;
&lt;td&gt;Electron&lt;/td&gt;
&lt;td&gt;Package metadata&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker Desktop&lt;/td&gt;
&lt;td&gt;Electron&lt;/td&gt;
&lt;td&gt;Vendor roadmap issue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cosmonic Desktop 0.5.29&lt;/td&gt;
&lt;td&gt;Electron&lt;/td&gt;
&lt;td&gt;Package metadata&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jan&lt;/td&gt;
&lt;td&gt;Tauri, migrated off Electron&lt;/td&gt;
&lt;td&gt;Repository layout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ollama&lt;/td&gt;
&lt;td&gt;Native Go plus the system webview&lt;/td&gt;
&lt;td&gt;Repository layout&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  LM Studio
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;.deb&lt;/code&gt; is 732 MB and the AppImage is 965 MB. &lt;code&gt;Installed-Size: 2275411&lt;/code&gt; KB works out to about 2.17 GiB on disk. The control file carries the electron-builder dependency set verbatim, and the package contains &lt;code&gt;LICENSE.electron.txt&lt;/code&gt;, &lt;code&gt;chrome-sandbox&lt;/code&gt;, &lt;code&gt;icudtl.dat&lt;/code&gt;, &lt;code&gt;v8_context_snapshot.bin&lt;/code&gt; and &lt;code&gt;libffmpeg.so&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Before anyone reads 2.17 GiB as a Chromium number: it is not. The package also contains 51 llama.cpp backend files, one per CPU variant. Most of that size is inference engines, not UI.&lt;/p&gt;

&lt;h3&gt;
  
  
  Docker Desktop
&lt;/h3&gt;

&lt;p&gt;Not a local AI tool, but it is the reference case for how this happens. Docker tracked the move publicly in &lt;code&gt;docker/roadmap#31&lt;/code&gt;, opened in March 2020 and marked "Shipped!". The stated reason is the honest one that applies to every app on this list:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Reduces engineering cost of maintaining 2 UIs and reduces ongoing cost to add/change features as we&lt;br&gt;
have a single cost base for UI&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Cosmonic Desktop
&lt;/h3&gt;

&lt;p&gt;Cosmonic builds WebAssembly infrastructure. They created wasmCloud and donated it to the CNCF. Cosmonic Desktop is their local sandbox for running AI-generated code and MCP servers as capability-scoped WebAssembly components.&lt;/p&gt;

&lt;p&gt;It is Electron as well. The &lt;code&gt;.deb&lt;/code&gt; carries the same dependency fingerprint, plus &lt;code&gt;LICENSE.electron.txt&lt;/code&gt;, &lt;code&gt;resources/app.asar&lt;/code&gt;, &lt;code&gt;chrome-sandbox&lt;/code&gt; and &lt;code&gt;v8_context_snapshot.bin&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Installed-Size&lt;/code&gt; comes to about 637 MiB, of which roughly 265 MiB is the Electron and Chromium runtime. The rest is their own application code, a wasmtime daemon, and a bundled compiler toolchain so a coding agent can build components locally. As with LM Studio, the headline number is not a measurement of UI overhead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Jan
&lt;/h3&gt;

&lt;p&gt;Jan is the interesting counterexample. &lt;code&gt;janhq/jan&lt;/code&gt; has a &lt;code&gt;src-tauri/&lt;/code&gt; directory and no &lt;code&gt;electron/&lt;/code&gt; directory. They moved. Their own stated reasons were that the Electron app size had become "really big", and that bundling Chromium and Node was not viable for scaling to mobile.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ollama
&lt;/h3&gt;

&lt;p&gt;Ollama is neither. There is no &lt;code&gt;package.json&lt;/code&gt; in the desktop app, no Electron, and no Tauri. The app is a Go program (&lt;code&gt;go run ./cmd/app&lt;/code&gt;) that binds the system webview directly through a vendored copy of &lt;code&gt;webview_go&lt;/code&gt;, with a native Windows tray in &lt;code&gt;app/wintray/&lt;/code&gt;, native macOS code in &lt;code&gt;app/darwin/&lt;/code&gt;, and an Inno Setup installer. Four files under &lt;code&gt;app/webview/&lt;/code&gt; do the work: &lt;code&gt;webview.go&lt;/code&gt;, &lt;code&gt;webview.cc&lt;/code&gt;, &lt;code&gt;glue.c&lt;/code&gt; and Microsoft's &lt;code&gt;WebView2.h&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;They built their own shell rather than take a framework dependency.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern is not "everyone ships Electron"
&lt;/h2&gt;

&lt;p&gt;That was my assumption going in and it is wrong. The pattern is that this category is actively leaving Electron, and there are already two established exits:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Tauri&lt;/strong&gt;, which swaps bundled Chromium for the system webview. Jan took this route.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hand-rolling&lt;/strong&gt;, which is the same trade with no framework in between. Ollama took this route.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both exits still land on a system webview, and that has a cost people underrate. Tauri runs three different engines depending on platform: WebView2 on Windows, WKWebView on macOS, WebKitGTK on Linux. Different rendering behavior, different CSS support, different JavaScript engine versions.&lt;/p&gt;

&lt;p&gt;The clearest evidence of what that costs is Ollama itself. Its desktop app README is titled "Ollama for macOS and Windows" and offers only a &lt;code&gt;.dmg&lt;/code&gt; and an &lt;code&gt;.exe&lt;/code&gt;. There is no Linux GUI. WebKitGTK is the engine everybody skips.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveats
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Installed sizes include everything in the package. For LM Studio that means inference engines, and for Cosmonic Desktop that means a wasmtime runtime and a compiler toolchain. Neither number is a measure of UI overhead, and I have tried to separate them above rather than quote the headline.&lt;/li&gt;
&lt;li&gt;Docker Desktop was confirmed from a vendor roadmap issue rather than package metadata.&lt;/li&gt;
&lt;li&gt;I checked what these tools ship today. Any of them may change.&lt;/li&gt;
&lt;li&gt;Two AI-generated sources were wrong when I checked them against the actual repositories: one claimed Ollama was Electron, and one gave a specific date for Jan's Tauri migration that the repository does not support. Both are cited confidently in places. Check the package.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>electron</category>
      <category>ui</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I built a leaner alternative to Electron. Then I found it was burning 90% CPU at idle.</title>
      <dc:creator>Quinn Millican</dc:creator>
      <pubDate>Tue, 15 Sep 2026 17:20:08 +0000</pubDate>
      <link>https://dev.to/syke99/i-built-a-leaner-alternative-to-electron-then-i-found-it-was-burning-90-cpu-at-idle-mfm</link>
      <guid>https://dev.to/syke99/i-built-a-leaner-alternative-to-electron-then-i-found-it-was-burning-90-cpu-at-idle-mfm</guid>
      <description>&lt;p&gt;I've been building &lt;a href="https://github.com/natyv-io" rel="noopener noreferrer"&gt;Natyv&lt;/a&gt;, a native desktop app runtime with no bundled browser engine. Your app logic runs as a WebAssembly guest module (via Extism) inside a native host; the host owns the window and render loop, your guest code declares UI and handles events through a small set of capability-scoped host functions. No Chromium, no DOM, no JS runtime unless your app logic happens to be written in JS.&lt;/p&gt;

&lt;p&gt;The whole point was to be leaner than Electron. So before claiming that, I decided to actually prove it: I built the same real app three ways, Natyv, Electron, and Tauri, a working IMAP/SMTP mail client with send, read, delete, and paginate, and benchmarked all three back to back, same machine, same workload.&lt;/p&gt;

&lt;p&gt;Disk size and idle memory came back exactly how I'd hoped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Natyv:    27MB disk, 58MB idle RAM
Electron: 244MB disk, 331MB idle RAM
Tauri:    11MB disk, 69MB idle RAM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Idle CPU did not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The number I didn't want to see&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Electron and Tauri both settled to 0% CPU at idle, completely unsurprising, both have mature, heavily optimized event loops. Natyv was sustaining 70 to 93% CPU doing absolutely nothing.&lt;/p&gt;

&lt;p&gt;That's not a rounding error. That's a fundamental problem, and it's the kind of thing you only find by actually comparing against real alternatives instead of profiling your own thing in isolation and calling it good.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause: a main loop with no brakes&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The real cause was almost embarrassingly simple once I found it. The event loop looked roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;while (running) {
    while (SDL_PollEvent(&amp;amp;event)) {
        handleEvent(event);
    }
    drawWindow();
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SDL_PollEvent&lt;/code&gt; is non-blocking. With nothing to throttle it, that loop ran as fast as the CPU would let it, thousands of times a second, forever, whether or not anything on screen had actually changed. And &lt;code&gt;drawWindow()&lt;/code&gt; did a full unconditional clear and redraw every single iteration regardless.&lt;/p&gt;

&lt;p&gt;Two real problems stacked on top of each other: the loop itself never slept, and the draw call never checked whether it needed to run at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix, in two real stages&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Stage 1 replaced the busy poll with &lt;code&gt;SDL_WaitEventTimeout(&amp;amp;event, 16)&lt;/code&gt;, waking at most 60 times a second (matching a 60Hz vsync interval) instead of continuously, plus &lt;code&gt;SDL_SetRenderVSync&lt;/code&gt; on the renderer. Tested in isolation first: idle CPU dropped from 70 to 93% down to about 22 to 24%, with zero behavior change since drawing was still unconditional every wake.&lt;/p&gt;

&lt;p&gt;Stage 2 added a real draw-level dirty check to the draw pass, skipping the redraw entirely unless something had actually changed since the last frame. The tricky part: "something changed" isn't just one flag. A Button's click-flash and a Spinner's animation are both driven by wall-clock time, not by any state change that bumps a generation counter, so the gate had to explicitly account for those as standing exceptions rather than relying on one dirty bit. Alongside that, a worker thread doing a widget mutation now pushes a real SDL event to wake the main thread immediately, instead of waiting out the timeout.&lt;/p&gt;

&lt;p&gt;That combination got idle CPU down to roughly 8 to 16%. Still not 0%, but the trajectory was right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The part that actually made it fun to debug: what an unthrottled loop had been quietly hiding&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Once the loop stopped iterating continuously, a handful of real, pre-existing bugs became visible for the first time, simply because they'd always been there, just invisible when the next iteration arrived microseconds later.&lt;/p&gt;

&lt;p&gt;A freshly created window's first present call isn't reliably guaranteed to actually display by the OS compositor, a real, known graphics-programming gotcha that a busy-spinning loop papers over automatically (the next redraw a fraction of a second later just fixes it). Once the loop only redrew on demand, that first frame sometimes genuinely never showed. Fixed with a short forced-redraw window on every newly created window.&lt;/p&gt;

&lt;p&gt;More interesting: several pieces of per-frame state, a widget snapshot used for hit-testing and drawing, a layout pass, text sync, were all computed exactly once per loop iteration, before that same iteration's own input events were processed. That was always true. It was invisible under the old loop because the next iteration, arriving instantly, would already reflect whatever the previous iteration's events had just changed. Once the loop only woke on a real event, "the next iteration" became "the next keystroke," and it showed up as real, reproducible bugs: text input lagging by exactly one character, a refresh action not rendering every row until some unrelated later click, a freshly created widget not appearing on screen until something else happened to trigger another layout pass.&lt;/p&gt;

&lt;p&gt;None of these were regressions from the throttling fix itself. They were real, pre-existing architectural gaps that an unthrottled loop had been silently hiding through brute force. That's a genuinely useful, general lesson: performance work that reduces how often a loop iterates is also a real regression-risk surface for exactly this class of bug, and it deserves live interactive testing (typing, clicking, scrolling), not just an idle CPU measurement and a passing test suite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Getting the rest of the way to 0.0%&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The last stretch was chasing down every remaining case where something kept the loop waking more often than it needed to: a text-sync and layout-rebuild pass that was running unconditionally on every wake instead of only when a real recompute had happened, and a fixed 16ms timeout that kept the loop polling roughly 60 times a second even at genuine idle, just to recheck that nothing had changed.&lt;/p&gt;

&lt;p&gt;Fixed by threading a real "did this actually recompute" signal through instead of approximating it from a generation counter, and by switching to a blocking wait (genuine 0% CPU) whenever nothing needs frequent waking, with a real audit of every wall-clock-driven UI state (a fading toast, a hover-delayed tooltip) to make sure nothing silently needed a wake it wasn't going to get.&lt;/p&gt;

&lt;p&gt;Final, measured result: 0.0% idle CPU, confirmed via &lt;code&gt;ps&lt;/code&gt;, matching Electron and Tauri exactly, with idle RSS unaffected by any of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I'm writing this up instead of just fixing it quietly&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It would've been easy to just ship the final numbers and let the benchmark table speak for itself. But the actual value here, to me, isn't "look how good our numbers are." It's that benchmarking honestly against real competitors, instead of only profiling in isolation, is what surfaced a genuine, serious bug that would have otherwise shipped. I'd rather show that process than hide it.&lt;/p&gt;

&lt;p&gt;Natyv is v0.1.0 and early. Go is the only guest SDK today, published on pkg.go.dev: &lt;a href="https://pkg.go.dev/github.com/natyv-io/sdks/go" rel="noopener noreferrer"&gt;SDK&lt;/a&gt;, and you can find the org's repo &lt;a&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One more honest note: I used Claude Code for a lot of the actual implementation and for the live debugging instrumentation described above, but the architecture, the fix strategy, and every real design decision along the way are mine. Wanted to say that plainly rather than have it come up later.&lt;/p&gt;

</description>
      <category>go</category>
      <category>webassembly</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
