Why Wednesday

Object Pooling, Explained: Why Instantiate and Destroy Cost You Frames

What object pooling is, why Instantiate and Destroy cause frame stutters, and how to build a pool in Unity 6 using ObjectPool<T>.

Concepts · By Game Design Jar · · P.09

Object pooling is one of those terms that gets thrown around in performance tuning advice, where no one takes the time to explain what it actually does. Here’s my breakdown from start to finish:

What object pooling is, in plain language

Instead of creating a new object every time you need one and destroying it when you’re done, you make a batch of them up front, hand them out when something needs one, and put them back in the batch instead of destroying them. The bullet doesn’t get deleted when it hits a wall. It gets turned off and put back in a box, ready to be turned on again next time you need a bullet.

That’s the simplest way to describe it. The objects themselves don’t change. What changes is who’s in charge of creating and destroying them, and how often that actually happens.

The problem it solves: what actually happens when you Instantiate and Destroy

Instantiate() in Unity isn’t free. It allocates memory for a new GameObject, runs Awake() and OnEnable(), sets up components, and if the prefab has anything nested (colliders, rigidbodies, particle systems), all of that gets initialized too. Destroy() isn’t free either: it tears the object down and marks that memory as no longer in use.

Do that once or twice a frame and you won’t notice. Do it forty times a frame because your bullet-hell shooter is spawning bullets, or your JRPG is throwing a fresh damage number popup every time something takes a hit, and you’re adding real work for the system to do every single frame just managing objects that exist for a second and then vanish.

I ran into a mild version of this while testing hit effects for Crown Code Chronicles. Nothing dramatic, just enough of a hitch during a flurry of attacks that I went looking for why, and Instantiate/Destroy calls were most of the answer.

Two costs, not one, and they show up at different times

This is the part that gets muddled in most explanations, including a lot of advice that tells you to pool without saying why.

The first cost is immediate. Everything Instantiate() does happens on the frame you call it: allocating the object, copying the prefab hierarchy, registering components with the physics and rendering systems, running Awake() and OnEnable(). Destroy() does its teardown at the end of the frame you call it on. For GameObjects this is usually the bigger of the two costs, and it’s the one pooling removes most directly.

The second cost is delayed, and it’s the one people mean when they blame the garbage collector. It needs a caveat, though, because it’s smaller than it’s usually made out to be for this particular case. A Unity GameObject is a native object. When you call Destroy(), Unity’s own memory manager reclaims that native memory, not the C# garbage collector. What does become garbage for C# is the managed wrapper objects and anything your own code allocated while the object was being set up or torn down.

C#, like most managed runtimes, doesn’t free that memory the instant it stops being used. It marks it as available and moves on. Periodically the collector sweeps through and reclaims it, and that sweep takes time on the main thread. Unity’s collector is Boehm, which is neither generational nor compacting. Since Unity 2019 it can also run in incremental mode, spreading the work across several frames instead of doing it all at once, and that’s on by default in recent versions including Unity 6. You can confirm it under Project Settings > Player. That makes the classic single huge frame spike much less common than older advice suggests, but the work still has to happen somewhere.

The practical upshot: pooling helps mostly by removing the immediate construction and teardown work, and helps secondarily by producing less garbage. If you go hunting only for a GC spike and don’t find one, don’t conclude pooling won’t help you. Check what Instantiate itself is costing on the frames where you spawn.

How a pool works: borrow, use, return

Strip away the implementation details and a pool is three operations:

  1. Borrow (sometimes called “get” or “rent”): ask the pool for an object. If one is sitting idle, you get that one. If not, the pool creates a new one (or, in a fixed-size pool, you’re out of luck and need to grow the pool or wait).
  2. Use: the object does its job. A bullet flies, a particle plays, an enemy walks around.
  3. Return (“release”): instead of destroying it, you turn it off and hand it back to the pool.

The pool itself is usually just a stack or a queue of inactive objects, plus a bit of tracking for which ones are currently out in the world.

For any fellow software engineers reading: it’s the same idea as a database connection pool. You ask for a connection, and if one is idle you get it. If not, you either wait or open a new one, depending on how the pool is configured. Same pattern, same name, different kind of object.

