Object Pooling in Unity with ObjectPool<T>: A Practical Guide

Illustration of reusable projectile capsules cycling back into a pool tray

Bullets, hit effects, enemies, damage numbers, coins: anything a game spawns constantly is a candidate for object pooling. Calling Instantiate and Destroy hundreds of times a second costs CPU time and creates garbage that the garbage collector later cleans up in a visible frame spike. A pool creates objects once and reuses them. Unity has shipped a built-in generic pool, UnityEngine.Pool.ObjectPool<T>, since 2021, so you don’t need to write your own. This guide shows how to use it properly: a bullet pool, a multi-prefab pool manager, resetting state, and the mistakes that cause the classic pooling bugs.

Why Pool at All?

Each Instantiate call allocates memory, builds the object’s components and runs Awake and OnEnable. Each Destroy leaves memory for the garbage collector. One or two per second is nothing. A minigun firing 30 bullets a second, each with a muzzle flash and an impact effect, is 90 of each per second, and it shows up in two ways:

  • Steady CPU cost from creating and destroying objects every frame.
  • GC spikes: every so often the garbage collector runs and a frame takes noticeably longer. On mobile and in WebGL builds this is a visible hitch.

With a pool, “destroying” a bullet just deactivates it and puts it back in a stack, and “spawning” one takes it out and reactivates it. After the first few seconds of play, nothing is allocated at all.

Always confirm with the Profiler before and after. In Window > Analysis > Profiler, the CPU module’s Hierarchy view has a GC Alloc column. Spawning code that allocates every frame is exactly what pooling fixes.

The ObjectPool<T> API

ObjectPool<T> lives in the UnityEngine.Pool namespace. You don’t subclass it; you hand it functions that say how to create, get, release and destroy an item:

var pool = new ObjectPool<T>(
    createFunc,          // () => T      make a new item when the pool is empty
    actionOnGet,         // (T) => void  called when an item is taken out
    actionOnRelease,     // (T) => void  called when an item is returned
    actionOnDestroy,     // (T) => void  called when the pool discards an item
    collectionCheck,     // bool, default true: detect double releases
    defaultCapacity,     // int, default 10: initial size of the internal stack
    maxSize);            // int, default 10000: max items kept in the pool

The main members are:

  • Get() returns a pooled item, calling createFunc first if none are free. There’s also an overload, Get(out PooledObject<T>), that returns a handle for using blocks.
  • Release(item) returns an item to the pool.
  • CountActive, CountInactive and CountAll tell you how many items are in use, waiting, and in total. They’re great for a debug overlay.
  • Clear() and Dispose() destroy all inactive items.

Two details matter. maxSize limits the number of inactive items kept, not the number you can have. Get() always succeeds; if you release an item while the pool already holds maxSize inactive items, that item is passed to actionOnDestroy instead of being stored. And the pool isn’t thread-safe, which doesn’t matter for normal game code since Unity objects must be used from the main thread anyway.

Example: A Bullet Pool

The gun owns the pool. Each bullet gets a reference to the pool it came from so it can release itself when it hits something or times out:

using UnityEngine;
using UnityEngine.Pool;

public class Gun : MonoBehaviour
{
    [SerializeField] Bullet bulletPrefab;
    [SerializeField] Transform muzzle;
    [SerializeField] float fireRate = 12f;

    ObjectPool<Bullet> pool;
    float nextShot;

    void Awake()
    {
        pool = new ObjectPool<Bullet>(
            createFunc: () =>
            {
                Bullet b = Instantiate(bulletPrefab);
                b.SetPool(pool);
                return b;
            },
            actionOnGet: b => b.gameObject.SetActive(true),
            actionOnRelease: b => b.gameObject.SetActive(false),
            actionOnDestroy: b => Destroy(b.gameObject),
            collectionCheck: true,
            defaultCapacity: 32,
            maxSize: 200);
    }

