Unity stutter from Instantiate and Destroy: when pooling is the fix
What Instantiate and Destroy cost in Unity, how to confirm with the Profiler that pooling is the fix, and how to use ObjectPool<T> without the usual bugs.
A gun fires ten times a second. Each shot calls Instantiate for a projectile and later Destroy on it. The frame counter looks fine, yet every few seconds the game hitches. The hitch is not the shot. It is the bill for all the shots before it.
What Instantiate and Destroy actually cost
Instantiate clones the prefab's GameObject and every component on it, sets up the hierarchy, and allocates managed memory for each component's C# side. Destroy tears it down again. Both land in the frame where you call them, as time under your spawn method in the CPU Profiler.
The second cost is deferred. The Profiler records every managed allocation as a GC.Alloc sample. Unity runs the garbage collector when a script asks for heap memory and none is free; if a collection still cannot find room, the heap grows and stays grown. The manual is plain about the cost: allocating managed memory is time-consuming for the CPU, and garbage collection might stop the CPU from doing other work until it completes. Its target for frequent allocations is zero bytes per frame, or as close as you can get.
So the hitch is often a collection, triggered by whichever spawn tipped the heap over. Projectiles are the textbook case: one object type, created and destroyed over and over.
Confirm it before you pool
Pooling adds a lifecycle to get right and fixes exactly one problem. Measure first.
- Record gameplay that includes the hitch and click the spike. In the CPU module's Hierarchy view, sort by the GC Alloc column: the scripting heap memory Unity allocated in that frame, per sample.
- If
Instantiateand your spawn methods top that column and aGC.Collectsample sits in the spike frame, pooling is the right tool. - If the spike is elsewhere (physics, a texture load, a string built every frame), pooling projectiles changes nothing.
- The Profiler can record call stacks and resolve them to method, file and line. Use that when the allocating sample is not obviously yours.
- The Memory Profiler module charts GC Allocated In Frame, a quick read on whether allocation is steady or bursty. For what is on the heap, the module's Detailed view is deprecated; Unity points you to the Memory Profiler package.
Object statistics in that module are not available in release players, so profile a development build.
Pooling prefab instances with ObjectPool<T>
Unity ships ObjectPool<T> in UnityEngine.Pool: a stack of instances of any class type. It knows nothing about GameObjects, so the four callbacks you pass to its constructor are where the GameObject behavior lives.
The constructor, as documented for Unity 6:
ObjectPool(Func<T> createFunc,
Action<T> actionOnGet = null,
Action<T> actionOnRelease = null,
Action<T> actionOnDestroy = null,
bool collectionCheck = true,
int defaultCapacity = 10,
int maxSize = 10000)
A complete pool for a projectile component:
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Pool;
public class ProjectilePool : MonoBehaviour
{
[SerializeField] private Projectile prefab;
[SerializeField] private int prewarm = 32;
private ObjectPool<Projectile> pool;
void Awake()
{
pool = new ObjectPool<Projectile>(
createFunc: Create,
actionOnGet: OnGet,
actionOnRelease: OnRelease,
actionOnDestroy: OnDestroyItem,
collectionCheck: true,
defaultCapacity: prewarm,
maxSize: 256);
// defaultCapacity creates nothing; pre-warm by hand.
var warm = new List<Projectile>(prewarm);
for (int i = 0; i < prewarm; i++) warm.Add(pool.Get());
foreach (var p in warm) pool.Release(p);
}
public Projectile Spawn(Vector3 position, Quaternion rotation)
{
var p = pool.Get();
p.transform.SetPositionAndRotation(position, rotation);
return p;
}
public void Despawn(Projectile p) => pool.Release(p);
private Projectile Create()
{
var p = Instantiate(prefab, transform);
p.gameObject.SetActive(false);
return p;
}
private void OnGet(Projectile p) => p.gameObject.SetActive(true);
private void OnRelease(Projectile p)
{
p.ResetState();
p.gameObject.SetActive(false);
}
private void OnDestroyItem(Projectile p) => Destroy(p.gameObject);
}
What each callback is for
createFuncruns whenGetfinds the pool empty. This is the only placeInstantiateshould appear.actionOnGetruns on everyGet. Activate the object here; position it in the caller, since the callback does not know where it is going.actionOnReleaseruns on everyRelease. Reset and deactivate here, so no caller can forget the reset.actionOnDestroyruns whenReleaseis called on a pool already holdingmaxSizeinstances. The instance is not kept, soDestroybelongs here.
CountAll, CountActive and CountInactive report how many exist, are out, and are waiting.
collectionCheck
With collectionCheck true, calling Release on an instance already in the pool throws an exception. The check runs only in the Editor, not in a Player build, so it costs nothing shipped. Leave it on; a double release is one of the quietest bugs a pool can have.
The mistakes that make a pool worse than no pool
State that survives reuse
Instantiate gave you a clean copy every time. A pool gives you the object as the last user left it. Reset everything that ran or accumulated, in actionOnRelease or the component's OnDisable:
public void ResetState()
{
StopAllCoroutines();
rigidbody.linearVelocity = Vector3.zero;
rigidbody.angularVelocity = Vector3.zero;
trail.Clear();
particles.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
hitCount = 0;
}
A half-finished coroutine leaves its flags set and does not resume on the next Get. An uncleared TrailRenderer draws a line from where the object died to where it respawned. Leftover velocity sends a Rigidbody flying on re-enable. A playing particle system keeps its live particles.
Destroy on a pooled object
If any code path calls Destroy on an instance the pool still tracks, the stack keeps a reference to a destroyed object. The next Get hands it out and the exception lands far from the cause. The fix is a rule: pooled prefabs never destroy themselves. They release.
Returning twice
Release the same instance twice and the pool holds it twice; two later Get calls hand one object to two owners. collectionCheck catches this in the Editor. In a build it goes unnoticed until something looks haunted, so give the pooled component a guard flag that its return path clears after the first call.
Pools that grow forever
Get on an empty pool creates an instance, so if release lags behind get, every peak adds instances that never go away. maxSize caps what the pool holds, not what Get creates; watch CountAll during play and make sure it settles.
Reparenting cost
Parking released objects under a tidy "Pool" parent looks nice in the hierarchy, but a parent change is work, and on mobile it can show in the profile. If you see time under SetParent or Transform.parent, leave pooled objects where they are; SetActive(false) is enough.
Sizing: pre-warm from a measured peak
Do not guess. defaultCapacity creates nothing, so pre-warming is explicit, and the number should come from a run:
- Play your heaviest encounter and log
CountActiveandCountAllonce a second, or draw them on screen. - The highest
CountAllyou see is your pre-warm amount: the number of instances the game actually needed at once. - Pre-warm that many in
Awake, behind a loading screen, so noInstantiateruns during play. - Set
maxSizea little above it so a one-off burst can still release without the pool destroying the overflow.
Re-run after balance changes; last month's enemy count is the slow way back to the hitch.
Where zCache fits
The pool above covers one prefab type. With thirty, you maintain a pool and a pre-warm count for each, plus an object-to-pool lookup on every return.
zCache is this publisher's package for that case (12.99 USD on the Asset Store; store version 1.1.0, with 1.2.0 for Unity 6.3 LTS in review as of 2026-10-29). One component holds pools for many prefab types. List the prefabs you know with a pre-warm amount each; ask for an unlisted one and a pool is created on the fly. Pools expand by a configurable step when dry. Every pooled object carries a tracking component, so returning it is a direct call with no lookup. A time-to-live helper returns effects by themselves, a statistics overlay prefab shows cached and in-use counts in the game view, and after a run you copy the live configuration back into edit mode so pre-cache amounts match a real session. A skip-reparenting option keeps returned objects at the scene root for mobile. Pure C#, any render pipeline.
What it is not: not DOTS, not a replacement for ObjectPool<T> for plain C# objects, and it does not reset component state for you; that stays in OnEnable and OnDisable.
Sources
- https://docs.unity.com/en-us/engine/6000.3/script-reference/unityengine/pool/objectpool1
- https://docs.unity.com/en-us/engine/6000.3/script-reference/unityengine/pool/objectpool1/ctor
- https://docs.unity.com/en-us/engine/6000.3/script-reference/unityengine/pool/objectpool1/get
- https://docs.unity.com/en-us/engine/6000.3/script-reference/unityengine/pool/objectpool1/release
- https://docs.unity.com/en-us/engine/6000.3/manual/analysis/performance-memory/managed-memory/introduction
- https://docs.unity.com/en-us/engine/6000.3/manual/analysis/performance-memory/managed-memory/garbage-collector
- https://docs.unity.com/en-us/engine/6000.3/manual/analysis/profiler/visualizing-data/cpu/cpu
- https://docs.unity.com/en-us/engine/6000.3/manual/analysis/profiler/visualizing-data/memory/memory
- https://docs.unity.com/en-us/engine/6000.3/script-reference/unityengine/rigidbody/linearvelocity
- https://docs.unity.com/en-us/engine/6000.3/script-reference/unityengine/trailrenderer/clear
- https://docs.unity.com/en-us/engine/6000.3/script-reference/unityengine/particlesystem/stop