← BlogEngineering

Our dictation app grew to 35 GB of RAM. The first fix was wrong.

GHZ Voice is a small Mac app. It sits in the menu bar, records while you hold a hotkey, and pastes cleaned-up text where your cursor is. In June 2026 it started using 28 GB of RAM after a few days in the tray. A week later, after we had shipped a fix, it reached 35 GB and froze the Mac.

This is the story of the fix that did not work, the one that did, and the method we now use for every memory problem.

The first diagnosis looked right

The obvious suspects were easy to find. The dashboard checked the microphone and accessibility permissions every four seconds, forever, even with the window hidden. Those checks called Objective-C APIs from Tauri worker threads that had no autorelease pool, so the objects they created were never freed.

We stopped the polling when the window is hidden, wrapped the calls in autoreleasepool, capped the debug log at 1 MB, and shipped version 0.1.15. It felt done. We had not measured anything.

Measuring instead of guessing

When the app climbed to 35 GB anyway, we stopped reading code and started reading the heap. macOS ships the tools for this:

Profiling a running Mac app
MallocStackLogging=1 "/Applications/GHZ Voice.app/Contents/MacOS/ghz-voice-app-prod"
vmmap -summary <pid>
malloc_history <pid> -allBySize
leaks <pid>

leaks found almost nothing: 4 KB of strings. That was the first useful clue. The memory was not leaked in the classic sense. It was still reachable, held by something alive.

vmmap showed 960 MB in large allocations, 77 blocks. malloc_history gave us the call stack behind them:

CoreAudio HALC_IOThread::IOWorkLoop
  → [GHZ Voice audio callback]
    → realloc (Vec::extend)

The audio thread was still appending samples to buffers of 100 to 134 MB. That is more than 500 seconds of audio, past the 300-second cap the app enforces on a recording.

The real cause

GHZ Voice uses the cpal crate to read the microphone. To stop a recording, the code dropped the stream. On macOS 26.2, dropping a cpal::Stream does not always stop the CoreAudio callback. Streams from old dictations kept running on the audio thread and kept growing their buffers.

One detail made it worse. In automatic mode the app opens several microphones at once and keeps the one that hears you best. The machine we test on has four inputs (an audio interface, the Studio Display, a webcam and AirPods Max), so every dictation left up to three orphaned streams behind.

The fix

  • Each stream shares an active flag with its callback. The callback returns immediately once the flag is false.
  • Stopping a recording sets the flag, pauses the stream, then drops it, before reading the samples.
  • A Drop implementation sets the flag on any exit path, including a panic.
  • A hard cap of about 60 MB per capture buffer, so even an unknown bug cannot grow without limit.

This time we measured before shipping 0.1.16:

960 MB → 0
large allocations alive after 5 dictations
1.14 GB → 25 MB
total malloc
+21 MB
resident memory, bounded

The same week we wired automatic updates into the app, so the next fix would not need every user to reinstall by hand. That is its own story: shipping updates to a Mac app without the App Store.

Keep reading

More build logs.

All posts →
  1. Infrastructure2 November 2026Three deploy mistakes that broke our production, and the rule each one left5 min
  2. Infrastructure2 November 2026One server for every backend: how we run GHZ Creative’s products5 min
  3. Infrastructure2 November 2026Dictation went from 1 second to 26. The cause was a network route.4 min