← All posts

Finding a Unity Memory Leak: A Profiling Walkthrough From First Crash to Fix

UnityMemoryDebugging
Rising memory usage graph from a Unity game session ending in an out-of-memory crash

The crash reports had a shape. Not a stack trace, a shape: sessions on Android ending abruptly between minute 35 and minute 45, almost exclusively on devices with 2 or 3 GB of RAM. No exception, no log, nothing in our analytics except a session that stopped mid-level. On our test phones, all comfortable 8 GB flagships, the game ran for hours.

That shape has exactly one usual suspect. When Android runs low on memory it doesn’t politely notify your game, it kills the process, and from the inside a kill looks like nothing at all. The players with cheap phones were hitting a memory ceiling we couldn’t see, which meant something in our game grew for forty minutes straight. This is the story of finding it, because the process is the same for every Unity memory leak and almost none of it is guesswork.

Why Unity memory leaks don’t look like leaks

The thing that makes Unity memory confusing is that there are two allocators with two lifetimes, joined in the middle. C# objects live in managed memory, owned by the garbage collector, freed when nothing references them. Textures, meshes, audio clips and the engine-side halves of your GameObjects live in native memory, freed when something explicitly destroys or unloads them. Every MonoBehaviour is both at once: a native object the engine owns and a managed wrapper the GC owns.

So “we have a leak” splits into two different questions. Managed leaks are almost always a reference somebody forgot: some long-lived object still points at things that should be dead, so the GC correctly refuses to collect them. Native leaks are almost always a Destroy somebody skipped: the C# reference went away, nothing frees native memory automatically, and the texture sits there forever. The tooling tells you which kind you have, so the first move is never code reading. It’s a snapshot.

The snapshot diff workflow

Unity’s Memory Profiler package (com.unity.memoryprofiler, a proper 1.x release since 2022 LTS) does one thing the ordinary Profiler window can’t: it captures the entire heap at a moment in time and, crucially, compares two captures. The workflow that finds nearly every leak is embarrassingly small:

  1. Play to a stable point, the main menu after one full gameplay loop. Capture.
  2. Play three more full loops: into the level, fight, die, back to menu. Same menu, same screen. Capture again.
  3. Open both, hit Compare, sort by count difference.

One tool-choice note before the walkthrough: the ordinary Profiler window’s memory module still earns its keep as the first look, not instead of snapshots but before them. Its live counters tell you which broad bucket is growing, managed heap, native, or graphics, and that decides where the snapshot hunt should even start. Watching the managed counter climb in that stair-step pattern where each GC frees less than the last is the thirty-second confirmation that you’re hunting references, not missing Destroys.

The reasoning behind the loop ritual: after returning to the same state, memory should return to roughly the same state. Anything whose count grew by the number of loops you ran is your leak, and growth-per-loop is a fingerprint that’s very hard for innocent allocations to fake. Do this on a device build, not in the editor; the editor keeps its own references to assets and inflates everything, in the same family of lies I catalogued in why builds behave differently from the editor.

Our diff was not subtle. EnemyController: +847 instances. Three loops of a level that spawns about 280 enemies. The scene had been unloaded three times, every enemy visibly despawned, and yet there they were, in memory, three levels’ worth of dead goblins.

Reading the diff: who’s holding the corpse

A managed object survives because something references it, and the Memory Profiler shows you exactly what: select an instance and inspect its references, walking up until you hit something with a reason to be alive. For our goblins the chain was two hops long and ended somewhere painfully familiar:

public class EnemyController : MonoBehaviour
{
    public static event Action<EnemyController> EnemyDied;

    void OnEnable()
    {
        WaveSystem.WaveCleared += OnWaveCleared;
    }

    // OnDisable exists. The unsubscribe line didn't.
}

WaveSystem.WaveCleared is a static event. Static means it lives for the entire run of the game, and an event is, underneath, a list of delegate references. Every enemy that subscribed and never unsubscribed is in that list forever, and each delegate holds the enemy’s managed object, which holds every field the enemy has: its inventory list, its path buffer, its reference to the material we’ll get to shortly. One missing line in OnDisable, multiplied by 280 enemies per level.

There’s a Unity-specific cruelty here worth spelling out. When a scene unloads, Unity destroys the native half of every object in it regardless of what references the managed half. So the game looks correct: enemies are gone from the hierarchy, gone from rendering, enemy == null even returns true, because Unity overloads == to report destroyed objects as null. But the managed wrapper, and everything it drags with it, sits in the static event’s invocation list, invisible to gameplay and ineligible for collection. These zombie objects are close cousins of the silently-broken references I wrote about in Unity’s silent failures: the engine knows, and tells no one.

The fix is symmetry, mechanically enforced: every += in OnEnable gets its -= in OnDisable, no exceptions, and event wiring stays out of Awake/OnDestroy unless you have a reason. On code review I search changed files for += and look for the mirror. It’s a thirty-second check. If your project leans on events heavily (mine do; I’ve written about event-based architecture in Unity approvingly), this discipline is the tax that pays for the pattern.

The native side: textures that outlive their owners

Fixing the goblins bought us about twenty minutes of session time on the crashing devices. The remaining growth wasn’t managed at all. The diff’s native view showed Material instances climbing, about a dozen per wave, and Texture2D memory stair-stepping up on every boss.

Two separate crimes, both classics. The materials came from one innocent-looking line:

renderer.material.SetFloat("_Dissolve", t);

