Digital Software Publishing Stockholm

Home › Unity tools › Guides

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.

2026-10-29 · Digital Software · about zCache

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.

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

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:

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

More guides

All guides · Atom feed