I've been building Natyv, 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.
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.
Disk size and idle memory came back exactly how I'd hoped:
Natyv: 27MB disk, 58MB idle RAM
Electron: 244MB disk, 331MB idle RAM
Tauri: 11MB disk, 69MB idle RAM
Idle CPU did not.
The number I didn't want to see
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.
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.
Root cause: a main loop with no brakes
The real cause was almost embarrassingly simple once I found it. The event loop looked roughly like this:
while (running) {
while (SDL_PollEvent(&event)) {
handleEvent(event);
}
drawWindow();
}
SDL_PollEvent 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 drawWindow() did a full unconditional clear and redraw every single iteration regardless.
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.
The fix, in two real stages
Stage 1 replaced the busy poll with SDL_WaitEventTimeout(&event, 16), waking at most 60 times a second (matching a 60Hz vsync interval) instead of continuously, plus SDL_SetRenderVSync 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.
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.
That combination got idle CPU down to roughly 8 to 16%. Still not 0%, but the trajectory was right.
The part that actually made it fun to debug: what an unthrottled loop had been quietly hiding
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.
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.
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.
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.
Getting the rest of the way to 0.0%
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.
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.
Final, measured result: 0.0% idle CPU, confirmed via ps, matching Electron and Tauri exactly, with idle RSS unaffected by any of it.
Why I'm writing this up instead of just fixing it quietly
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.
Natyv is v0.1.0 and early. Go is the only guest SDK today, published on pkg.go.dev: SDK, and you can find the org's repo here.
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.
Top comments (0)