    public void TryFire()
    {
        if (Time.time < nextShot) return;
        nextShot = Time.time + 1f / fireRate;

        Bullet b = pool.Get();
        b.Launch(muzzle.position, muzzle.forward);
    }
}
using UnityEngine;
using UnityEngine.Pool;

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

    IObjectPool<Bullet> pool;
    Rigidbody rb;
    float despawnAt;
    bool released;

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

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

    public void Launch(Vector3 position, Vector3 direction)
    {
        released = false;
        transform.SetPositionAndRotation(position, Quaternion.LookRotation(direction));
        rb.linearVelocity = direction * speed;
        rb.angularVelocity = Vector3.zero;
        despawnAt = Time.time + lifetime;
    }

    void Update()
    {
        if (Time.time >= despawnAt) Despawn();
    }

    void OnCollisionEnter(Collision collision)
    {
        // deal damage, spawn an impact effect from its own pool, etc.
        Despawn();
    }

    void Despawn()
    {
        if (released) return;   // hit something on the same frame it timed out
        released = true;
        pool.Release(this);
    }
}

Note the released flag. A bullet can time out and collide in the same frame, or hit two colliders at once. Without the guard, it gets released twice, which is the most common pooling bug (see troubleshooting). Storing the pool as IObjectPool<Bullet> lets you swap in a different pool type later without touching the bullet. In Unity 6, Rigidbody.velocity is named linearVelocity; use velocity on older versions.

Resetting Pooled Objects

A reused object keeps all the state it had when it was released. This causes the bugs that make people think pooling is “buggy”: enemies spawning with 0 health, bullets flying off at their old speed, trails streaking across the screen. Reset every piece of state that matters, either in actionOnGet or in a method like Launch above. A checklist:

  • Rigidbody: reset linearVelocity and angularVelocity. Setting transform.position on a Rigidbody object is fine when it’s activated, but use rb.position if it’s already active.
  • Health, timers, flags and any “is dead” state.
  • TrailRenderer: call Clear() after moving it, or it draws a line from the old position.
  • ParticleSystem: call Clear() and Play(). For effects, release the object when the effect has finished, for example with the Particle System’s Stop Action set to Callback and releasing in OnParticleSystemStopped().
  • Animator: call Rebind() or play the start state, since it may resume mid-animation.
  • Coroutines stop when an object is deactivated. Restart any you need in OnEnable.
  • Event subscriptions: subscribe in OnEnable and unsubscribe in OnDisable, so inactive pooled objects don’t keep reacting to events.

A good habit: put per-life initialisation in OnEnable and one-time setup (caching components) in Awake. Awake runs only once per pooled object; OnEnable runs every time it comes out of the pool.

📊 How much frame time can you actually spend?

Pooling saves milliseconds, but how many do you need? Our free calculator splits your frame budget at 30, 60 or 120 FPS across scripts, rendering, physics and more, so you know whether spawning is really the bottleneck.

Open the Performance Budget Calculator →

Prewarming and Sizing the Pool

A pool starts empty, so the first burst of shooting still calls Instantiate. To move that cost into a loading screen, prewarm the pool: get the number of items you expect to need, then release them all.

void Prewarm(int count)
{
    var temp = new List<Bullet>(count);
    for (int i = 0; i < count; i++) temp.Add(pool.Get());
    foreach (var b in temp) pool.Release(b);
}

To size it, log pool.CountAll during a heavy fight. That’s your peak. Set defaultCapacity close to your normal usage and maxSize a little above the peak, so an unusual burst creates a few extra objects that get destroyed afterwards instead of staying in memory forever.

A Pool Manager for Many Prefabs

With a dozen effects and projectiles, one pool per script gets repetitive. A small manager keeps one pool per prefab and hands out instances by prefab reference:

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

public class PoolManager : MonoBehaviour
{
    public static PoolManager Instance { get; private set; }

    readonly Dictionary<GameObject, ObjectPool<GameObject>> pools = new();
    readonly Dictionary<GameObject, ObjectPool<GameObject>> owners = 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),
                go => go.SetActive(true),
                go => go.SetActive(false),
                go => Destroy(go),
                true, 16, 256);
            pools[prefab] = pool;
        }

        GameObject obj = pool.Get();
        obj.transform.SetPositionAndRotation(pos, rot);
        owners[obj] = pool;
        return obj;
    }

    public void Despawn(GameObject obj)
    {
        if (owners.TryGetValue(obj, out var pool)) pool.Release(obj);
        else Destroy(obj);   // wasn't pooled
    }
}

