Why Wednesday

Garbage Collection, Explained: Why Your Game Stutters Every Few Seconds

What garbage collection actually is, why it causes a hitch instead of running quietly, and how to spot the allocations causing your Unity game to stutter.

Concepts · By Game Design Jar · · P.22

Your game runs smooth for a few seconds, then drops a frame hard enough that you notice it, then goes back to smooth. If that’s happening on a rhythm rather than randomly, garbage collection is almost always the cause. Here’s what’s actually going on and how to find it.

What garbage collection actually is

C# manages memory for you. When your code creates an object with new, the runtime finds a spot for it in memory. When nothing in your game needs that object anymore, something has to free that memory back up so it can be reused. That something is the garbage collector, or GC.

Compare that to a language like C or C++, where you (or the engine) manually track every allocation and explicitly free it. C# skips that step for you. You just create objects, and the runtime figures out later which ones are no longer reachable by anything in your code, and reclaims their memory.

That’s the entire point of garbage collection: you never write a Free() call, and you can’t leak memory by forgetting one. It’s a genuinely good trade for almost all software, and a rougher one for a game that needs to render a new frame every 16 milliseconds.

The problem it solves

Before automatic memory management was standard, a huge share of nasty bugs were memory bugs. You’d free something too early and then try to use it (a dangling pointer), or forget to free it at all and slowly eat all the RAM on the machine (a leak). Tracking every allocation by hand across a large codebase is genuinely hard, and C, C++ and older languages made it very easy to get wrong.

Garbage collection removes that whole category of bug. You can’t use something after it’s freed, because the GC won’t free it while anything still holds a reference to it. You can’t leak memory just by forgetting a call, because there’s no call to forget. That’s a real win, and it’s why C#, Java, Python and most modern languages default to it.

The cost is that the runtime has to spend time, at some point, walking through memory and deciding what’s still reachable and what isn’t, and that work has to happen somewhere. In a web API or a desktop app, that somewhere is usually invisible, because nobody’s timing your response down to the millisecond. In a game running at 60 frames a second, that somewhere is your frame budget.

Why the collector causes a hitch instead of running quietly

Unity’s C# runtime doesn’t collect garbage constantly in small pieces. It waits until enough garbage has piled up (a threshold based on how much memory has been allocated since the last pass), and then runs a collection pass. That pass has to walk the entire set of currently-reachable objects, starting from root references like static fields, local variables on the stack, and active object references, and mark everything they can reach as still in use. Anything not marked gets reclaimed.

That walk takes real time on the main thread, the same thread running your game logic and issuing your render calls that frame. So instead of the cost being smeared thinly across every frame, it lands all at once, usually as a visible stutter every few seconds. The stutter isn’t random, it’s the collector finally deciding the pile of garbage is big enough to deal with.

Unity’s incremental garbage collector spreads that marking work over several frames instead of doing it all in one, and in Unity 6 it’s switched on by default (Project Settings > Player > Configuration, “Use incremental GC”). It smooths the spike out, but it doesn’t make the work disappear, and it doesn’t stop you generating garbage faster than the collector keeps up with. If your Update method allocates a new object every frame, you can out-allocate any collection strategy.

There’s also a GarbageCollector.GCMode API for pausing collection entirely during something you can’t afford to have interrupted, then switching it back on afterwards. That’s a scalpel for a specific moment, not a fix: while it’s off, the garbage still piles up, and it all comes due when you turn it back on.

What counts as an allocation in a Unity script

This is the part that trips people up, because a lot of ordinary-looking C# creates garbage without an obvious new in sight. Anything targeting Unity 6 or any recent version applies here, since this is C# and .NET behavior, not something Unity changed.

Things that allocate:

  • new on any reference type (a class, an array, a List<T>)
  • String concatenation with +, since strings are immutable and every + builds a new one
  • string.Format and most Debug.Log calls that build a string
  • Boxing, which happens when a value type (an int, a float, a struct) gets treated as a general object, such as passing it into a non-generic collection or an interface method
  • A lambda or anonymous method that captures a variable from outside itself, because the compiler has to allocate an object to hold that captured state
  • foreach over some collection types when the enumerator is a reference type instead of a struct

Here’s a small before-and-after. This targets Unity 6, but nothing here is Unity-specific, it’s just C#.

Before, allocating every frame:

void Update()
{
    List<Enemy> nearby = new List<Enemy>();
    foreach (Enemy enemy in allEnemies)
    {
        float dist = Vector3.Distance(enemy.transform.position, transform.position);
        if (dist < detectionRange)
        {
            nearby.Add(enemy);
        }
    }

    healthText.text = "HP: " + currentHealth + "/" + maxHealth;
}

Two allocations here, every single frame: the new List<Enemy>(), and the string built by +. Neither looks alarming on its own. At 60 frames a second, that’s 120 small allocations a second just from this one method.

After:

private readonly List<Enemy> nearby = new List<Enemy>();
private int lastDisplayedHealth = -1;

void Update()
{
    nearby.Clear();
    foreach (Enemy enemy in allEnemies)
    {
        float dist = Vector3.Distance(enemy.transform.position, transform.position);
        if (dist < detectionRange)
        {
            nearby.Add(enemy);
        }
    }

    if (currentHealth != lastDisplayedHealth)
    {
        healthText.text = $"HP: {currentHealth}/{maxHealth}";
        lastDisplayedHealth = currentHealth;
    }
}

