Object Pooling

Object pooling means reusing objects instead of creating and destroying them. When a bullet "dies", you switch it off and put it back in a pool; the next shot takes it out and switches it on again. It removes the cost of Instantiate/Destroy and the garbage-collection stutters they cause.

Shoes at a bowling alley. The alley doesn't make a new pair for every customer and throw them away afterwards. It keeps a shelf of shoes: hand a pair out, get it back, spray it clean (reset), and put it back on the shelf. When it's busy and the shelf is empty, it buys a few more pairs.

When to pool

Pool itDon't bother
Bullets, shell casings, projectilesThe player
Hit effects, particles, decalsA boss spawned once per level
Damage numbers, floating textUI screens opened occasionally
Enemies in wave-based gamesAnything created a few times per minute
Audio one-shot sources

Rule of thumb: pool things created many times per second, or in bursts large enough to show a spike in the Profiler.

The lifecycle

Instantiate() a new oneonly when the pool is emptyPoolinactive objectsIn the gameactive objectsGet(): SetActive(true)Release(): reset, deactivatereleased when already at maxSizeDestroy()
In steady play only the highlighted loop runs: Get and Release just switch objects on and off. Instantiate and Destroy (dashed) only happen at the edges, when the pool runs dry or overflows.

Unity's built-in ObjectPool<T>

Namespace UnityEngine.Pool (Unity 2021.1+).

Constructor parameterMeaning
createFuncHow to make a new one (usually Instantiate)
actionOnGetRuns when taken out (usually SetActive(true))
actionOnReleaseRuns when returned (usually SetActive(false))
actionOnDestroyRuns when the pool is full and throws one away, or on Clear()
collectionCheckIf true, throws an error if you release the same object twice (checked in the Editor). Keep it on while developing.
defaultCapacityStarting size of the internal storage
maxSizeMax objects kept inactive. Extras released beyond this are destroyed.
MemberDescription
Get()Take one out (creates one if the pool is empty)
Release(obj)Give one back
CountActive / CountInactive / CountAllHow many are out / waiting / total
Clear()Destroy all inactive ones

Full example: pooled bullets

using UnityEngine;
using UnityEngine.Pool;

public class Gun : MonoBehaviour
{
    [SerializeField] private Bullet bulletPrefab;
    [SerializeField] private Transform muzzle;
    [SerializeField] private int prewarm = 30;

    private ObjectPool<Bullet> pool;

    void Awake()
    {
        pool = new ObjectPool<Bullet>(
            createFunc: () =>
            {
                Bullet b = Instantiate(bulletPrefab);
                b.SetPool(pool);          // the bullet needs to know where to return
                return b;
            },
            actionOnGet:     b => b.gameObject.SetActive(true),
            actionOnRelease: b => b.gameObject.SetActive(false),
            actionOnDestroy: b => Destroy(b.gameObject),
            collectionCheck: true,
            defaultCapacity: prewarm,
            maxSize: 200);

        Prewarm();
    }

    // Create objects during loading, not in the middle of a fight
    void Prewarm()
    {
        var temp = new Bullet[prewarm];
        for (int i = 0; i < prewarm; i++) temp[i] = pool.Get();
        for (int i = 0; i < prewarm; i++) pool.Release(temp[i]);
    }

    public void Fire()
    {
        Bullet b = pool.Get();
        b.Launch(muzzle.position, muzzle.rotation);
    }
}
using UnityEngine;
using UnityEngine.Pool;

[RequireComponent(typeof(Rigidbody))]
public class Bullet : MonoBehaviour
{
    [SerializeField] private float speed = 40f;
    [SerializeField] private float lifetime = 3f;
    [SerializeField] private TrailRenderer trail;

    private IObjectPool<Bullet> pool;
    private Rigidbody rb;
    private float dieAt;
    private bool released;

    void Awake() { rb = GetComponent<Rigidbody>(); }

