Quick answer: GC spikes happen because something allocates in your hot path. Profile to find the allocations, then eliminate them with object pooling, non-allocating APIs, pre-sized collections, and StringBuilder for string work. IL2CPP does not save you from this — managed memory still has a collector, and you still need to stop generating garbage to start with.
Nothing ruins a frame like a garbage collection pause. The game is humming along at 60 FPS, the combat feels great, and then a 40 ms hitch happens every ten seconds because something allocated three megabytes per frame and the collector finally gave up trying to keep up. GC spikes are not inevitable, and they are not a runtime limitation. They are a code problem, and once you know where to look, they are usually fast to fix.
Profile Allocations, Not Frame Time
A profiler that only shows frame time will mislead you. You see a spike, you see it correlate with GC, and you conclude “GC is slow.” The actual problem is that you are generating garbage; the collector is just the messenger. Switch to allocation profiling and you see exactly which call sites are creating objects.
In Unity, the Profiler’s Memory module with deep profiling enabled shows bytes allocated per frame per call stack. Ten bytes per frame is fine. Forty kilobytes per frame will cause a spike within a minute. In Godot, the Monitor tab shows total allocations and the debugger’s memory panel breaks them down by type. For IL2CPP builds, use Unity’s Memory Profiler package which captures the Boehm heap directly.
The Usual Suspects in Update
Most GC spikes trace back to the same handful of patterns in Update loops. Knowing the list cuts diagnosis time in half.
// BAD: foreach over a List<T> allocated an enumerator every frame
foreach (var enemy in enemies) enemy.Tick();
// GOOD: for-loop with cached count
int count = enemies.Count;
for (int i = 0; i < count; i++) enemies[i].Tick();
// BAD: string concat generates garbage every frame
label.text = "HP: " + player.hp + "/" + player.maxHp;
// GOOD: reuse a StringBuilder
_sb.Clear();
_sb.Append("HP: ").Append(player.hp).Append('/').Append(player.maxHp);
label.SetText(_sb);
// BAD: closure captures variables, allocates delegate
Input.OnPressed(() => player.Jump(jumpHeight));
// GOOD: pre-allocate delegate once
_jumpCallback ??= () => _player.Jump(_jumpHeight);
Input.OnPressed(_jumpCallback);
LINQ is a particular trap. items.Where(x => x.active).Select(x => x.pos) looks concise but allocates several wrapper objects per call. Inside Update it is an instant GC spike source. Replace with explicit loops or Span<T> when the data layout allows.
Use Non-Alloc APIs Where They Exist
Engine APIs often ship in two flavors: one that allocates and one that does not. Physics.RaycastAll allocates a new array; Physics.RaycastNonAlloc fills a caller-provided buffer. GetComponents<T>() allocates; the overload that takes a List<T> does not. Adopt the non-alloc variants everywhere they are available.
private readonly RaycastHit[] _hits = new RaycastHit[16];
void DoRaycast() {
int n = Physics.RaycastNonAlloc(origin, dir, _hits, maxDist);
for (int i = 0; i < n; i++) {
HandleHit(_hits[i]);
}
}
Size buffers based on realistic maxima. A 16-slot buffer covers nearly every raycast in a typical game; rarely does one hit 40 colliders. If the buffer overflows, log a warning and grow it once; do not silently truncate.
Pool Things That Churn
Bullets, particles, damage numbers, projectile trails, and ephemeral UI widgets are prime pooling candidates. They are created and destroyed rapidly, they have uniform shape, and they do not need unique identity. Wrap them in a simple pool:
public class Pool<T> where T : class, new() {
private readonly Stack<T> _free = new();
private readonly Action<T> _reset;
public Pool(Action<T> reset, int prewarm = 32) {
_reset = reset;
for (int i = 0; i < prewarm; i++) _free.Push(new T());
}
public T Rent() => _free.Count > 0 ? _free.Pop() : new T();
public void Return(T item) { _reset(item); _free.Push(item); }
}
Reset on return, not on rent, so items sit in the pool in a clean state. A common bug is an item that retains its old subscription on a pooled event, causing the caller to receive callbacks after returning it. The reset action should null out references, cancel tweens, and disable any component that fires behind your back.
Godot and Native Scripting
Godot’s GDScript has its own GC characteristics. Allocations inside _process still churn memory; the fix is the same as in C#: pre-allocate arrays, reuse dictionaries, avoid str() concatenation on hot paths. For Godot C#, the same guidelines as Unity apply because both use .NET managed memory.
If a system is truly allocation-hostile — say, your particle pipeline — consider moving it to a native module via GDExtension. You pay a small integration cost but get full manual memory control, and your main thread never sees GC from that system.
“We spent two days convinced we had a render thread bug. Allocation profiling showed one line: a
FindObjectsOfTypeinside a UI update that allocated 400 KB per second. Cached the list on spawn and the 90 ms hitches vanished.”
Related Issues
For broader performance diagnostics, read how to debug render pipeline stalls. For frame-time monitoring, see how to debug streaming hitches in open-world games.
Turn on allocation profiling for a single gameplay minute. The first three call sites on the list are probably the cause of every stutter you have shipped.