A small pool in Unity 6 (C#), and the built-in ObjectPool that already does this

You can write a pool by hand, and it’s worth doing once so the mechanism isn’t a mystery:

public class SimplePool : MonoBehaviour
{
    [SerializeField] private GameObject prefab;
    [SerializeField] private int initialSize = 20;

    private readonly Stack<GameObject> pool = new();

    private void Awake()
    {
        for (int i = 0; i < initialSize; i++)
            pool.Push(CreateOne());
    }

    // Both creation paths go through here, so objects that get made later
    // when the pool runs dry are parented the same way as the initial batch.
    private GameObject CreateOne()
    {
        GameObject obj = Instantiate(prefab, transform);
        obj.SetActive(false);
        return obj;
    }

    public GameObject Borrow(Vector3 position, Quaternion rotation)
    {
        GameObject obj = pool.Count > 0 ? pool.Pop() : CreateOne();
        obj.transform.SetPositionAndRotation(position, rotation);
        obj.SetActive(true);
        return obj;
    }

    public void Return(GameObject obj)
    {
        obj.SetActive(false);
        pool.Push(obj);
    }
}

That works, but Unity has already solved this for you. Since Unity 2021 (and this still applies in Unity 6), there’s UnityEngine.Pool.ObjectPool<T>, which handles the collection, growth, and optional max-size limit for you:

using UnityEngine;
using UnityEngine.Pool;

public class BulletPool : MonoBehaviour
{
    [SerializeField] private Bullet bulletPrefab;

    private ObjectPool<Bullet> pool;

    private void Awake()
    {
        pool = new ObjectPool<Bullet>(
            createFunc: () =>
            {
                Bullet b = Instantiate(bulletPrefab);
                b.gameObject.SetActive(false);
                return b;
            },
            // Deliberately no actionOnGet. The bullet stays inactive until
            // Fire() has placed it, so trails and particles don't emit a frame
            // at wherever the previous bullet died.
            actionOnRelease: bullet => bullet.gameObject.SetActive(false),
            actionOnDestroy: bullet => Destroy(bullet.gameObject),
            collectionCheck: true,
            defaultCapacity: 20,
            maxSize: 100
        );
    }

    public void Fire(Vector3 position, Quaternion rotation)
    {
        Bullet bullet = pool.Get();
        bullet.transform.SetPositionAndRotation(position, rotation);
        bullet.Init(pool); // so the bullet can call pool.Release(this) when it's done
        bullet.gameObject.SetActive(true);
    }
}

That activation order matters more than it looks. If you activate the bullet before you position it, anything that starts emitting on enable (a TrailRenderer, a particle system set to play on awake) gets one frame at the old location. It’s a small visual artifact that’s hard to attribute to pooling when you see it.

collectionCheck is on by default, and it’s worth leaving on during development. It throws if you accidentally release the same object twice, which is an easy mistake to make and a really annoying one to debug without that error. Turn it off for release builds if you want the few cycles back.

Resetting state on reuse, the part that causes most pooling bugs

This is where pooling actually goes wrong in practice, and it’s not the pooling mechanism itself, it’s what people forget to do around it.

When an object gets destroyed, it takes its state with it. When you return an object to a pool instead, its state stays exactly as it was when you were done with it: leftover velocity, a half-finished animation, a hasHit flag still set to true from last time, a coroutine still technically running. Pull that same object back out for reuse and unless you reset it, it starts its new life carrying baggage from its old one.

The fix is to treat Get/OnEnable and Release/OnDisable as the actual reset points, not Awake and OnDestroy, since those only run once for the object’s entire lifetime in the pool. Anything that needs to be fresh each time an object is borrowed (position, rotation, velocity, animation state, timers, references to who it’s targeting) needs to be explicitly set in an Init() method you call right after Get(), or in OnEnable() if the object doesn’t need any external data to reset.

The bug I hit most often testing this myself was a particle effect that kept a reference to the enemy it last hit. Pool it, reuse it on a different enemy, and it would briefly point back at the wrong target before the next frame’s data overwrote it. Nothing that crashed anything, just a visual glitch that took longer to track down than the fix did once I found it, because the pooling code itself looked completely correct.

How this differs in Godot and Unreal

Godot doesn’t ship a built-in pooling class the way Unity now does. You build the same borrow/use/return pattern yourself, usually with an array of pre-instantiated nodes and visible/process_mode toggled instead of adding and removing them from the scene tree, since adding and removing nodes has its own overhead. I haven’t built this in Godot myself, so I’d rather point you at the mechanism than pretend I know the idiomatic Godot way to package it.

Unreal handles the cleanup side differently. Its UObject garbage collector is a mark-and-sweep tracing collector that periodically walks what’s still reachable from the engine’s root set and frees the rest, on its own schedule rather than C#‘s. (Reference counting does exist in Unreal, but that’s TSharedPtr and friends for plain C++ objects, which is a separate system from UObject GC.) For actors specifically, the usual approach is to avoid SpawnActor/Destroy churn by hiding, disabling tick, and repositioning instead. The timing of the cost is different from Unity’s. The reason pooling helps is the same.

When to reach for a pool

Pool when something spawns and despawns often and predictably: bullets, particle bursts, hit-effects, floating damage numbers, enemies in a wave-based game, tiles in a procedurally generated level that scrolls in and out. The common thread is frequency. If you’re creating and destroying the same kind of object many times over the course of a session, that’s a pooling candidate.

When not to bother, and how to check before you refactor anything

If you’re spawning a boss once per level, or a single UI popup when the player opens a menu, pooling adds code and complexity for a cost that was never actually a problem. Destroying one object a minute isn’t the thing making your game stutter.

Before you pool anything, open the Unity Profiler (Window > Analysis > Profiler), play the section you’re worried about, and look at two things rather than one. In the CPU timeline, check how much time is going to Instantiate and Destroy on the frames where you spawn. In the Hierarchy view, sort by GC Alloc to see what’s actually producing garbage. If either lines up with a burst of spawning, you’ve found a real case for pooling.

Check both, because they fail differently. The immediate spawn cost will happily sit there with a completely flat GC graph, and if GC Alloc is the only column you look at you’ll talk yourself out of a fix that would have worked.

If the timeline is flat and boring on both counts, leave it alone and go fix whatever’s actually slow, which very often turns out to be something in your update loop that has nothing to do with spawning objects. If that’s where you end up, a good place to start is how delta time affects your frame-rate-dependent logic, since a lot of complaints that sound like performance problems are really timing bugs.

If you’re working in Godot and want to see this same instinct (measure before you optimize) applied to a different kind of feel problem, the coyote time and jump buffering piece walks through a similar case of a small, targeted fix instead of a broad rewrite.

object poolingunityperformancegame feelgarbage collectionc#

Registration card

Register to receive one focused post every weekday — engine tutorials, marketing tactics, and design craft. Free, unsubscribe anytime.

No spam. We never share your address. No purchase necessary — it's free.