In React Native, how do you compare Hermes heap snapshots to prove that a screen leaks, and how do you find what retains it?
answer
- warm up, then baseline
- repeat the action N times
- counts growing by multiples of N
- retained size, not shallow size
- follow retainers to the root
basics
~20 sTake a baseline snapshot after warming up, open and close the screen a fixed number of times, take another, and compare: objects whose count grows with the repetitions are leaking. Their retainer path shows which listener, timer, cache or closure holds them.
solid answer
~50 sIn a debug build I open React Native DevTools, visit the destination screen of the travel feed once to warm caches, and take a **baseline** heap snapshot. Then I open and close that screen, say, five times and take a second snapshot. Comparing the two, anything that grew by about five instances per type — my screen's closures, its data arrays, its fetched items — is a leak candidate; things that grew once are caches. I sort by **retained size** to find what holds the most memory, then read the **retainers** path from the object up to a root: typically a subscription list in an event emitter, a timer, or a module-level cache, holding a closure that captured the screen's state. After fixing, I repeat the same steps and expect no growth per visit. Sizes differ from release, but what retains what does not.
code
typescript · 15 linestype Listener = (destinationId: string) => void;
const listeners = new Set<Listener>();
export const recentlyViewed = {
subscribe(listener: Listener): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
markSeen(destinationId: string): void {
listeners.forEach(listener => listener(destinationId));
},
};go deeper
Know that a heap snapshot lists JavaScript objects and that comparing two of them shows what grew.
Explain the warm-up, baseline, repeat and compare procedure, and the difference between shallow and retained size.
Show how you follow retainers from a leaked object to the subscription, timer, cache or closure that holds it, and how you verify the fix.
Discuss turning the procedure into a repeatable leak check for critical flows and deciding which memory growth is acceptable caching.
## What a heap snapshot is A **heap snapshot** is a full picture of every object in the JavaScript heap at one moment — in React Native, the **Hermes** heap — with the references between them. React Native DevTools can record one from its Memory panel in a development build. A single snapshot tells you what exists; **comparing** snapshots tells you what accumulates, which is what a leak is. Key terms: - **Shallow size** — the memory an object occupies itself. - **Retained size** — the memory that would be freed if the object were collected, including everything only it keeps alive. - **Retainers** — the chain of references from a GC root (globals, the module registry, native-held callbacks) down to the object. As long as one chain exists, the object cannot be collected. ## The comparison procedure 1. **Build and open a debug build** with React Native DevTools attached — DevTools is disabled in release builds. 2. **Warm up.** Open and close the suspect screen once. First visits fill caches and load modules, and those one-time allocations are not leaks. 3. **Take a baseline snapshot.** 4. **Repeat the action a fixed number of times** — open and close the destination screen five times, ending in the same state as the baseline. 5. **Take a second snapshot** and compare it with the baseline. ## Reading the comparison | Pattern between snapshots | Likely meaning | |---|---| | a type grows by about N for N repetitions | one object leaked per visit — a strong leak signal | | a type grows once, then stays flat | a cache or lazily created singleton | | a type grows and shrinks between runs | ordinary garbage not yet collected | Then: - **Filter for your own names** — the screen component, its data types, named handler functions — rather than engine internals. Named functions are easier to find than anonymous ones. - **Sort by retained size** to find which leaked objects hold the most memory; a small closure can retain a large feed array. - **Open the retainers** and walk up to a root. The first reference you own is the bug. ## Typical retainer paths in React Native - **An event subscription never removed**: the emitter's listener registry → your handler closure → the screen's state setter and data. Fix: keep the subscription from `AppState.addEventListener`, `Keyboard.addListener` or `Dimensions.addEventListener` and call `remove()` in cleanup. - **A timer never cleared**: the timer registry → the interval callback → everything it captures. Fix: `clearInterval` / `clearTimeout` in cleanup. - **A module-level cache**: a module's top-level `Map` or array → every item ever loaded. Fix: bound it or key it by something that gets evicted. - **A long-lived closure**: a callback handed to a global service (a store, a logger, a native module) that captured the whole screen scope. Fix: unregister it, or capture only the values it needs. ## Retained closures, specifically A **closure** keeps alive the variables it references from its enclosing scopes. A handler that references `items` keeps the entire `items` array reachable, and that array may hold every destination the user scrolled past, with nested objects for each. The handler itself is tiny — its **shallow size** is small — which is why sorting by **retained size** is essential: it reveals that a few bytes of function are holding megabytes of data. ## A worked example from the travel feed Suppose each visit to a destination screen registers a callback with a module-level `recentlyViewed` service so it can refresh a "seen" badge, and never unregisters it. The comparison after five visits shows five more copies of the screen's handler closure, five more copies of its destination objects, and five more photo-gallery arrays. The retainers path reads: module `recentlyViewed` → its listeners `Set` → handler closure → `gallery` array. The fix is to return an unsubscribe function from the service and call it in the effect cleanup — then the same five visits leave nothing behind. ## Verifying the fix and its limits - **Re-run the same procedure** after the fix: the per-visit growth should be gone. - **Track trends in release** with `performance.memory.usedJSHeapSize`, which works outside DevTools, during a repeated-navigation run. - **Remember the scope**: snapshots cover the JavaScript heap only. Decoded images and native views are outside it; if process memory grows while snapshots do not, use a native memory profiler. - **Debug sizes are not release sizes**: dev-mode code allocates extra objects. The retainer chains are what carry over.
- Why warm up before taking the baseline React Native heap snapshot?The first visit to a screen loads modules, fills caches and creates singletons that stay for the app's lifetime. If the baseline is taken before that, those one-time allocations look like growth in the comparison. Warming up first means the only difference left between snapshots is what each extra visit adds.
- A leaked handler shows a tiny shallow size in the snapshot. Why can it still be the main problem?Shallow size counts only the function object itself. Its retained size includes everything reachable only through it — the captured state, the feed array, each item's nested data. Sorting by retained size exposes a small closure holding megabytes, which is exactly how leaked listeners look.
saying these in an interview costs you the question
- One heap snapshot is enough to prove a leak
- Sort by shallow size to find what holds the most memory
- Take the baseline before ever visiting the screen
- Heap snapshots include decoded image bitmaps
- Capture the snapshots from a release build with DevTools attached