Engineering

Chasing five memory leaks in a week

We put a number on the landing page: 14 MB idle. It's illustrative, but it's also a promise, and promises like that have a way of getting tested. Ours got tested when a routine profiling pass showed idle RSS creeping up the longer you left the Capture screen open — not a lot per minute, but a native app has no excuse for a heap that only grows.

Finding the shape of the problem

The first leak was the easiest to see and the hardest to place: every repaint of the Capture screen was allocating scratch strings for row layout — URL truncation, status-code formatting, timing labels — and never freeing them, because nothing in the frame loop owned that cleanup. At 60 frames a second, "small allocation nobody frees" turns into a real number by lunchtime.

The fix was a UI-thread scratch arena: a bump allocator reset once at the top of every frame, so anything allocated during layout for that frame gets freed in one shot instead of needing an owner. It's the kind of fix that sounds obvious in retrospect and took a profiler and a `wip.md` full of dead ends to actually land on.

The rest of the trail

Once the arena was in place, three more leaks showed up in its shadow — places that were still allocating outside the arena, or duplicating a URL-display string per row on every filter keystroke instead of once per change. One was quadratic: the capture filter was re-scanning and re-allocating the full session list on every character typed into the filter box, which is invisible at ten sessions and very visible at a thousand.

The last one was in the request builder's Logs tab — the reconstructed wire view was building a fresh Builder() string per card on every repaint instead of caching it, so scrolling through a long capture history left a trail of abandoned buffers behind it.

Five commits, one week, and the common thread in all of them was the same question: who owns this allocation, and when does it die?

What we changed going forward

The scratch arena is now the default answer for "I need a temporary string during a repaint." If it doesn't need to outlive the frame, it goes in the arena and gets reset for free next frame — no manual bookkeeping, no path where an early return skips a free. It's a small pattern, but it's the same one that keeps Nop's memory graph flat instead of sawtooth, which is the whole point of building this thing native in the first place.