Every shot, every hit effect, every new enemy: in many Unity projects each of these starts with Instantiate and ends with Destroy. In a quiet test scene nobody notices. Then comes the boss fight with forty projectiles on screen, and exactly when the game should feel at its best, the frame rate twitches.
If you have ever asked yourself why your Unity game stutters when spawning objects, the answer is often not your graphics card. It is the constant creating and throwing away of objects. This article explains where those spikes come from, how Object Pooling in Unity with the built-in ObjectPool<T> fixes them, which mistakes to avoid, and how to prove in the Profiler that the change actually worked.
Why Spawning Stutters: Instantiate, Destroy and the Garbage Collector
Creating a GameObject is not free. Unity has to build the object, set up its components, and run callbacks like Awake and OnEnable. Destroying it costs work again. If that happens a few times per level, it does not matter. If it happens dozens of times per second, it adds up quickly.
The second part of the problem is memory. Your scripts allocate managed memory on the so-called managed heap, and the garbage collector later has to find and clean up whatever is no longer referenced. The Unity documentation describes the default mode as incremental garbage collection: the collector spreads its work over several frames instead of stopping the game in one long pause. That reduces spikes, but Unity states clearly that it does not make garbage collection faster. If references change too often, the incremental collector can even fall back to a full, non-incremental collection. And on the web platform, incremental GC is not supported at all.
In short: the less you allocate and throw away while the game is running, the less work the garbage collector has, and the more stable your frame time stays. This is exactly where pooling comes in.
The Principle: Borrow Objects Instead of Building New Ones
The idea behind object pooling is simple. Instead of creating a new projectile for every shot and destroying it afterwards, you keep a stock of projectiles. When you need one, you take it from the pool and activate it. When it has done its job, you deactivate it and put it back. The next shot reuses the same object.
The Unity manual describes it the same way: object pooling reduces the overhead of repeatedly instantiating and destroying objects and limits the total number of allocations and deallocations. That lowers garbage collection overhead and CPU load.
You no longer need to write your own pool class for this. Since Unity 2021, the UnityEngine.Pool namespace ships with ready-made implementations. The most important one for GameObjects is ObjectPool<T>, a stack-based pool for any reference type. One limitation is worth knowing from the start: like many core Unity APIs, UnityEngine.Pool is not thread-safe and should only be called from the main thread.
ObjectPool<T> in Practice
The Parameters at a Glance
The constructor takes a few delegates and three settings. According to the Unity documentation, they do the following:
- createFunc is called when the pool is empty and a new instance is needed. Usually it just calls
Instantiateon your prefab. - actionOnGet runs when an object is taken from the pool. This is where you activate and prepare it.
- actionOnRelease runs when an object goes back into the pool. This is where you deactivate it and clean it up.
- actionOnDestroy runs when an object is returned but the pool is already full. Typically you call
Destroyhere. - collectionCheck throws an exception if you release an object that is already in the pool. Important: this check only runs in the Editor, not in Player builds.
- defaultCapacity is the starting size of the internal storage. It does not create any objects up front.
- maxSize is the upper limit. Once the pool holds that many inactive objects, further returned objects are destroyed via
actionOnDestroy.
Code Example: Projectiles from the Pool
The following example is based on the projectile example in the Unity manual, shortened and adapted for Unity 6. A spawner creates the pool once in Awake and calls Get instead of Instantiate when firing:
using UnityEngine;
using UnityEngine.Pool;
public class ProjectileSpawner : MonoBehaviour
{
[SerializeField] private Projectile projectilePrefab;
[SerializeField] private Transform muzzle;
[SerializeField] private bool collectionCheck = true;
[SerializeField] private int defaultCapacity = 20;
[SerializeField] private int maxSize = 100;
private ObjectPool<Projectile> pool;
private void Awake()
{
pool = new ObjectPool<Projectile>(
createFunc: () =>
{
Projectile projectile = Instantiate(projectilePrefab);
projectile.Pool = pool;
return projectile;
},
actionOnGet: projectile => projectile.gameObject.SetActive(true),
actionOnRelease: projectile => projectile.gameObject.SetActive(false),
actionOnDestroy: projectile => Destroy(projectile.gameObject),
collectionCheck: collectionCheck,
defaultCapacity: defaultCapacity,
maxSize: maxSize);
}
public void Fire()
{
Projectile projectile = pool.Get();
projectile.transform.SetPositionAndRotation(muzzle.position, muzzle.rotation);
projectile.Launch();
}
}
The projectile knows its pool and returns itself, either when it hits something or when its lifetime runs out:
using UnityEngine;
using UnityEngine.Pool;
[RequireComponent(typeof(Rigidbody))]
public class Projectile : MonoBehaviour
{
[SerializeField] private float speed = 30f;
[SerializeField] private float lifetime = 3f;
public IObjectPool<Projectile> Pool { get; set; }
private Rigidbody body;
private float releaseTime;
private bool released;
private void Awake()
{
body = GetComponent<Rigidbody>();
}
public void Launch()
{
released = false;
releaseTime = Time.time + lifetime;
body.linearVelocity = transform.forward * speed;
}
private void Update()
{
if (Time.time >= releaseTime)
ReturnToPool();
}
private void OnCollisionEnter(Collision collision)
{
ReturnToPool();
}
private void ReturnToPool()
{
if (released)
return;
released = true;
body.linearVelocity = Vector3.zero;
body.angularVelocity = Vector3.zero;
Pool.Release(this);
}
}
Two details matter here. First, the projectile resets its Rigidbody velocity before it goes back, so the next shot does not start with leftover momentum. Second, the released flag prevents the same projectile from being returned twice if a collision and the timeout happen in the same frame. We will come back to both points.
Warming Up the Pool
This is one of the most common misunderstandings. Many developers assume that defaultCapacity: 20 creates twenty projectiles right away. The Unity documentation says otherwise: instances are only created when Get is called and the pool is empty. A fresh pool is empty, so the first burst of shots still runs Instantiate for every projectile, right in the middle of the action.
If you want to avoid that first spike, warm up the pool while the level is loading. Take the desired number of objects out and put them straight back:
using System.Collections.Generic;
public void Prewarm(int count)
{
var warmup = new List<Projectile>(count);
for (int i = 0; i < count; i++)
warmup.Add(pool.Get());
foreach (Projectile projectile in warmup)
pool.Release(projectile);
}
Keep count at or below maxSize. Otherwise the pool destroys the extra objects right away, and the warm-up was pointless.
The Typical Pitfalls
Resetting State
A pooled object carries everything from its last use with it. A pooled enemy might come back with its old position, low health, a half-finished animation, a running coroutine or still-active event subscriptions. The Unity manual lists the usual reset tasks: stop coroutines, unsubscribe from events, reset physics, clear animations and stop particle systems.
You can reset either in actionOnRelease or in actionOnGet, or give the pooled object its own reset method that the pool calls. The manual also recommends deactivating the GameObject on release, so it no longer receives Update calls while it sits in the pool. From a practical point of view, a missing reset rarely shows up as a crash. It shows up as strange behavior: an enemy that spawns already half dead, or a bullet that flies off sideways. If you see something like that after introducing a pool, check the reset first.
Choosing the Right Pool Size
If maxSize is too small, the pool destroys returned objects during intense moments and has to create them again shortly after. The stutter you wanted to get rid of is back. If it is too large, the pool keeps memory occupied for objects that are rarely needed.
There is no universal number for this. ObjectPool<T> gives you three properties that help: CountActive (objects currently in use), CountInactive (objects waiting in the pool) and CountAll (both together). Our practical recommendation: log these values during the most intense scene of your game and derive defaultCapacity, maxSize and the warm-up count from what you actually see.
Double Release and Scene Changes
Releasing the same object twice is a nasty bug, because two parts of the game then hold the same instance. collectionCheck catches it with an exception, but only in the Editor. In a Player build the error passes silently. So keep the check on during development and protect your own code, as with the released flag in the example above.
Scene changes need attention too. According to the Unity manual, a pool is tied to the scene it was created in by default. When that scene is unloaded, Unity destroys the pool and all pooled instances with it. If you need a pool across scenes, place the pool manager under a root object marked with DontDestroyOnLoad and reparent returned objects back under that root. Between levels, Clear destroys all inactive instances and frees the memory.
When Pooling Is Not Worth It
The following is our practical judgment, not an official rule. Pooling pays off for objects that are created and destroyed often and in large numbers: projectiles, hit effects, damage numbers, enemies in waves. It does not pay off for objects that are created rarely or live for the whole game, such as the player character, level geometry or a menu that opens once. A pool there only adds management code, keeps memory occupied and introduces another source of reset bugs.
Our rule of thumb is therefore: measure first, then pool. Only introduce a pool when the Profiler shows that spawning actually costs you something.
Not Just GameObjects: Pooling Lists and Collections
Pooling is not limited to GameObjects. A common source of allocations is temporary lists created every frame. The Unity manual shows the problem with a simple example: an Update method that creates a new List<float> every frame. The fix is to keep the list as a field and call Clear before each use. Clear removes the values but keeps the allocated memory, so the list only allocates again when it needs to grow.
For temporary collections, UnityEngine.Pool also provides ready-made pools such as ListPool<T>, HashSetPool<T>, DictionaryPool<TKey, TValue> and the general CollectionPool<TCollection, TItem>. The using variant returns the list to the pool automatically at the end of the block:
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Pool;
public class TargetScanner : MonoBehaviour
{
private void Update()
{
using (ListPool<Vector3>.Get(out List<Vector3> positions))
{
// fill and use positions here
}
// the list is back in the pool at this point
}
}
Proving in the Profiler That It Works
Pooling without measuring is guesswork. The Unity Profiler shows you clearly whether the change helped. The Unity documentation describes the steps like this:
- Open the Profiler via Window > Analysis > Profiler.
- Record a scene with many spawns, ideally your most intense moment.
- Select a frame in the CPU Usage module and open the Hierarchy view.
- Look at the GC Alloc column. It shows how many bytes Unity allocated on the managed heap in that frame and thread.
Also keep an eye on the GarbageCollector category in the CPU chart. It shows how much time the garbage collector takes. In the Timeline view, GC.Alloc samples appear in bright magenta. If you want to know exactly where an allocation comes from, enable Call Stacks in the Profiler toolbar. Unity recommends this over Deep Profiling, which can affect performance itself.
Compare a recording before and after the change. If spawning was the cause, the GC Alloc values during heavy action should drop noticeably. Measure on the target device with a development build if you can, because the Editor adds its own overhead and a desktop PC hides problems that a phone or handheld will show. Why hardware differences matter so much for garbage collection pauses is covered in our article on cross-platform Unity development from mobile to console.
Checklist: Object Pooling in Five Steps
- Measure first. Use the Profiler to check whether spawning actually causes allocations and spikes.
- Warm up the pool while loading.
defaultCapacitydoes not create objects, so callGetandReleaseonce in a loop. - Reset state completely. Position, physics, animation, coroutines, events and particles belong in your reset.
- Derive maxSize from real game values. Log
CountActiveandCountInactivein your most intense scene. - Keep collectionCheck on during development and profile again after the change.
Conclusion
Stutter when spawning is rarely a mystery. It usually comes from creating and destroying objects over and over, and from the garbage collector having to clean up afterwards. ObjectPool<T> from UnityEngine.Pool solves this with little code, as long as you reset state cleanly, size the pool based on real data and warm it up while loading. The Profiler then shows whether it worked, and it also tells you where pooling is not needed at all.
A practical next step: open the Profiler today, record your busiest scene and look at the GC Alloc column. If you need support with performance problems in a Unity project, ProjectMakers helps through our game development services. And if you want to go further with frame rate optimization, our article on real-time global illumination in Unity covers the lighting side.