What Is It?
Every Instantiate allocates managed and native memory, runs Awake/OnEnable, and every Destroy leaves garbage behind. Do that for twenty bullets a second and the garbage collector eventually wakes up mid-fight and freezes the frame — the dreaded GC spike.
An object pool pre-creates (or lazily creates) instances and keeps the inactive ones on a shelf. "Spawning" becomes pool.Get() — reactivate, reposition, go. "Destroying" becomes pool.Release(obj) — deactivate and put it back. After warm-up, the steady state allocates nothing.
Since Unity 2021 the engine ships UnityEngine.Pool.ObjectPool<T>, which takes four callbacks (create, on-get, on-release, on-destroy) plus a default capacity and max size. A turret, a particle burst, floating damage numbers or enemy waves can all be pooled with a dozen lines.
When Is It Used?
Use it for anything spawned and despawned frequently: projectiles, hit effects, pickups, audio one-shots, UI list items, enemies in wave shooters.
It matters most on mobile and consoles, where GC pauses and instantiate cost are most visible, and for objects with expensive setup (many components, large meshes).
Do not pool objects that are created once per level, or whose state is hard to reset cleanly. A pool that hands out half-reset objects causes bugs that are worse than the allocation it saved.
Interactive Demo
Click the controls and watch the objects collaborate. The console mirrors what the C# code below would log.
Code
using UnityEngine;
namespace Patterns.Optimization.ObjectPooling
{
/// <summary>
/// Fires bullets on a timer. It asks the pool for bullets instead of
/// calling Instantiate, so firing allocates nothing after warm-up.
/// </summary>
public class Turret : MonoBehaviour
{
[SerializeField] private BulletPool pool;
[SerializeField] private Transform muzzle;
[SerializeField] private float fireRate = 8f;
[SerializeField] private float bulletSpeed = 25f;
private float cooldown;
private void Update()
{
cooldown -= Time.deltaTime;
if (cooldown > 0f || !Input.GetButton("Fire1"))
return;
cooldown = 1f / fireRate;
Fire();
}
private void Fire()
{
Bullet bullet = pool.Get();
bullet.transform.SetPositionAndRotation(muzzle.position, muzzle.rotation);
bullet.Launch(muzzle.forward * bulletSpeed);
}
}
}using UnityEngine;
using UnityEngine.Pool;
namespace Patterns.Optimization.ObjectPooling
{
/// <summary>
/// Wraps Unity's built-in ObjectPool<T> for bullets and pre-warms
/// it so the first volley does not hitch.
/// </summary>
public class BulletPool : MonoBehaviour
{
[SerializeField] private Bullet prefab;
[SerializeField] private int defaultCapacity = 20;
[SerializeField] private int maxSize = 100;
[SerializeField] private int prewarm = 20;
private ObjectPool<Bullet> pool;
private void Awake()
{
pool = new ObjectPool<Bullet>(
createFunc: () =>
{
Bullet b = Instantiate(prefab, transform);
b.SetPool(pool);
return b;
},
actionOnGet: b => b.gameObject.SetActive(true),
actionOnRelease: b => b.gameObject.SetActive(false),
actionOnDestroy: b => Destroy(b.gameObject),
collectionCheck: Application.isEditor,
defaultCapacity: defaultCapacity,
maxSize: maxSize);
// Create everything now, during loading, then shelve it.
var warm = new Bullet[prewarm];
for (int i = 0; i < prewarm; i++) warm[i] = pool.Get();
for (int i = 0; i < prewarm; i++) pool.Release(warm[i]);
}
public Bullet Get() => pool.Get();
public int CountActive => pool.CountActive;
public int CountInactive => pool.CountInactive;
}
}using UnityEngine;
using UnityEngine.Pool;
namespace Patterns.Optimization.ObjectPooling
{
/// <summary>
/// A pooled projectile. Instead of destroying itself, it releases
/// back to the pool on hit or when its lifetime runs out.
/// </summary>
[RequireComponent(typeof(Rigidbody))]
public class Bullet : MonoBehaviour
{
[SerializeField] private float lifetime = 2f;
private IObjectPool<Bullet> pool;
private Rigidbody body;
private float timer;
private bool released;
private void Awake() => body = GetComponent<Rigidbody>();
public void SetPool(IObjectPool<Bullet> owner) => pool = owner;
public void Launch(Vector3 velocity)
{
// Reset ALL state: this object has been used before.
released = false;
timer = lifetime;
body.linearVelocity = velocity;
body.angularVelocity = Vector3.zero;
}
private void Update()
{
timer -= Time.deltaTime;
if (timer <= 0f) Release();
}
private void OnCollisionEnter(Collision collision) => Release();
private void Release()
{
if (released) return; // guard against double release
released = true;
pool.Release(this);
}
}
}Advantages & Disadvantages
+ Advantages
- Eliminates per-spawn allocation, so the GC stays quiet during gameplay.
- Spawning is just
SetActive(true)— much cheaper thanInstantiate. - Pre-warming moves the creation cost to a loading screen where nobody notices.
− Disadvantages
- Pooled objects hold memory even when unused; an oversized pool wastes RAM.
- Every object must fully reset its state on reuse — velocity, health, trails, coroutines.
- Releasing the same object twice, or using it after release, causes confusing bugs.
Tips
- 01Prefer the built-in
ObjectPool<T>withcollectionCheck: truein development; it throws on double release. - 02Reset state in the on-get callback (or an
OnEnable), not in the object's constructor-likeAwake, sinceAwakeonly runs once. - 03Clear
TrailRenderers withClear()and stop particle systems when releasing, or ghosts appear on the next spawn. - 04Give the pooled object a reference back to its pool (or an
Action<T>release callback) so it can return itself on hit or timeout. - 05Profile with the Memory Profiler and the GC Alloc column in the CPU Profiler to confirm the pool actually removed allocations.