I had left my program running overnight. When I returned, it was using over 7 gigabytes of memory. And it was still rising.
This is the story of how I traced a memory leak in my 2D animation editor, Splineworks. A journey that would take me down a path of false leads, false starts, and ultimately across the divide between two programming languages.
The Setup
Splineworks is a 2D animation editor I'm writing using Zig. It uses OpenGL for rendering, GLFW for windowing, and a thin Objective-C layer for native macOS menu integration. The code base is 50,000 lines, and memory had never been a problem until then.
Then I left it open for 24 hours, no action, just sitting there. And it used 7GB RAM.
The Obvious Suspects
My first instinct was the render loop. Even at 60 frames per second, a small leak per frame would add up quickly. I dove into shape_renderer.zig, which handles tessellation and drawing all shapes on the screen.
It was a goldmine of bugs — 15 places where ArrayLists were allocated for drawEllipseShape(), drawPolygonShape(), drawPathShape(), etc., and never explicitly freed.
I went through all 15 places and added defer .deinit() calls. It compiled just fine. I felt good. At 60 frames per second, with just a few shapes drawn on the screen, we'd leak megabytes per minute — no problem reaching 7GB in 24 hours.
I rebuilt the application and ran it.
Still Leaking
My memory profile is still growing. I'm not doing anything but opened up a project file. It's already at 1.41 GB and still climbing.
That sinking feeling. Back to the code.
And then I saw it — line 340 of shape_renderer.zig:
var arena = std.heap.ArenaAllocator.init(self.allocator);
defer arena.deinit();Every call to drawShape() was already inside an arena allocator. When the function returned, arena.deinit() bulk freed everything. My 15 "fixes" were completely unnecessary. The arena had already taken care of everything.
The leak was elsewhere.
The Systematic Search
I went through all of the systems that interacted with the frame loop:
- Stage renderer: One allocation, and only on user request.
- Editor UI: No per-frame allocations.
- Breadcrumbs: Fixed-size ring buffer.
- Onion skins: Stack-based buffers.
- Font/text rendering: Only allocating for new characters.
- Autosave: Early returns if nothing to export.
- Deformer pipeline: Frees old allocation before allocating a new one.
I was beginning to wonder if I was losing my mind. Was the GPA just not returning pages to the OS? No, 7GB was way beyond what would be a caching effect. Something was allocating and not freeing.
The Diagnostic That Changed Everything
I wanted hard data. I added a simple memory tracker to the main loop:
if (frame_count % 300 == 0) {
const bytes = gpa.total_requested_bytes;
std.log.warn("MEMTRACK frame={d} gpa_requested={d}MB", .{
frame_count, bytes / (1024 * 1024),
});
}It would log the total bytes requested from Zig's general-purpose allocator every 300 frames, approximately every 5 seconds. I rebuilt the executable, opened a new project, and watched the output scroll by:
MEMTRACK frame=300 gpa_requested=20MB
MEMTRACK frame=600 gpa_requested=20MB
MEMTRACK frame=900 gpa_requested=20MB
MEMTRACK frame=1200 gpa_requested=20MB
MEMTRACK frame=1500 gpa_requested=20MB
...
Twenty megabytes. Every single log statement. Rock steady.
The Zig heap is flat. The leak is not happening in Zig.
Crossing the Language Boundary
But if it's not in Zig, and it's not in OpenGL (which I verified: all textures, framebuffers, and vertex buffers are created once during initialization and reused), then where?
And then it dawned on me: Objective-C.
Splineworks has a small Objective-C file, macos_menu.m, which acts as a bridge between Zig and native macOS menu bar integration. It creates NSMenuItem objects, configures them to be enabled/checked or not, and connects them to the editor's action system.
Objective-C's memory management system on macOS uses autorelease pools. When Objective-C code creates certain objects (like [NSString stringWithUTF8String:]), it adds them to the current autorelease pool. When that pool is drained, all objects in it are released. However, if no autorelease pool exists or it's never drained, all objects created in that way leak forever.
GLFW has its own autorelease pool in glfwPollEvents(). However, my menu update code runs after that function returns — outside any autorelease pool.
The Smoking Gun
Here's what was going on every 10 frames:
The editor is calling macos_menu_update_enabled() and macos_menu_update_state() for every action. There are 143 menu action IDs. Each call to the underlying forItemWithActionId function:
- Creates an
NSStringfrom the action ID using[NSString stringWithUTF8String:] - Creates several
NSArrayobjects from[mainMenu itemArray]and[submenu itemArray] - And several other autoreleased objects
That's 143 × 2 = 286 Objective-C calls every 10 frames. That's roughly 1,716 autoreleased objects per second at 60 fps — and not a single one is ever being released.
The back-of-the-napkin math:
~1,700 calls/second × ~100–200 bytes/call = 1–2 MB/minute
For 24 hours, that lands somewhere in the range of 2–3 GB. And that's before accounting for intermediate NSArray objects — which makes 7GB entirely plausible.
The Fix
Two lines of code:
void macos_menu_update_enabled(const char* action_id, bool enabled) {
@autoreleasepool {
// ... existing code ...
}
}
void macos_menu_update_state(const char* action_id, bool checked) {
@autoreleasepool {
// ... existing code ...
}
}By wrapping the functions in @autoreleasepool, every object created inside the block is released when the block exits.
I rebuilt. I opened the same project file. I monitored Activity Monitor.
760 MB. 762 MB. 761 MB. 764 MB. 760 MB.
Stable.
Lessons Learned
1. Measure before you fix. My initial instinct of searching for unfreed allocations in the render loop turned up 15 "bugs" that were actually correct. Had I started with the MEMTRACK diagnostic, I would have immediately known the Zig heap wasn't the problem.
2. Language boundaries are leak boundaries. When working with multiple languages, each has its own memory management paradigm. Zig's heap was functioning perfectly — but it had zero visibility into what Objective-C was doing on its behalf.
3. The idle case matters. This leak was never obvious during normal use. It only surfaced after 24 hours of idling, because menu state was being updated continuously regardless of whether anything had changed.
4. Autorelease pools are not optional. When calling into Objective-C from another language on macOS, any function that creates autoreleased objects needs its own autorelease pool.
5. Arena allocators are your friend. The fact that shape_renderer.zig was already using an arena allocator meant dozens of intermediate allocations were handled correctly — but it also meant I wasted time fixing code that was never broken.
The Bonus Bug
While investigating the leak, I tried loading a test file and got a JSON parse error. It turned out the JSON serializer had a separate bug: writeKey("stroke_color", true) should have been writeKey("stroke_color", false). The true parameter indicates "this is the first field in the object" and omits the preceding comma — but stroke_color follows fill_color, so a comma is required. This single-character error caused 31 missing commas across the file. Unrelated to the memory leak, but a welcome find.
Final Tally
| Metric | Value |
|---|---|
| Memory after 24 hours idle | 7 GB+ |
| Memory after fix | 760 MB (stable) |
| Zig GPA heap (unchanged throughout) | 20 MB |
| False-positive "leaks" investigated | 15 |
| Autoreleased ObjC objects per second | ~1,716 |
| Lines of code to fix | 2 |
Sometimes the hardest bugs aren't in the code you wrote. They're in the space between the code you wrote and the code you called.