Quick answer: Pooled objects retain all their state from previous use. You must explicitly reset every mutable field — velocity, position, active effects, animation state, coroutines — when an object is retrieved from the pool. Use Unity’s built-in ObjectPool<T> with OnGet and OnRelease callbacks to enforce consistent resets.
Object pooling is one of the first performance optimizations most Unity developers learn. Instead of calling Instantiate and Destroy every time a bullet fires or an enemy spawns, you recycle existing GameObjects from a pool. It eliminates GC spikes and reduces frame hitches. But pooling introduces a class of bugs that are genuinely difficult to diagnose: objects that come back from the pool carrying state from their previous life.
The Symptoms
Stale pool state manifests in ways that can look like completely unrelated bugs:
- Bullets that curve. A projectile retrieved from the pool still has angular velocity from its last use, so it spirals instead of flying straight.
- Enemies that spawn dead. The health field was set to zero when the enemy died, and nobody reset it before the next spawn.
- Particle effects playing on spawn. A hit-flash particle system was still set to
isPlaying = truefrom the last impact, so the object appears with a flash the instant it’s retrieved. - Audio playing from nowhere. An AudioSource on the pooled object was mid-clip when it was released, and it resumes the moment the object is re-activated.
- Coroutine overlap. A coroutine that was running on the object when it was released continues executing after the object is pulled from the pool again, causing doubled behavior.
These bugs are maddening because they are intermittent. They depend on what the previous user of that particular pool slot did with the object.
The Root Cause
When you deactivate a GameObject and store it in a pool, Unity does not reset anything. The Rigidbody keeps its velocity. The Animator stays on whatever state it was in. Particle systems retain their emission state. Every field on every component remains exactly as it was. The pool is not a factory — it is a shelf, and whatever you put on the shelf is what you get back.
Most custom pool implementations look something like this:
// Naive pool - no reset logic
public GameObject Get()
{
if (_pool.Count > 0)
{
GameObject obj = _pool.Dequeue();
obj.SetActive(true);
return obj; // Still has old state!
}
return Instantiate(_prefab);
}
public void Release(GameObject obj)
{
obj.SetActive(false);
_pool.Enqueue(obj);
}
The problem is clear: Get hands back an object with whatever state it accumulated during its last lifetime, and Release just hides it without cleaning up.
The Fix: IPoolable Interface + Callbacks
The cleanest pattern is to define a reset contract that every poolable object must implement:
public interface IPoolable
{
void OnGetFromPool();
void OnReturnToPool();
}
public class Projectile : MonoBehaviour, IPoolable
{
private Rigidbody _rb;
private TrailRenderer _trail;
private ParticleSystem _impactFx;
public void OnGetFromPool()
{
// Reset physics
_rb.linearVelocity = Vector3.zero;
_rb.angularVelocity = Vector3.zero;
// Clear visual artifacts
_trail.Clear();
_impactFx.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
// Reset game state
_hasHit = false;
_lifetime = 0f;
}
public void OnReturnToPool()
{
// Stop any running coroutines
StopAllCoroutines();
// Ensure no lingering physics forces
_rb.linearVelocity = Vector3.zero;
_rb.angularVelocity = Vector3.zero;
}
}
Using Unity’s Built-in ObjectPool<T>
Since Unity 2021.1, the UnityEngine.Pool namespace provides ObjectPool<T>, which has built-in callback hooks for exactly this purpose. Here is how to wire it up:
using UnityEngine.Pool;
public class ProjectilePool : MonoBehaviour
{
[SerializeField] private Projectile _prefab;
private ObjectPool<Projectile> _pool;
void Awake()
{
_pool = new ObjectPool<Projectile>(
createFunc: () => Instantiate(_prefab),
actionOnGet: p => { p.gameObject.SetActive(true); p.OnGetFromPool(); },
actionOnRelease: p => { p.OnReturnToPool(); p.gameObject.SetActive(false); },
actionOnDestroy: p => Destroy(p.gameObject),
defaultCapacity: 20,
maxSize: 100
);
}
public Projectile Get() => _pool.Get();
public void Release(Projectile p) => _pool.Release(p);
}
The key insight is the ordering in actionOnRelease: call your cleanup logic before deactivating the object. Some cleanup operations (like stopping particle systems) behave differently on inactive GameObjects.
The Reset Checklist
Every poolable object should reset at minimum:
- Transform: position, rotation, scale (if modified at runtime), parent
- Rigidbody: linearVelocity, angularVelocity, isKinematic if toggled, constraints if changed
- Animator: call
Rebind()to reset all parameters and states, or usePlay("Idle", 0, 0f) - Particle systems: call
Stop(true, StopEmittingAndClear)on release, orClear()on get - Trail renderers: call
Clear()on get — doing it on release doesn’t work because the trail clears relative to the current position - Audio sources: call
Stop()on release - Coroutines: call
StopAllCoroutines()on release - Custom fields: health, ammo, status effects, timers, hit flags — anything your game logic writes to
Common Pitfall: TrailRenderer Ghosts
Trail renderers deserve special mention because they are the most visibly broken component in pooled objects. When you retrieve a pooled projectile and move it to its new spawn position, the trail draws a line from wherever the object was last used to its new position — producing a visual “ghost trail” that streaks across the screen for a single frame.
The fix is to clear the trail after setting the new position:
Projectile p = _pool.Get();
p.transform.position = spawnPoint;
p.GetComponent<TrailRenderer>().Clear();
Alternatively, you can disable the trail renderer on release, set the position on get, and re-enable it one frame later using a coroutine or callback.
“We spent two days chasing a bug where enemies spawned invincible. Turns out the ‘isDead’ flag was still true from the last pool cycle, and our damage system checked it before our spawn logic reset it. One bool, two days.”
Related Issues
Pooling intersects with several other Unity systems that can cause confusing behavior. See Fix Unity Additive Scene Not Unloading (Memory Leak) for what happens when pooled objects prevent scene unloading, and Automated Crash Reporting for Indie Games for how to capture the stack traces that help you trace stale-state bugs back to their source.
Tip: Add a debug-only check to your pool’s Get method that logs a warning if any Rigidbody velocity is non-zero at retrieval time. It costs nothing in release builds and catches reset oversights immediately during testing.