36% Memory Reduction: Phase 4 Task 1 Results
We replaced JSON.stringify()
with stable array references in the level editor’s ghost highlight system. The result: 36% reduction in peak memory usage (24.7 MB saved) with smoother and more stable garbage collection patterns.
Part of the Phase 4 Frontend & Backend Optimization series. Read the Task 1 Baseline Analysis first.
The Fix: useStableArray Hook
The problem was simple: JSON.stringify()
creates a new string object on every mouse move, breaking React’s memoization. The solution was equally simple: cache array references and only update when contents actually change.
// client/src/hooks/useStableArray.ts
function useStableArray<T>(arr: T[] | null | undefined): T[] {
const ref = useRef<T[]>(arr || []);
// Only update ref if array contents changed
if (!arraysEqual(ref.current, arr || [])) {
ref.current = arr || [];
}
return ref.current;
}
Applied to 3 locations:
- GameplayLevelCanvas.tsx:308-312
- TestLevelCanvas.tsx:290-294
- useLivePreviewBuildableHighlights.ts:32-42
Before:
useMemo(() => {
// expensive calculation
}, [JSON.stringify(ghostCellPositions || [])])
After:
const stableGhostPositions = useStableArray(ghostCellPositions);
useMemo(() => {
// expensive calculation
}, [stableGhostPositions])
Results Comparison
Metric | Before | After | Improvement |
---|---|---|---|
Peak memory | 68.4 MB | 43.7 MB | -24.7 MB (36%) |
Memory range | 35.1-68.4 MB | 35.7-43.7 MB | Narrower, more stable |
String allocations | 19.8 MB (41%) | 22.4 MB (44%)* | Spread more evenly |
GC spikes | Infrequent but disruptive | More frequent but gentler | Less noticeable pauses |
String allocation percentage is similar, but in the “After” case allocations are steadier and spread over time instead of building into large spikes.
Memory Graph Comparison
Before (Baseline):
- Large sawtooth pattern with infrequent, aggressive GC cycles
- Orange line (retained memory) rises and does not come back down, suggesting references remain alive unnecessarily
After (Optimized):
- Heap stays in a much narrower band
- GC runs more frequently, but cleans up smaller amounts — less disruptive for interactivity
- Orange line spikes a couple of times but then stays flat at a low level, showing healthier retention
What Changed (And What Didn’t)
✅ Improved
Memory stability: The memory graph shows much smoother allocation patterns without aggressive spikes.
GC pressure: More frequent but gentler garbage collection cycles—less disruptive for interactivity.
Render efficiency: Flamegraph bars are noticeably thinner, confirming memoization now works correctly.
📊 Similar (As Expected)
Commit counts: Still ~550-600 commits during the 10-second hover test. This is expected—mouse move events still trigger renders, but each render is cheaper because memoization works.
String allocations: Percentage remains around 41-44%. The difference is that allocations are more controlled and spread over time rather than spiking aggressively.
🎯 Real Impact
No perceived lag: The level editor feels smooth during hover interactions, especially on lower-end devices.
Better memory headroom: Provides breathing room for other game systems without triggering aggressive GC.
Foundation for future work: Stable array references set the pattern for Tasks 2-4.
Phase 4 Task 1 Summary:
- ✅ 36% peak memory reduction (24.7 MB saved)
- ✅ Smoother memory patterns (fewer GC spikes)
- ✅ Memoization working (thinner flamegraph bars)
- ✅ Zero functional regressions
- ✅ Foundation for Tasks 2-4
Implementation and measurements completed using Claude Code with document-driven development approach.