The list is created once and reused with Clear(), which empties it without freeing the underlying array. The text only rebuilds when the health value actually changes, instead of every frame regardless. Neither change alters the logic, they just stop repeating work the original didn’t need to repeat.

One thing worth being clear about: $"HP: {currentHealth}/{maxHealth}" still allocates a string. Interpolation isn’t free, it just reads better than +. The win here isn’t the syntax, it’s that the line now runs when the number changes instead of sixty times a second.

If you’re using TextMeshPro, you can skip that allocation entirely. SetText takes a format string and the numbers separately, and builds the text without creating a string at all:

// Allocates a string every time it runs
healthText.text = $"HP: {currentHealth}/{maxHealth}";

// Same result, no string allocated
healthText.SetText("HP: {0}/{1}", currentHealth, maxHealth);

How to see it for yourself in the Unity Profiler

Open Window > Analysis > Profiler and play your game (in the editor, or better, a development build, since the editor adds its own overhead). Switch to the CPU Usage module and look at the frame timeline. A GC spike shows up as a tall bar with a distinct color band for garbage collection, and you can click any frame to see the breakdown below.

The more useful view for this specific problem is the GC Alloc column, visible when you expand a frame in the hierarchy view. It tells you, in bytes, how much garbage a given function call generated in that frame. Sort by that column and you’ll usually find the same handful of methods at the top every time: something in Update, something building UI text, something doing per-frame distance checks against a list. Start with whatever sits at the top of that list.

The first time I actually opened the profiler on my own project instead of just reading about it, I expected to find one obvious culprit. Instead I found about six small ones, all under a few hundred bytes each, all in Update methods I’d written early on and never looked at again. None of them were dramatic alone. Added up across every enemy on screen, they were enough to see a spike on the timeline.

The allocations that show up in almost every project

A few patterns account for most GC pressure in small Unity projects:

  • Building strings for UI (health, score, timers) every frame instead of only when the value changes
  • GetComponent<T>() calls inside Update instead of cached once in Awake or Start. The generic version doesn’t allocate, but it isn’t free either, and it tends to sit next to code that does allocate
  • Instantiate and Destroy calls for short-lived objects like bullets, hit effects, or pickups
  • LINQ queries (.Where, .Select, .OrderBy) used in per-frame code, since most LINQ operators allocate enumerators and intermediate collections
  • Camera.main in a hot loop. This one used to be far worse: it did a tag search on every call until Unity 2019.4.9, and now uses an internal cache, so it costs roughly what a GetComponent costs. Still worth caching if you call it every frame, but it isn’t the disaster the older advice makes it sound

None of these are wrong to use. They’re wrong to use every frame without thinking about it.

When to care about this

Care about it once you can actually see a problem: a stutter on the frame timeline, a dropped frame rate on lower-end hardware, or a GC Alloc column that’s climbing steadily rather than staying near zero. Real-time systems that run every frame for the whole life of the game (movement, combat loops, UI updates, camera follow) are worth auditing, because their small allocations compound over a long play session.

This matters more as your game gets busier. A turn-based JRPG with a handful of enemies on screen has a very different allocation budget than an action game spawning bullets every few frames, and it’s worth checking the profiler as content gets added rather than assuming everything still fits.

When not to bother

Don’t chase every allocation in your codebase. One-off allocations, like loading a scene, instantiating a UI panel when a menu opens, or building a save file, happen once and are gone. They don’t compound, and optimizing them buys you nothing while adding complexity you’ll have to maintain. The rule of thumb is: if it runs in Update, FixedUpdate, or anything called every frame or every physics step, it’s worth a look. If it runs once when a button is pressed, it almost never is.

It’s also easy to over-correct into code that’s harder to read for a saving that never shows up on the profiler. Cache a list here, skip a string rebuild there, and stop once the GC Alloc column for your hot paths sits near zero. Chasing it further than that is optimizing a number nobody will ever notice.

How this relates to object pooling

Object pooling is the direct fix for one specific source of GC pressure: repeated Instantiate and Destroy calls for things that get created and thrown away constantly, like bullets, particle bursts, or enemy spawns. Instead of destroying the object, a pool disables it and hands it back out later, so you get the same object reused instead of a fresh allocation every time. The full mechanism, including a working pool implementation, is in Object Pooling, Explained.

Pooling doesn’t fix string allocations, boxing, or lambda captures. Those need the fixes described above: cache the list, skip the rebuild when nothing changed, avoid LINQ in hot paths. Pooling solves the Instantiate/Destroy case specifically, which tends to be the biggest single source of GC pressure in anything with bullets or frequent spawns, but it’s one tool among several, not a fix for garbage collection generally.

If you want the companion piece on why frame timing itself varies across machines, Delta Time, Explained covers the other half of “why does my game behave differently on different hardware,” which often gets confused with GC stutter but is a completely separate mechanism.

unitygarbage collectionperformancec#game feelobject pooling

Registration card

Register to receive one focused post every weekday — engine tutorials, marketing tactics, and design craft. Free, unsubscribe anytime.

No spam. We never share your address. No purchase necessary — it's free.