Quick answer: Use the Unity Memory Profiler package to take before/after snapshots, compare them to find objects that aren’t being freed, then fix the underlying cause—most commonly unsubscribed event listeners, unreleased Resources.Load() assets, or Instantiate() calls without matching Destroy().

Memory leaks in Unity games are insidious. They don’t crash your game immediately—they make it run fine in your 20-minute playtesting session and then crash on a player who’s been playing for two hours. By the time the crash report lands in your dashboard, the player has already refunded and left a review. Finding and fixing leaks before they reach players is one of the highest-return debugging activities you can do as an indie developer.

Setting Up the Unity Memory Profiler

The Memory Profiler is a separate package from the built-in Profiler window. Install it via the Package Manager:

  1. Open Window > Package Manager
  2. Switch the dropdown to Unity Registry
  3. Search for Memory Profiler and install the latest version (1.1.x as of Unity 2023)

Once installed, open it via Window > Analysis > Memory Profiler. You’ll see a workspace where you can capture snapshots of the heap at any point while the game is running in the Editor or on a connected device.

Taking and Comparing Snapshots

The most effective technique for finding leaks is differential snapshot analysis:

  1. Take a baseline snapshot after the game has loaded and settled (garbage collection has run).
  2. Perform the suspected leak scenario—load and unload a scene, open and close a UI panel, play through a level, return to the main menu.
  3. Take a second snapshot in the same state as the baseline (back at the main menu, for example).
  4. Use Compare Snapshots in the Memory Profiler toolbar to diff the two.

In the comparison view, sort by “Diff” descending. Any object type with a positive count difference that should have been freed by returning to your baseline state is a leak candidate. Pay particular attention to MonoBehaviour subclasses, Texture2D, Mesh, AudioClip, and Material—these are the types most commonly leaked.

A growing count of your custom MonoBehaviour types after scene transitions almost always points to one of the patterns described below.

The Most Common Unity Memory Leak Patterns

1. Event Listeners Not Unsubscribed in OnDestroy

This is the single most common source of Unity memory leaks. When you subscribe a method to a C# event or UnityEvent and the subscribing object is destroyed, the event’s delegate still holds a reference to that object. The object cannot be garbage collected, and if the event fires, it will call a method on a destroyed object—which causes other bugs on top of the leak.

// LEAKS: subscribes but never unsubscribes
public class ScoreDisplay : MonoBehaviour
{
    void OnEnable()
    {
        GameEvents.onScoreChanged += UpdateDisplay;
    }

    // Missing: OnDisable or OnDestroy that unsubscribes
}

// CORRECT: symmetric subscribe/unsubscribe
public class ScoreDisplay : MonoBehaviour
{
    void OnEnable()
    {
        GameEvents.onScoreChanged += UpdateDisplay;
    }

    void OnDisable()
    {
        GameEvents.onScoreChanged -= UpdateDisplay;
    }
}

The rule is simple: every += needs a matching -=. If you subscribe in Start(), unsubscribe in OnDestroy(). If you subscribe in OnEnable(), unsubscribe in OnDisable(). Make this a code review checklist item.

2. Instantiate Without Destroy

Every call to Instantiate() allocates a new GameObject in memory. If you never call Destroy() on it, it accumulates. The most common scenario is a projectile or particle system spawned during gameplay that should destroy itself but doesn’t due to a logic error (missed condition, disabled script, etc.).

// Bullet that destroys itself on hit OR after 5 seconds
public class Bullet : MonoBehaviour
{
    void Start()
    {
        // Safety net: always destroy after 5 seconds
        Destroy(gameObject, 5f);
    }

    void OnCollisionEnter(Collision col)
    {
        Destroy(gameObject);
    }
}

For high-frequency spawning, use an object pool instead of Instantiate/Destroy to avoid both leaks and GC pressure. Unity 2021+ includes UnityEngine.Pool.ObjectPool<T> in the standard library.

3. Resources.Load Assets Never Unloaded

Loading assets with Resources.Load() pins them in memory. They will not be unloaded until you explicitly ask Unity to release them. If your game loads textures, audio clips, or prefabs at runtime and never releases them, memory grows with each load.

// Load a texture at runtime
Texture2D tex = Resources.Load<Texture2D>("UI/LevelThumb_World3");
someRenderer.material.mainTexture = tex;