Accessing renderer.material clones the shared material on first touch, and that clone is yours now. Unity does not destroy it when the renderer dies; it’s an asset-like object with no scene to belong to. Every enemy that ever dissolved had minted a material nobody freed. The options are renderer.sharedMaterial with a MaterialPropertyBlock, or explicitly Destroy(instancedMaterial) in the enemy’s own teardown. We did the property block; it’s also just faster.

The textures were boss portraits downloaded for a live-ops event screen, created via DownloadHandlerTexture. A Texture2D you create from code, from a download, new Texture2D, or Instantiate, is likewise yours to Destroy. Ours were parked in a static dictionary “cache” with no eviction, which is not a cache, it’s a leak with good intentions.

One more thing kept both crimes hidden: our levels loaded additively. On a plain single-scene load Unity runs an unload of unreferenced assets, which quietly mops up some orphaned instances, but additive flows don’t get that sweep unless you call Resources.UnloadUnusedAssets() yourself. We added a call at the end-of-level fade, where its hitch can’t be felt. It’s a broom, not a fix; the fix is owning your instances. But defence in depth is allowed.

The other suspects I’ve booked since

That project had two leaks. Other people’s projects have shown me the rest of the family, and they recur often enough to be a checklist.

Coroutines that never end. A while (true) coroutine with a yield return inside holds its enclosing object, and everything captured in its closure, for as long as it runs. Park one on a persistent manager, capture a level object in it (“just cache the player transform”), and the level can never fully unload. The pattern repeats with Invoke and with async methods awaiting something that never completes.

DontDestroyOnLoad doubles. The classic: a manager marks itself persistent, the game returns to the boot scene, the boot scene instantiates a second manager. Now two audio systems live forever, then three. The singleton guard everyone writes eventually exists because everyone has shipped this at least once; the Memory Profiler shows it as instance counts that grow by one per menu visit instead of per gameplay loop.

Pools that only grow. An object pool with no cap is a leak wearing a uniform. Worst case spawns during a lag spike expand it to 900 projectiles, and it holds all 900 for the rest of the session. Caps and shrink policies are boring and they work.

While you’re staring at reference chains, one C# footnote will save you a confused hour: Unity’s destroyed-object-pretends-to-be-null trick only works through the overloaded ==. The newer operators bypass it, so enemy?.transform and enemy ?? fallback happily dereference a destroyed object and hand you MissingReferenceExceptions, or worse, quietly wrong behaviour. On Unity types, spell the null checks out.

What stuck afterwards

The session graph after both fixes was the boring, beautiful sawtooth you want: climb during a level, full drop at the menu, flat baseline across hours. Crashes on 2 GB devices fell to noise within a release.

We also finally wrote down a memory budget, which I’d recommend to any team before their next leak rather than after: pick the worst device you genuinely support, subtract what the OS and your peak scene legitimately need, and treat the remainder as a number the profiler must stay under at the end of every loop. A budget converts “memory seems fine?” into a pass-or-fail check anyone on the team can run.

What survived on the team was less the fixes than the ritual. Snapshot, three loops, snapshot, diff, sorted by count delta: it runs in fifteen minutes before every release candidate and has caught two regressions since, both within a day of being introduced. Static state gets treated as guilty until proven innocent, because both of our leaks, and honestly every managed leak I’ve found in other people’s projects since, lived behind the word static.

Memory work has a reputation as a dark art, and I want to push back on that. The tools show you the exact object, the exact reference chain, the exact line to change. The only genuinely hard part is the discipline to measure on the hardware your players actually own, before the crash reports start drawing shapes for you. This hunt and its siblings are collected in my list of Unity problems that eat entire weeks; this one only takes a week if you skip the snapshots and start guessing.

Common questions

Why does my Unity game crash on Android with no error message?

When Android runs low on memory it kills the process without any exception or log, so from inside the game an out-of-memory kill looks like nothing at all. If sessions end abruptly on low-RAM devices while flagship phones run for hours, suspect a memory leak. Profile on the worst device you genuinely support, not your test flagship.

How do I find a memory leak with the Unity Memory Profiler?

Capture a snapshot at a stable point like the main menu, play three full gameplay loops back to the same screen, capture again, then use Compare and sort by count difference. Anything whose count grew by the number of loops you ran is your leak, and the reference view shows the exact chain keeping it alive. Do this on a device build, because the editor holds its own references to assets and inflates everything.

Do static events cause memory leaks in Unity?

Yes, they are the most common managed leak in practice. A static event lives for the entire run of the game, and its invocation list holds a reference to every subscriber, so any object that subscribes in OnEnable and never unsubscribes can never be collected. Enforce symmetry: every += in OnEnable gets its -= in OnDisable.

Why is a destroyed GameObject not null in Unity?

Unity overloads the == operator so destroyed objects compare equal to null, but the managed wrapper object still exists until the garbage collector can reclaim it. The newer C# operators bypass the overload, so enemy?.transform and enemy ?? fallback will happily dereference a destroyed object and throw MissingReferenceException. On Unity types, write null checks out explicitly.

Does Resources.UnloadUnusedAssets fix memory leaks?

It helps, but it is a broom, not a fix; the real fix is owning and destroying the instances you create. Plain single-scene loads run an unreferenced-asset sweep automatically, but additive scene flows do not, so call Resources.UnloadUnusedAssets yourself at a moment where its hitch cannot be felt, like an end-of-level fade.