Replace Instantiate(effectPrefab, pos, rot) with PoolManager.Instance.Spawn(effectPrefab, pos, rot) and Destroy(gameObject) with PoolManager.Instance.Despawn(gameObject). Because pooled objects are children of the manager, they’re cleaned up with it when the scene unloads.

Pooling Lists and Other Collections

Garbage doesn’t only come from GameObjects. A method that creates a new List<Collider>() every frame allocates too. UnityEngine.Pool includes ListPool, HashSetPool, DictionaryPool and GenericPool for exactly this:

using (ListPool<Collider>.Get(out List<Collider> hits))
{
    // fill and use the list; it's cleared and returned at the end of the block
}

Combined with the non-allocating physics queries such as Physics.OverlapSphereNonAlloc, this keeps hot code paths allocation-free. Performance also depends on project-wide settings such as the scripting backend and the incremental garbage collector; our Unity project settings checklist covers those.

When Not to Pool

Pooling adds complexity and bugs if it isn’t needed. Skip it for:

  • Objects spawned rarely: a boss, a level’s props, UI screens.
  • Objects that live for the whole level anyway.
  • Prototypes. Write Instantiate/Destroy first, profile, and pool what shows up. Because the Spawn/Despawn calls above mirror Instantiate/Destroy, switching later is quick.

Pool the things that are spawned many times per second or in bursts: projectiles, impact and muzzle effects, pickups, damage numbers, audio sources for one-shot sounds, and waves of simple enemies.

Troubleshooting

InvalidOperationException: Trying to release an object that has already been released to the pool.

The same item was released twice. collectionCheck caught it, which is why it’s on by default. Typical causes: a collision and a timeout on the same frame, OnTriggerEnter firing for two colliders, or both the bullet and the thing it hit releasing it. Add a released flag as in the bullet example, and make one object responsible for releasing. You can turn collectionCheck off in release builds for speed once the bug is gone, but don’t turn it off to hide the error, because a double-released object would be handed out twice.

MissingReferenceException: The object of type ‘GameObject’ has been destroyed but you are still trying to access it.

The pool still holds objects that Unity destroyed, usually because a scene was unloaded and the pool lives in a static field or a DontDestroyOnLoad object while its items belonged to the old scene. Either create pools per scene (as a scene object that’s destroyed with the scene), or parent pooled items under a DontDestroyOnLoad object so they survive too. Never call Destroy on a pooled object directly; release it instead.

Reused objects keep their old state

Something isn’t reset when the object comes out of the pool. Go through the reset checklist. Remember that Awake and Start only run for a pooled object’s first life.

Pooling didn’t improve performance

Spawning wasn’t the bottleneck, or the pool is too small and keeps creating and destroying extras. Check CountAll against maxSize, and profile again. The spike may come from something else, such as physics or rendering.

Gear note: object pooling is one chapter of Game Programming Patterns by Robert Nystrom, which explains the reasoning behind this and a dozen other patterns you will meet in Unity. See our game dev book guide for more. (Amazon link; we may earn a commission.)

FAQ

Does Unity have a built-in object pool?

Yes. UnityEngine.Pool.ObjectPool<T> has been built in since Unity 2021, along with ListPool, HashSetPool, DictionaryPool and GenericPool.

What does maxSize do in ObjectPool?

It’s the maximum number of inactive items the pool keeps. Get always succeeds; items released beyond maxSize are passed to actionOnDestroy instead of being stored.

Is object pooling still worth it in Unity 6?

For objects spawned many times per second, yes. It removes Instantiate/Destroy cost and garbage collection spikes. For rarely spawned objects, it isn’t worth the extra complexity.

Why do my pooled objects keep old values?

Reused objects keep their state. Reset velocity, health, timers, trails and particles when an object is taken from the pool, and use OnEnable for per-life setup.

Should I disable collectionCheck?

Leave it on during development so double releases throw an error. You can disable it in shipping builds for a small speed gain once your code is correct.

Is ObjectPool thread-safe?

No. Use it from the main thread, which is where Unity objects must be used anyway.

Comments

One response to “Object Pooling in Unity with ObjectPool<T>: A Practical Guide”

  1. […] creates garbage. Keep one instance and use Emit with overridden positions, or pool the effects (our ObjectPool guide shows […]

Leave a Reply

Your email address will not be published. Required fields are marked *