Unpacking how the Hermes garbage collector works: generational GC with a nursery and old generation, mark-compact to fight fragmentation, arena-based allocation, how to benchmark memory in React Native apps, and best practices for minimizing memory churn.

In episode 5 you optimized startup via bytecode prepackaging, used the -O and -output-source-map optimizer flags, and understood the relationship between bundle size and cold start. Now it's the turn of one side that's often ignored until the app crashes: memory management. This episode dissects how the Hermes garbage collector (GC) works — generational GC, mark-compact, and arena-based allocation — then teaches you how to measure memory in React Native apps and write code that minimizes memory churn.
By the end of this episode, you won't just understand what the GC does behind the scenes; you'll also know which metrics to measure before deciding to "patch" code. Remember the golden rule of optimization: never touch anything before you have numbers from measurement.
If memory feels abundant on desktop, on mobile devices memory is a scarce commodity. Android apps live inside a heap budget limited by the system — exceeding the limit means an OutOfMemoryError and the app is force-closed by the OS. On iOS, high memory pressure triggers jetsam, which is just as brutal.
Hermes was designed on top of this assumption. One of its main advantages over V8 or JavaScriptCore is a smaller memory footprint and more predictable allocation behavior. But that doesn't free the app from its duties: code that allocates wastefully still makes the GC work harder, and a hard-working GC means jank in the middle of scrolling or during screen transitions.
The garbage collector has one job: freeing objects that are no longer referenced by the program. Since JavaScript objects are born and die in massive numbers, the GC can't scan them all every time — it has to be smart. Hermes uses a combination of three strategies: generational, compacting, and arena.
Generational GC leverages an empirical fact that holds in almost every runtime: most objects die young. New objects are born in the nursery (young generation). When the nursery is full, the young GC only scans the nursery, promoting surviving objects to the old generation, and discarding the rest. The old generation is never touched during this cycle, so cleaning up "young garbage" is very fast.
This strategy is explicit in Hermes' design: small object allocation must be as cheap as possible, and nursery collection must finish in a few milliseconds. The price paid is a write barrier so objects in the old generation that point to the nursery can be tracked.
For the old generation, Hermes uses a mark-compact collector. The mark phase traces all live objects starting from the roots — the global object, stack, and registry — and marks them. The compact phase packs the live objects to one side of the heap so fragmentation holes are closed.
On runtimes using the Hades GC, the mark phase is done concurrently by a background thread. As a result, the GC pauses felt by the app are much shorter than a typical stop-the-world GC. Of course, there's no free lunch: compaction and concurrent marking consume a little extra CPU and extra memory for tracking structures.
Hermes doesn't request memory from the operating system per object. It requests one large block (chunk) at a time, then splits that block into arenas where objects are allocated sequentially. The results: the number of system calls drops drastically, object allocation becomes a very fast pointer bump, and a whole arena can be freed en masse at once.
So don't panic if the task manager shows the heap rising "suddenly." Most likely that's not a leak — it's Hermes grabbing a chunk in advance to use gradually.
Hermes exposes heap information via HermesInternal.getRuntimeProperties(). Call it from inside the app to see the numbers directly:
const props = HermesInternal.getRuntimeProperties();
console.log("UsedBytes:", props.UsedBytes);
console.log("HeapSize:", props.HeapSize);
console.log("BytecodePages:", props.BytecodePages);
console.log("ExternalBytes:", props.ExternalBytes);UsedBytes tells you how many bytes are actually used by objects, HeapSize the size of the committed heap, and ExternalBytes the memory borrowed for external buffers such as large strings or ArrayBuffer. The difference between them is space waiting for the GC. For experiments, you can also force a collection with HermesInternal.gc() before taking the numbers, so the measurement starts from a clean state.
Info
HermesInternal is fully available in development builds. In release builds, some internal APIs may be stripped or guarded — so design your measurements as a development feature, not something left on in production.
Measuring via HermesInternal only tells you about the JavaScript heap. For the full picture, you need to see the app process from the system side. On Android, the fastest tool is dumpsys:
adb shell dumpsys meminfo com.example.belajarhermes | grep -E "Native Heap|Java Heap|Total"The dumpsys output separates memory into categories: Native Heap holds the memory used by C/C++ code (including the Hermes runtime and bytecode), while Java Heap is used by ART and Java objects. Their total is the app's real consumption in the OS's eyes.
A more thorough method is running profiles:
Additionally, the Memory tab in Chrome DevTools for Hermes can produce heap snapshots of the JS heap. Save a snapshot before and after an action, then compare the dominators to find retained objects. Do the measurements on a physical device, not an emulator — emulator numbers are often far from the reality of users' devices.
Memory churn is the phenomenon of objects being allocated and freed too often. High churn keeps the GC spinning constantly and wastes CPU. The key pattern to suppress it is reusing objects that already exist:
const tokenPool = [];
function acquireToken() {
return tokenPool.pop() || { index: 0, used: false };
}
function releaseToken(token) {
token.used = false;
tokenPool.push(token);
}When a token is used up, you return it to the pool instead of letting it die and creating a new one. This is the classic object pooling pattern, very effective in hot paths like list rendering or repeated data parsing. Some other habits worth building:
Object.freeze for data that is truly immutable. This helps the engine predict object shapes and reduces unexpected allocations.Warning
Calling HermesInternal.gc() in production to "free" memory is counterproductive: it stops the world and slows the app down. Let the GC decide its own timing unless you're doing a measured experiment.
You now know the brain of Hermes' memory management: a nursery that holds young objects, mark-compact for the old generation that fights fragmentation, Hades that shifts GC work to a background thread, and arenas that make allocation cheap. You also know how to measure with HermesInternal.getRuntimeProperties(), adb shell dumpsys meminfo, and heap snapshots, plus code patterns to suppress memory churn.
The essentials to take home:
HermesInternal.getRuntimeProperties(), dumpsys, and heap snapshots before changing anything.In the next episode, episode 7, we move to the fun operational side: debugging and profiling. You'll connect Hermes to Chrome DevTools and Flipper, profile JavaScript execution, take heap snapshots, and learn to recognize performance pitfalls like re-renders, large objects, and sync loops. See you there!