If you’ve opened the Unity Profiler and seen those jagged green spikes under GC.Collect during your bullet-spawning or enemy-wave gameplay, you already know the problem. This guide walks you through exactly why those spikes happen, how object pooling eliminates them, and how to build a production-ready GenericObjectPool<T> in C# that you can drop into your project today.
Quick Answer: GC spikes in Unity happen because Instantiate and Destroy create and discard managed heap objects, building pressure until Unity’s garbage collector runs a stop-the-world collection. Object pooling replaces that cycle by reusing pre-instantiated GameObjects via a Stack<T> or Queue<T>. After pooling, GC allocation during gameplay drops to near zero for pooled object types, and those profiler spikes flatten out entirely.
Why GC Spikes Happen in Unity Games
Unity runs on the Mono runtime (or IL2CPP for compiled builds), and its garbage collector is a generational, stop-the-world collector. That last part is the problem. When the GC decides it’s time to collect, it pauses your game thread. Even a 2-4 millisecond pause on a 16ms frame budget is enough to drop a frame, and players feel it.
Every call to Instantiate allocates a new managed object on the heap. The GameObject itself, its components, and any backing data all land in managed memory. When you call Destroy, the object doesn’t get freed immediately. It gets marked for collection and sits there until the GC runs its next pass. In a top-down shooter where bullets fire at 10-20 per second and each lives for half a second, you’re constantly feeding the managed heap with short-lived allocations. That’s exactly the pattern that builds GC pressure fastest.
Open your Unity Profiler during a spawn-heavy gameplay session and look at the CPU Usage track. You’ll see the GC.Collect marker appear as a spike, often right after a burst of bullet creation. The GC Alloc column in the Hierarchy view shows per-frame allocation in bytes. Before pooling, that number climbs noticeably during active spawning. After pooling, it should read near zero for your pooled types.
Unity does offer an Incremental GC mode (enabled in Project Settings under Configuration) that spreads collection work across multiple frames instead of doing it all at once. This helps reduce the severity of individual spikes. It doesn’t eliminate allocation pressure, though. If you’re still calling Instantiate every frame, you’re still generating garbage for the GC to eventually collect. Pooling addresses the root cause; Incremental GC is a complementary setting, not a replacement.
The Core Idea Behind Object Pooling
Object pooling replaces the allocate-and-discard cycle with a reuse cycle. Instead of instantiating a bullet when the player fires and destroying it when it hits something, you pull an inactive bullet from a pre-warmed pool, activate it, use it, then deactivate it and return it to the pool. No new heap allocation. No garbage for the GC to collect.
The tradeoff is upfront memory cost at scene load. Your pool holds N inactive GameObjects in memory even when they’re not being used. For a bullet pool of 50 objects, that’s 50 GameObjects sitting deactivated in your scene. For most gameplay scenarios, that’s an acceptable cost. The question of how many to pre-allocate is something we’ll cover in the sizing section.
The core mechanism looks like this: at scene start, you instantiate your full pool and push all objects onto a Stack<T>. When you need an object, you pop from the stack, activate it, and hand it to the caller. When the caller is done, it calls Release(), the object gets deactivated, and it goes back on the stack. O(1) push and pop. No allocation during gameplay.
Building a Generic Object Pool in C#
The GenericObjectPool Class
This implementation uses a Stack<T> for O(1) get and return operations, accepts a factory function for creating new instances, and includes a pre-warm method you can call at scene load. The type parameter T is constrained to Component so you can pool any MonoBehaviour-based type directly.
using System;
using System.Collections.Generic;
using UnityEngine;
public class GenericObjectPool<T> where T : Component
{
private readonly Stack<T> _pool;
private readonly Func<T> _factory;
private readonly int _maxSize;
private int _activeCount;
public GenericObjectPool(Func<T> factory, int initialSize = 10, int maxSize = 100)
{
_factory = factory ?? throw new ArgumentNullException(nameof(factory));
_maxSize = maxSize;
_pool = new Stack<T>(initialSize);
PreWarm(initialSize);
}
private void PreWarm(int count)
{
for (int i = 0; i < count; i++)
{
T instance = _factory();
instance.gameObject.SetActive(false);
_pool.Push(instance);
}
}
public T Get()
{
T instance;
if (_pool.Count > 0)
{
instance = _pool.Pop();
}
else if (_activeCount < _maxSize)
{
// Dynamic expansion: rare, log it so you can tune initial size
Debug.LogWarning($"[ObjectPool] Expanding pool for {typeof(T).Name}. Consider increasing initial size.");
instance = _factory();
}
else
{
Debug.LogError($"[ObjectPool] Pool exhausted for {typeof(T).Name}. Returning null.");
return null;
}
instance.gameObject.SetActive(true);
_activeCount++;
return instance;
}
public void Release(T instance)
{
if (instance == null) return;
if (_pool.Count + _activeCount > _maxSize)
{
UnityEngine.Object.Destroy(instance.gameObject);
return;
}
instance.gameObject.SetActive(false);
_pool.Push(instance);
_activeCount--;
}
}
How to Implement Object Pooling in Unity: Step-by-Step
- Create a new C# script named
BulletPool.csand paste theGenericObjectPool<T>class into your project. - Add a
PoolManagerMonoBehaviour to a GameObject in your scene that holds a reference to your bullet prefab. - Initialize the pool in
Awake()using a factory lambda that callsObject.Instantiateon the prefab. - Replace every
Instantiate(bulletPrefab)call in your weapon script with_pool.Get(). - Replace every
Destroy(bullet)call in your bullet script with_pool.Release(this). - Configure initial size and max size based on your peak concurrent bullet count (more on sizing below).
- Run the Profiler again and compare GC Alloc per frame before and after the change.
Here’s what a PoolManager looks like wired up in practice, initializing a bullet pool at scene load.
public class BulletPoolManager : MonoBehaviour
{
[SerializeField] private Bullet _bulletPrefab;
[SerializeField] private int _initialPoolSize = 30;
[SerializeField] private int _maxPoolSize = 60;
public GenericObjectPool<Bullet> BulletPool { get; private set; }
private void Awake()
{
BulletPool = new GenericObjectPool<Bullet>(
() => Instantiate(_bulletPrefab),
_initialPoolSize,
_maxPoolSize
);
}
}
Handling Pool Overflow Without Allocating
What happens when your pool runs dry? This is where a lot of pooling tutorials go quiet, and it’s where real gameplay bugs come from. You have three options.
Dynamic expansion creates a new instance at runtime when the pool is empty. This does allocate, which defeats the purpose temporarily, but if it’s rare, it’s acceptable. The key is logging every expansion so you can tune your initial size to prevent it. The pool code above already does this with Debug.LogWarning. Watch your console during playtesting and eliminate those warnings by increasing your initial capacity.
Recycling the oldest active object works well for bullets and projectiles with short lifetimes. You maintain a secondary queue of active objects and pull the oldest one back when the pool is empty. This requires tracking active instances, which adds a small overhead, but it guarantees zero allocation during gameplay.
Denying the request returns null and skips the spawn entirely. For non-critical objects like particle effects or screen-shake instances, this is fine. For bullets, it’s not. Choose your overflow strategy based on how bad a missed spawn actually is for your gameplay.
The practical heuristic for pool sizing: profile your scene during peak gameplay and note the maximum concurrent active count for the object type. Set your initial pool size to 120% of that peak. This gives you a buffer for burst traffic without wasting memory on objects that will never be needed.
Resetting Object State on Return
Returned objects carry stale state. A bullet that just hit a wall still has its last velocity, its hit-flash animation might be mid-play, and any event subscriptions it made during its lifetime are still live. If you return it to the pool without resetting, the next caller gets a bullet that immediately behaves wrong.
The clean solution is an IPoolable interface with a ResetState() method. Every poolable MonoBehaviour implements it, and your pool calls it on release.
public interface IPoolable
{
void ResetState();
}
public class Bullet : MonoBehaviour, IPoolable
{
private Rigidbody2D _rb;
private void Awake() => _rb = GetComponent<Rigidbody2D>();
public void ResetState()
{
_rb.velocity = Vector2.zero;
_rb.angularVelocity = 0f;
transform.position = Vector3.zero;
StopAllCoroutines(); // Critical: kill any running coroutines
}
}
Watch out for lingering coroutines. StopAllCoroutines() in ResetState() is non-negotiable if your pooled objects start any. A coroutine that survives deactivation and reactivation will run twice on the next use. Event subscriptions are trickier. If your bullet subscribes to a game event in OnEnable(), make sure it unsubscribes in OnDisable(), which Unity calls automatically when you SetActive(false).
The double-return bug is worth calling out explicitly. If two systems both hold a reference to the same bullet and both call Release(), the object ends up in the pool twice. The next two Get() calls return the same instance, and you have two active bullets sharing one GameObject. Guard against this with a simple bool _isInPool flag on the poolable object, set to true on release and false on get.
Unity’s Built-In ObjectPool vs Rolling Your Own
| Approach | GC Allocation | Setup Complexity | Best Use Case | Unity Version |
|---|---|---|---|---|
| Instantiate / Destroy | High (per spawn) | None | Prototyping only | All versions |
| Hand-rolled GenericPool | Near zero (gameplay) | Medium | Custom overflow logic, editor tooling | All versions |
| Unity ObjectPool<T> | Near zero (gameplay) | Low | Standard pooling, clean callback API | 2021 LTS+ |
Unity’s Unity.Pool.ObjectPool<T>, available since Unity 2021 LTS, covers most common cases cleanly. It accepts four callbacks: onCreate, onGet, onRelease, and onDestroy, giving you full lifecycle control without writing the stack management yourself. If you’re on 2021 LTS or newer and your pooling needs are straightforward, use it.
Roll your own when you need custom overflow behavior, serialized pool configuration in the Inspector, or integration with editor tooling. The built-in pool doesn’t expose a max-size-with-recycle-oldest strategy, for example. It also doesn’t log expansion events out of the box, which matters during tuning.
Scene Transitions and Pool Lifetime
Pools attached to scene GameObjects get destroyed when the scene unloads. That’s usually correct behavior. Your bullet pool belongs to the gameplay scene, and when you load the main menu, you don’t need bullets in memory.
Persistent pools using DontDestroyOnLoad require explicit clearing when you transition scenes. If your pool holds references to scene-specific components (a reference to the player’s Transform, for example), those references become invalid after the scene unloads. The pool object survives, but the references inside it are now pointing at destroyed objects. This causes null reference exceptions that are genuinely hard to track down because they appear in the new scene, not the old one.
The safest pattern for persistent pools is to separate the pool manager from any scene-specific data. The pool knows how to create and recycle objects. It doesn’t hold references to scene components. If a pooled object needs scene-specific context, pass it in at Get() time, not at initialization.
Measuring the Impact: Before and After Profiling
Before you implement pooling, record a 30-second Profiler session during your most spawn-heavy gameplay moment. In the Profiler window, look at the CPU Usage module and filter by GC.Collect. Note the spike height and frequency. Switch to the Memory module and check the GC Alloc column in the Hierarchy view during the spawn burst.
After pooling, run the same session. GC Alloc for your pooled object types should drop to zero or near-zero per frame during gameplay. The GC.Collect marker should appear far less frequently, and when it does appear (because other systems still allocate), the spike should be smaller because you’ve reduced overall heap pressure.
Frame time consistency matters as much as average frame time. A game averaging 16ms per frame with occasional 25ms spikes feels worse than one averaging 17ms with no spikes. Look at the frame time histogram in the Profiler, not just the average. Pooling’s real win is flattening those spikes, and the histogram is where you see that most clearly.
FAQ: Object Pooling in Unity
Does Unity have a built-in object pool?
Yes. Unity.Pool.ObjectPool<T> ships with Unity 2021 LTS and later. It handles the stack management and lifecycle callbacks for you. For projects on older Unity versions, or when you need custom overflow handling, a hand-rolled implementation like the one in this article is the way to go.
When should I not use object pooling?
Don’t pool objects that are spawned infrequently (fewer than a few times per minute), have expensive initialization that can’t be reset cleanly, or are so large that holding idle instances in memory creates its own performance problem. Pooling adds complexity. If the GC pressure from a given object type isn’t showing up in your Profiler, you probably don’t need to pool it.
How do I size my object pool correctly?
Profile your scene during peak gameplay and find the maximum number of that object type active at the same time. Set your initial pool size to 120% of that number. This absorbs burst traffic without dynamic expansion. Monitor the expansion log warnings during playtesting and adjust upward if they appear regularly.
What’s the difference between pooling and caching in Unity?
Caching typically refers to storing the result of an expensive computation or lookup so you don’t repeat it. Pooling refers to reusing allocated objects so you avoid re-allocating them. They’re related ideas but solve different problems. You might cache a component reference with GetComponent and separately pool the GameObject that component lives on.
Does object pooling work with Unity’s Input System?
Yes, pooling is independent of the input system. The input system determines when a bullet fires; the pool determines how the bullet GameObject is created. The only thing to watch is that pooled objects which subscribe to input events need to unsubscribe when returned to the pool, following the same pattern as any other event subscription cleanup.
Object pooling won’t solve every performance problem in your Unity project, but for spawn-heavy gameplay systems, it’s one of the highest-value changes you can make. Profile first, implement the pool, profile again, and let the data tell the story. If you want the complete GenericObjectPool<T> as a ready-to-import Unity package with XML documentation and a pool sizing reference card, grab it from the link below. And if you’re ready to scale this pattern to thousands of entities using the Unity Job System, the follow-up article covers exactly that.
- How Hela Designs Co-op Around Exploration Instead of Combat - September 16, 2026
- Write Once, Click Never: Automating Repetitive Unity Tasks with Editor Scripts - September 13, 2026
- ScriptableObject Event Systems: Decoupling Unity C# Without Overengineering - September 13, 2026




