Heap size on its own is a weak signal. Check Reserved versus Used, whether allocation keeps growing across a session, and whether GC stutter frames line up with your allocation pattern — before you change any code.
A large Mono heap in a Unity game is not automatically a leak, and it is not automatically worth optimizing. The heap number tells you how much memory the managed runtime has reserved. Whether that number is a problem depends on three separate facts:
- How much of the heap is actually holding live data (Mono Used) versus merely reserved (Mono Reserved).
- Whether the value keeps growing across a session, or settles after the first scene.
- Whether managed allocation is producing GC stutter frames during normal gameplay.
GameOptim's guidance treats a Mono heap above roughly 80 MB as a reason to collect Mono data in depth — not as a verdict that the project is leaking. The rest of this article explains how to separate those cases, what to inspect first, and which optimizations are actually worth doing.
Summary
- A high Mono heap value is a triage signal, not proof of a leak.
- Reserved and Used answer different questions: how much capacity the runtime holds, and how much of it is live. Read them together, never the larger one alone.
- Continuous managed allocation causes both memory growth and GC stutter; forced collections such as
GC.CollectandUnloadUnusedshow up as recurring stutter points in profile data. - Lua objects that hold references to Mono objects can keep managed memory alive, so a Lua-side reference graph can look exactly like a Mono leak.
- On IL2CPP, Mono heap data can be collected with SDK 2.4.5 or later and Script Debugging enabled, so producing a separate Mono build is no longer a prerequisite for this analysis.
What Is the Core Problem?
Unity's Mono heap is the managed memory area used by C# objects, strings, and the runtime's own bookkeeping. Teams usually arrive at this question after seeing one of two symptoms: the heap value in a report looks large, or the game stutters at intervals that no rendering change seems to fix.
These are different problems wearing the same number:
| Concept | What it describes | Why it matters |
|---|---|---|
| Mono Reserved | The heap capacity the runtime holds | A high value with low usage means capacity, not retention |
| Mono Used | Memory currently allocated to live managed objects | Growth here usually means something is still referenced |
| Allocation churn | How fast new managed memory is being allocated and dropped | Drives GC frequency, and therefore stutter |
| Leak | Live objects that should have been released but are still referenced | Eventually exhausts memory and can crash the process |
A heap can be large and healthy (a big but stable live set), or moderate and unhealthy (a small live set that keeps growing). Optimization decisions made from the Reserved figure alone will frequently target the wrong subsystem.
Why Does a Large Mono Heap Slow Down a Unity Game?
Two mechanisms connect heap size to frame time.
First, allocation triggers collection. Every managed allocation adds to the heap; when the heap crosses a threshold, the runtime runs a garbage collection. That collection consumes CPU time inside a frame, which is why sustained allocation shows up as periodic frame-time spikes rather than a smooth slowdown. Reducing allocation frequency reduces the frequency of those spikes, independent of how large the heap currently is.
Second, reserved memory has a hard ceiling. If a project leaks managed memory badly enough, the heap keeps expanding until the process runs out of memory and the game terminates. On constrained devices this can arrive well before the game looks "heavy" in a desktop profile.
Collecting this data has its own cost, which is why sampling strategy matters. Instrumentation adds work at every probe point, so capturing everything on every frame risks distorting the very frame times you are trying to measure. GameOptim's memory modules therefore sample at key moments — the first frame of a test, every thousand frames, scene-transition frames, and the final frame — instead of recording continuously. Reading a report with that rhythm in mind prevents you from treating a sampling gap as a memory drop.
How Can You Tell Whether the Heap Is Actually a Problem?
Compare the metrics against each other and against time. The combinations below separate a leak from a large-but-stable footprint.
| Observation across a session | Likely meaning | Next check |
|---|---|---|
| Used rises steadily and never returns | Live objects are being retained | Reference chain — including Lua-held references to Mono objects |
| Reserved rises while Used stays flat | The heap expanded, but the retained object set did not | Whether the expansion is bounded; it is not, by itself, evidence of a leak |
| Reserved and Used both high but stable | Large live footprint, stable workload | Whether the device budget tolerates it; optimization is a trade-off decision, not a bug fix |
| Stutter frames cluster around allocation spikes | GC pressure from allocation churn | Allocation rate per frame, and any forced collection calls |
| Stutter correlates with scene loads | Load-time allocation, not steady-state | Loading-path functions rather than gameplay allocation |
GameOptim's published guidance is to keep the Mono heap within about 80 MB, and to run a Mono-mode session for deeper allocation and leak analysis when the heap runs higher than that. Treat 80 MB as the point at which the question becomes worth a dedicated test, not as a pass/fail line: the acceptable ceiling is set by your target device and the rest of your memory budget.
What Should You Check First?
Work in this order; each step rules out a cheaper explanation before you spend effort on the next.
- Confirm the build can report Mono data. On IL2CPP, this requires SDK 2.4.5 or later and the Script Debugging option enabled at build time. Without it, the absence of Mono data is a configuration issue, not a finding. Keep build settings identical between runs you intend to compare.
- Collect a session that includes real gameplay. A session that only loads a scene cannot distinguish a live set from a leak. The sampling points — start frame, periodic samples, scene transitions, end frame — are what make growth visible.
- Read Reserved and Used as a pair. Establish whether the retainer is capacity or live objects before looking anywhere else.
- Check Lua-to-Mono references. If Lua holds references to Mono objects, those objects are never released, and Mono Used climbs with no C# allocation in sight. Lua heap data and Mono object reference data answer this directly.
-
Check whether the stutter frames are collection frames.
GC.CollectandUnloadUnusedappear as their own stutter categories, with duration, location, and call frequency. If your spikes belong to those categories, you are looking at collection cost, not at rendering or asset work. -
Only then look at loading-path functions.
Shader.Parse,Shader.CreateGPUProgram,Instantiate, andLoadingReadObjectare the common load-time costs. If stutter happens during scene transitions rather than steady state, this is where the time is going.
What Should You Optimize?
Prioritize by what the data showed, in this order:
- Retained objects first. If Used grows and never drops, fix the reference that keeps the object alive — Lua references are the most common blind spot, because the C# side looks clean.
- Allocation rate second. If Used is stable but stutter follows allocation spikes, reduce per-frame managed allocation. You are targeting GC frequency, not heap size.
-
Forced collection calls third. If
GC.CollectorUnloadUnusedshows up as a stutter category, the call sites are in your own code. The data gives you the timing and frequency to judge whether the call is doing work at a moment the frame cannot afford. - Loading path last, and only if it matched the symptom. Loading stutter is a different problem with different fixes; do not tune steady-state allocation to fix a scene-load spike.
Each of these changes should be followed by a new collection run under the same conditions. Because sampling is periodic, a change that only shifts when allocation happens can look subtle in aggregate numbers while being obvious in the stutter frames.
What Should You Avoid Optimizing Prematurely?
- Do not treat the heap number as a diagnosis. A large heap with a stable live set is a capacity decision, not a bug. Cutting it requires giving something up.
- Do not read Reserved as if it were Used. The larger of the two figures is often the less actionable one.
- Do not produce a separate Mono build by habit. On IL2CPP packages, Mono analysis is available through the SDK and Script Debugging configuration described above.
- Do not enable every collection mode at once "to be safe." Each mode adds instrumentation cost, and the combined overhead affects the run to different degrees. Enable Resource or Lua collection inside an Overview session only when you need data from subsystems that must be read together — for example, a texture's size and its Mono-side wrapper in one report — and follow the documented caveats about the performance impact of combined modes.
Practical Recommendations
| Step | What to inspect | How to judge | What to change | What to expect |
|---|---|---|---|---|
| 1 | Build configuration | Mono data present or absent | Enable Script Debugging, SDK 2.4.5+ | Data becomes available without a Mono build |
| 2 | Reserved vs Used | Which figure is driving the total | Nothing yet | A clear statement of capacity vs retention |
| 3 | Used over time | Monotonic growth across sampling points | Retained reference chain, including Lua | Flattened Used curve after the fix |
| 4 | Stutter frame categories | Share of frames attributed to GC | Allocation rate per frame | Fewer collection-triggered spikes |
| 5 | Collection call sites | Duration and frequency of GC.Collect / UnloadUnused
|
Call timing in your code | Predictable, bounded collection cost |
| 6 | Scene-load functions | Load-time stutter categories | Loading path, not gameplay allocation | Load spikes addressed at their actual source |
Key Takeaways
- A large Mono heap is a measurement question before it is an optimization question.
- Mono Reserved shows capacity; Mono Used shows retention. Growth in Used is the leak signal.
- GC stutter comes from allocation frequency, not from heap size alone.
- Lua references to Mono objects can retain managed memory invisibly to C#-side review.
- GameOptim suggests keeping the heap within roughly 80 MB and running a Mono-mode test above that — a trigger for investigation, not a failure threshold.
FAQ
Does a large Mono heap always mean a memory leak?
No. A heap can be large because a large live set is legitimately retained, or because the runtime reserved capacity it has not yet needed. A leak requires Used memory to keep growing across a session without being released.
Is 80 MB a hard limit for a Unity Mono heap?
It is GameOptim's suggested ceiling, and the point at which a deeper Mono-mode test is recommended. The real limit depends on your target device's total memory budget and what else the game needs.
Why can't I see Mono data in my IL2CPP build?
Check the SDK version and the build option: Mono heap collection on IL2CPP requires SDK 2.4.5 or later with Script Debugging enabled. This is a configuration prerequisite, not a runtime behavior to debug.
How can Lua cause a Mono memory leak?
When Lua holds a reference to a Mono object, the managed object stays reachable and cannot be collected. Mono object reference data in the report exposes these links, which is why Lua memory and Mono memory are worth reading together.
Should I enable Mono, Resource, and Lua collection in the same run?
Only when you need those subsystems correlated in one report — for example, resource lifetime next to the managed object that owns it. Each enabled mode adds instrumentation cost, so combined runs should be judged against the documented impact rather than assumed to be free.
Top comments (0)