Quick answer: ObjectPool.Get() returns already-active objects when previous users did not properly release them, when double-release puts an active object back in the pool, or when actionOnGet does not reset the object state. Ensure every Get() has a matching Release(), enable collectionCheck to catch double-releases, and always reset object state in actionOnGet.

Here is how to fix Unity Object Pool returning objects that are still in use. You fire a bullet, it hits a wall, you release it to the pool. You fire again, the pool hands you the same bullet — but it is still visible at the wall impact point, playing its hit particle effect. Now you have one bullet in two places. Object pooling bugs like this are subtle because the pool is working correctly; the lifecycle management around it is not.

The Symptom

Objects obtained from a pool exhibit these problems:

The root cause is almost always a lifecycle mismatch between Get and Release calls.

Understanding Unity ObjectPool

Unity’s UnityEngine.Pool.ObjectPool<T> (available since Unity 2021.1) manages object reuse through four callbacks:

using UnityEngine.Pool;

ObjectPool<GameObject> pool = new ObjectPool<GameObject>(
    createFunc:    () => Instantiate(prefab),        // called when pool is empty
    actionOnGet:   (obj) => obj.SetActive(true),     // called on Get()
    actionOnRelease: (obj) => obj.SetActive(false),  // called on Release()
    actionOnDestroy: (obj) => Destroy(obj),          // called when pool exceeds maxSize
    collectionCheck: true,                            // throw on double-release
    defaultCapacity: 10,
    maxSize: 100
);

The pool is a stack. Release pushes, Get pops. If the lifecycle callbacks do not properly activate/deactivate objects, you get visible objects in the “available” stack.

Fix 1: Always Deactivate on Release

The most common bug is a missing or incomplete actionOnRelease. If you do not deactivate the GameObject, it stays visible in the scene while the pool considers it available.

// WRONG: no deactivation on release
actionOnRelease: (obj) => { } // object stays active and visible

// RIGHT: deactivate and reset
actionOnRelease: (obj) =>
{
    obj.SetActive(false);
    obj.transform.SetParent(poolContainer); // move to pool hierarchy
}

Also reset any state that should not persist: stop particle systems, reset velocity on Rigidbodies, clear damage counters, cancel coroutines.

Fix 2: Reset State on Get

Even with proper release, always reset the object in actionOnGet to guarantee a clean state:

actionOnGet: (obj) =>
{
    obj.SetActive(true);
    obj.transform.position = Vector3.zero;
    obj.transform.rotation = Quaternion.identity;

    Rigidbody rb = obj.GetComponent<Rigidbody>();
    if (rb != null)
    {
        rb.linearVelocity = Vector3.zero;
        rb.angularVelocity = Vector3.zero;
    }

    // Reset any component-specific state
    Bullet bullet = obj.GetComponent<Bullet>();
    if (bullet != null) bullet.Reset();
}

Defensive resets in actionOnGet protect against incomplete actionOnRelease implementations and future code changes that add new state to the pooled object.

Fix 3: Prevent Double-Release

Double-releasing an object puts it in the pool twice. The next two Get() calls return the same object. The second caller overwrites the first caller’s state. This is the hardest pool bug to diagnose because the symptoms appear far from the cause.

// Common double-release scenario
void OnCollisionEnter(Collision collision)
{
    pool.Release(gameObject); // released on collision
}

void OnLifetimeExpired()
{
    pool.Release(gameObject); // released again on timeout — DOUBLE RELEASE
}

Fix: Track whether the object has been released with a flag, or use the built-in collectionCheck parameter:

private bool _isReleased;

public void ReturnToPool()
{
    if (_isReleased) return; // already released, skip
    _isReleased = true;
    pool.Release(gameObject);
}

public void OnGetFromPool()
{
    _isReleased = false; // reset flag when taken from pool
}

Fix 4: Handle maxSize Eviction

When Release() is called and the pool is at maxSize, the object is passed to actionOnDestroy instead of being stored. If actionOnDestroy is null or does not call Destroy(), the object becomes a leaked GameObject — deactivated but never destroyed, accumulating in memory.

// Always provide actionOnDestroy
actionOnDestroy: (obj) => Destroy(obj)

Set maxSize based on your expected peak usage. If you expect at most 50 bullets on screen, a maxSize of 60–80 gives headroom without unbounded growth.

Debugging Pool Issues

Add logging to your pool callbacks to trace the lifecycle:

var pool = new ObjectPool<GameObject>(
    createFunc: () => {
        var obj = Instantiate(prefab);
        Debug.Log($"[Pool] Created {obj.GetInstanceID()}");
        return obj;
    },
    actionOnGet: (obj) => {
        Debug.Log($"[Pool] Get {obj.GetInstanceID()}, active: {pool.CountActive}");
        obj.SetActive(true);
    },
    actionOnRelease: (obj) => {
        Debug.Log($"[Pool] Release {obj.GetInstanceID()}, inactive: {pool.CountInactive}");
        obj.SetActive(false);
    },
    actionOnDestroy: (obj) => {
        Debug.Log($"[Pool] Destroy {obj.GetInstanceID()} (maxSize reached)");
        Destroy(obj);
    },
    collectionCheck: true,
    maxSize: 50
);

Watch for Get without a subsequent Release (leak), two Releases for one Get (double-release), or Get returning an ID that was never Released (pool corruption).

“Every Get must have exactly one Release. More than one and you corrupt the pool. Fewer than one and you leak objects. Track the lifecycle like you track memory.”

Related Issues

For pooled objects losing their references after scene loads, note that pools do not survive scene transitions unless they are on a DontDestroyOnLoad object. For pooled particle systems not replaying, call ParticleSystem.Clear() and Play() in actionOnGet. For coroutines continuing on released objects, call StopAllCoroutines() in actionOnRelease.

Deactivate on Release. Reset on Get. Guard against double-release. Always provide actionOnDestroy. One Get, one Release.