Flutter's Dart VM uses generational garbage collection with a scavenge-promote cycle optimized for short-lived objects. When building complex mobile apps—especially those with persistent UI state, cached network responses, or embedded ML models—heap fragmentation becomes the silent performance killer. After shipping apps like KidzCare (speech therapy with on-device audio processing) and GlucoScan AI (PPG signal buffering), I've learned that naive allocation patterns can trigger 400ms+ GC pauses on mid-tier Android devices. Arena allocators offer a disciplined escape hatch.
The Fragmentation Problem
Dart's heap consists of two generations: new-space (scavenge every 2-4MB) and old-space (mark-compact when pressure builds). Objects surviving two scavenges promote to old-space, where they sit until a full GC. In a typical Flutter app with:
- Cached JSON models (user profiles, product catalogs)
- Image decode buffers (even after widgets dispose)
- Audio sample arrays for DSP
- LLM token histories in chat UIs
You end up with a patchwork of live objects interspersed with dead references. The VM's mark-compact phase must walk this mess, moving survivors to defragment. On a Pixel 4a with 6GB RAM, I measured 320ms pauses during old-space compaction when the app held 180MB of mixed-lifetime objects.
Measurement: GC Observatory
Enable VM service and connect Observatory. Watch HeapMap view during a product list scroll (500 items, each with 3 cached images). You'll see old-space pages with 40-60% occupancy—wasted address space the VM can't reclaim without compacting. The GCStats timeline shows scavenge frequency climbing as fragmentation worsens, because the VM can't find contiguous chunks for new allocations.
Arena Allocation: Grouping Lifetimes
An arena is a memory region where you allocate objects with identical lifetimes, then free the entire arena at once. In Flutter, we can't control VM internals, but we can simulate arenas by grouping related objects and explicitly nulling references in bulk. The key insight: if ten objects die together, the GC sees ten holes appear simultaneously—easier to coalesce than scattered deaths.
Pattern 1: Session Arenas
For a chat app with on-device LLM, each conversation session allocates:
- Token history list (grows to 2K tokens)
- Embedding cache (768-dim vectors)
- Response metadata (timestamps, model IDs)
Naive approach: store these in a global Map<String, Session>. When the user closes a chat, remove the map entry—but individual objects linger in old-space until the next full GC. Instead:
class SessionArena {
final List<List<int>> _tokenBuffers = [];
final List<Float32List> _embeddings = [];
final List<Map<String, dynamic>> _metadata = [];
void allocateTokenBuffer(int capacity) {
_tokenBuffers.add(List<int>.filled(capacity, 0));
}
void dispose() {
_tokenBuffers.clear();
_embeddings.clear();
_metadata.clear();
// All references dropped atomically
}
}When the session ends, dispose() nulls all references in one sweep. The next scavenge finds a contiguous block of dead objects, reducing fragmentation. In production, this dropped old-space GC pauses from 280ms to 110ms (measured via timeline trace) for a 15-message conversation.
Pattern 2: Frame Arenas for Audio DSP
Real-time audio apps (HearingAid Pro, Voice Trainer) process 512-sample frames at 48kHz. Each frame allocates:
- Input buffer (Float32List, 2KB)
- FFT workspace (complex pairs, 4KB)
- Filter state (biquad coefficients, 256 bytes)
Allocating per-frame creates 94 objects/second. After 10 seconds, you have 940 short-lived objects scattered across new-space. Instead, allocate a pool of frame arenas at startup:
class AudioFrameArena {
late Float32List inputBuffer;
late Float32List fftWorkspace;
late Float32List filterState;
bool inUse = false;
void allocate() {
inputBuffer = Float32List(512);
fftWorkspace = Float32List(1024);
filterState = Float32List(64);
}
void reset() {
inputBuffer.fillRange(0, 512, 0.0);
// Don't reallocate, just zero
}
}
final arenaPool = List.generate(8, (_) => AudioFrameArena()..allocate());Acquire an arena, process the frame, reset, release. The Float32List instances stay alive in old-space, but you never allocate new ones. Scavenge pressure drops to near-zero for audio workloads. Measured impact: GC jitter reduced from 12ms spikes (causing audio glitches) to 100 objects with identical lifetimes per second