// Later, when done with it:
someRenderer.material.mainTexture = null;
tex = null; // Remove your reference

// Then trigger unloading of unreferenced assets:
yield return Resources.UnloadUnusedAssets();

Note the order: you must null your references before calling UnloadUnusedAssets(). If any reference to the asset still exists when the call is made, the asset will not be unloaded. This is the most common reason developers call UnloadUnusedAssets() and see no memory improvement.

4. AssetBundle.Unload(true) vs Unload(false)

If you use AssetBundles for DLC or addressable content, understanding the Unload parameter is critical:

// Unload(false): frees the bundle manifest ONLY.
// Assets you loaded from this bundle stay in memory.
// Use this if you still need those assets after unloading the bundle.
myBundle.Unload(false);

// Unload(true): frees the bundle AND all assets loaded from it.
// Those assets are immediately invalid, even if you have references to them.
// Use this when you are completely done with all assets from this bundle.
myBundle.Unload(true);

The typical correct pattern is to hold a reference to the bundle, call Unload(false) immediately after loading all assets you need (freeing the bundle data but keeping assets alive), then null all asset references and call Unload(true) when transitioning away from the scene that used them. Calling Unload(false) and forgetting to eventually call Unload(true) is a common source of slow memory growth.

5. Coroutines Holding References

A coroutine that is still running holds a reference to its enclosing MonoBehaviour and to any local variables captured in its closure. If a scene unloads while a coroutine is mid-execution—waiting on a web request, for example—the MonoBehaviour cannot be garbage collected until the coroutine completes.

void OnDestroy()
{
    // Stop all coroutines this object started before it is destroyed
    StopAllCoroutines();
}

For coroutines that outlive scene transitions (running on a DontDestroyOnLoad manager), make sure they handle cancellation explicitly and null out any references to scene objects after the scene unloads.

Tracking Memory Trends Over Time

One-off profiling sessions catch leaks you know to look for. To catch leaks you don’t know about yet, instrument your game to report memory usage periodically.

using UnityEngine.Profiling;

public class MemoryMonitor : MonoBehaviour
{
    [SerializeField] private float reportIntervalSeconds = 60f;
    private float nextReportTime;

    void Update()
    {
        if (Time.time < nextReportTime) return;
        nextReportTime = Time.time + reportIntervalSeconds;

        long totalMB = Profiler.GetTotalAllocatedMemoryLong() / (1024 * 1024);
        long reservedMB = Profiler.GetTotalReservedMemoryLong() / (1024 * 1024);

        BugnetSDK.AddBreadcrumb($"Memory: {totalMB}MB allocated, {reservedMB}MB reserved");

        // Optionally alert if over a threshold
        if (totalMB > 800)
        {
            BugnetSDK.LogWarning($"High memory usage: {totalMB}MB",
                new Dictionary<string, string> {
                    { "scene", UnityEngine.SceneManagement.SceneManager.GetActiveScene().name },
                    { "session_minutes", ((int)(Time.time / 60)).ToString() }
                });
        }
    }
}

Attaching memory readings as breadcrumbs to crash reports gives you the context to see whether a crash was preceded by steadily climbing memory—a sure sign of a leak rather than a one-off error. In Bugnet’s crash detail view, you’ll see the breadcrumb trail leading up to the crash, including these periodic memory readings.

Calling UnloadUnusedAssets at Scene Transitions

As a general hygiene measure, call Resources.UnloadUnusedAssets() and GC.Collect() when transitioning between major scenes. This won’t fix true leaks (live references prevent unloading), but it clears accumulated orphaned assets and gives you a cleaner baseline for the next scene.

IEnumerator LoadSceneWithCleanup(string sceneName)
{
    // Unload current scene
    yield return SceneManager.LoadSceneAsync("LoadingScreen");

    // Clean up before loading next scene
    yield return Resources.UnloadUnusedAssets();
    GC.Collect();

    yield return SceneManager.LoadSceneAsync(sceneName);
}

“The best time to find a memory leak is before you ship. The second best time is from a crash report that includes memory breadcrumbs showing a two-hour climb to 1.2 GB before the OS killed your process.”

Set a calendar reminder to run a differential snapshot analysis once per milestone—before each public build. It takes 15 minutes and will save you hours of post-release debugging.