    public void SetPool(IObjectPool<Bullet> p) { pool = p; }

    // Everything that must be "fresh" is reset here, NOT in Start
    public void Launch(Vector3 pos, Quaternion rot)
    {
        released = false;
        transform.SetPositionAndRotation(pos, rot);
        rb.position = pos;                  // keep physics in sync with the teleport
        rb.linearVelocity = transform.forward * speed;
        rb.angularVelocity = Vector3.zero;
        if (trail) trail.Clear();           // otherwise a streak is drawn from the old position
        dieAt = Time.time + lifetime;
    }

    void Update()
    {
        if (Time.time >= dieAt) ReturnToPool();
    }

    void OnCollisionEnter(Collision c)
    {
        if (c.collider.TryGetComponent(out IDamageable d)) d.TakeDamage(10f);
        ReturnToPool();
    }

    private void ReturnToPool()
    {
        if (released) return;   // guard: hit something AND timed out on the same frame
        released = true;
        pool.Release(this);     // instead of Destroy(gameObject)
    }
}

What happens at runtime

  1. At load, Prewarm creates 30 bullets (running their Awake), then deactivates them. All the expensive work is done up front.
  2. Fire() takes an inactive bullet from the pool's internal stack. No Instantiate, no memory allocation. SetActive(true) runs OnEnable.
  3. On hit or timeout, the bullet deactivates itself (OnDisable runs) and goes back on the stack.
  4. If 31 bullets are needed at once, the pool creates one more. Over time the pool grows to the peak you actually need and then stops allocating.

Pooling particle effects

Set the ParticleSystem's Stop Action to Callback (Main module). Unity then calls OnParticleSystemStopped on scripts on the same GameObject when it finishes.

using UnityEngine;
using UnityEngine.Pool;

[RequireComponent(typeof(ParticleSystem))]
public class PooledEffect : MonoBehaviour
{
    public IObjectPool<PooledEffect> Pool { get; set; }
    private ParticleSystem ps;

    void Awake()
    {
        ps = GetComponent<ParticleSystem>();
        var main = ps.main;
        main.stopAction = ParticleSystemStopAction.Callback;
    }

    public void PlayAt(Vector3 pos)
    {
        transform.position = pos;
        ps.Play();
    }

    void OnParticleSystemStopped() { Pool.Release(this); }
}

One pool per prefab

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Pool;

public class PoolManager : MonoBehaviour
{
    public static PoolManager Instance { get; private set; }
    private readonly Dictionary<GameObject, ObjectPool<GameObject>> pools = new();
    private readonly Dictionary<GameObject, GameObject> instanceToPrefab = new();

    void Awake() { Instance = this; }

    public GameObject Spawn(GameObject prefab, Vector3 pos, Quaternion rot)
    {
        if (!pools.TryGetValue(prefab, out var pool))
        {
            pool = new ObjectPool<GameObject>(
                () => Instantiate(prefab, transform),   // parent under the manager: tidy Hierarchy
                go => go.SetActive(true),
                go => go.SetActive(false),
                go => Destroy(go));
            pools[prefab] = pool;
        }
        GameObject obj = pool.Get();
        obj.transform.SetPositionAndRotation(pos, rot);
        instanceToPrefab[obj] = prefab;
        return obj;
    }

    public void Despawn(GameObject obj)
    {
        if (instanceToPrefab.TryGetValue(obj, out var prefab)) pools[prefab].Release(obj);
        else Destroy(obj); // wasn't pooled
    }
}

Pooling temporary collections

UnityEngine.Pool also pools lists and sets, to avoid garbage from temporary collections:

using (ListPool<Collider>.Get(out List<Collider> nearby))
{
    GetComponentsInChildren(nearby);
    // use the list...
} // automatically returned to the pool here

Also available: HashSetPool<T>, DictionaryPool<K,V>, GenericPool<T> for plain C# classes.

Common Mistakes

See also