DEV Community

unity source code
unity source code

Posted on

Unity Object Pooling: The Single Optimization That Fixes Most Mobile Performance Issues

If you've ever profiled a Unity mobile game and seen GC.Alloc spikes every time an enemy spawns, a bullet fires, or a particle effect plays, you've already met the problem this article solves. Frequent Instantiate() and Destroy() calls are one of the most common causes of frame hitches on mobile devices, and the fix — object pooling — is one of the highest-leverage optimizations you can make in a Unity project.

This guide walks through what object pooling actually is, why it matters so much specifically on mobile hardware, and how to implement a clean, reusable pooling system in Unity with C#. By the end, you'll have a pattern you can drop into any project, whether you're spawning bullets, enemies, particles, or UI elements.

We'll also cover common implementation mistakes that quietly undo the benefits of pooling, how to extend the pattern to UI and particle systems, and a few related allocation sources worth checking once pooling is in place, so you're not left wondering why frame times are still inconsistent after adding a pool.

Why Instantiate() and Destroy() Are So Expensive

Every time you call Instantiate(), Unity has to allocate memory for a new GameObject, its components, and any associated data. Every time you call Destroy(), that memory eventually needs to be reclaimed by the garbage collector. On desktop, this cost is often invisible. On mobile, where CPUs are slower and garbage collection pauses are more expensive relative to your frame budget, this pattern becomes a real problem.

A mobile game targeting 60 FPS has roughly 16.6ms per frame. A garbage collection pass can easily eat several milliseconds of that budget, and it doesn't happen predictably — it happens whenever the collector decides memory pressure is high enough. That means your frame times become inconsistent, which players feel as stutter even if your average FPS looks fine on paper.

Games that spawn a lot of short-lived objects — bullet hell shooters, endless runners, tower defense games, idle games with lots of floating damage numbers — are especially vulnerable to this, because they're doing exactly the kind of high-frequency instantiate/destroy cycle that triggers frequent GC passes.

What Object Pooling Actually Does

Object pooling solves this by never actually destroying objects during gameplay. Instead, you:

  1. Pre-instantiate a batch of objects at the start (or on first need)
  2. Disable them and store references in a pool
  3. When you need one, pull it from the pool, reset its state, and enable it
  4. When you're done with it, disable it and return it to the pool instead of destroying it

The result is that after the initial warm-up, your game does zero Instantiate() or Destroy() calls during actual gameplay. You're reusing the same fixed set of objects over and over, which means no new allocations, no extra GC pressure, and dramatically more consistent frame times.

A Simple, Reusable Object Pool in Unity

Here's a generic pooling system you can use for almost any prefab-based object — bullets, enemies, pickups, particle effects, whatever you need.

using System.Collections.Generic;
using UnityEngine;

public class ObjectPool : MonoBehaviour
{
    [SerializeField] private GameObject prefab;
    [SerializeField] private int initialSize = 20;
    [SerializeField] private bool canGrow = true;

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

    private void Awake()
    {
        for (int i = 0; i < initialSize; i++)
        {
            GameObject obj = CreateNewObject();
            pool.Enqueue(obj);
        }
    }

    private GameObject CreateNewObject()
    {
        GameObject obj = Instantiate(prefab, transform);
        obj.SetActive(false);
        return obj;
    }

    public GameObject Get()
    {
        if (pool.Count == 0)
        {
            if (!canGrow)
            {
                Debug.LogWarning($"Pool for {prefab.name} is empty and canGrow is false.");
                return null;
            }
            pool.Enqueue(CreateNewObject());
        }

        GameObject obj = pool.Dequeue();
        obj.SetActive(true);
        return obj;
    }

    public void Return(GameObject obj)
    {
        obj.SetActive(false);
        obj.transform.SetParent(transform);
        pool.Enqueue(obj);
    }
}
Enter fullscreen mode Exit fullscreen mode

And a small helper component so any pooled object can return itself after a set lifetime — useful for bullets, hit-effects, or floating damage text:

using UnityEngine;

public class PooledLifetime : MonoBehaviour
{
    [SerializeField] private float lifetime = 2f;
    private ObjectPool sourcePool;
    private float timer;

    public void Activate(ObjectPool pool)
    {
        sourcePool = pool;
        timer = lifetime;
    }

    private void OnEnable()
    {
        timer = lifetime;
    }

    private void Update()
    {
        timer -= Time.deltaTime;
        if (timer <= 0f)
        {
            sourcePool.Return(gameObject);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage looks like this when spawning, say, a bullet:

GameObject bullet = bulletPool.Get();
bullet.transform.position = firePoint.position;
bullet.transform.rotation = firePoint.rotation;
bullet.GetComponent<PooledLifetime>().Activate(bulletPool);
Enter fullscreen mode Exit fullscreen mode

That's the entire pattern. No new allocations happen after the initial warm-up loop in Awake(), and every bullet, enemy, or effect is simply recycled.

Common Mistakes When Implementing Pooling

Forgetting to reset object state. If a pooled enemy has health, status effects, or animation state, you need to explicitly reset all of that when it's pulled from the pool — otherwise you'll get bugs where a "fresh" enemy spawns already poisoned or at 1 HP from its previous life.

Not resetting physics state. Rigidbody velocity and angular velocity persist across SetActive(false)/SetActive(true) cycles. If you don't zero these out when returning an object to the pool, you'll see strange movement the next time it's activated.

Rigidbody rb = obj.GetComponent<Rigidbody>();
rb.velocity = Vector3.zero;
rb.angularVelocity = Vector3.zero;
Enter fullscreen mode Exit fullscreen mode

Pooling everything, even when it doesn't help. Pooling adds a small amount of complexity. It's worth it for objects that spawn frequently (bullets, particles, enemies in wave-based games) but probably not worth it for something that's instantiated once per level, like a boss.

Ignoring child object state. If your pooled prefab has child GameObjects that get enabled/disabled conditionally during gameplay (like a shield visual on an enemy), make sure your reset logic restores the default child state too, not just the root object.

Not warming up pools early enough. If you create your pool lazily on first use, that first Instantiate() burst can still cause a hitch — right when the player first encounters that enemy type or effect. Warm up pools during a loading screen or level transition instead, when a hitch is invisible to the player.

Pooling Particle Systems and VFX

Particle effects are a classic case where pooling makes an enormous difference, since hit-effects, muzzle flashes, and explosion VFX often spawn dozens of times per second in busy scenes. The same ObjectPool pattern above works for particle prefabs — just make sure your particle system's Stop Action is set to Callback or handled manually so you can return it to the pool exactly when the effect finishes, rather than relying on Destroy in an animation event.

Measuring the Impact

Don't just take pooling on faith — verify it in the Unity Profiler. Open Window > Analysis > Profiler, switch to the CPU and Memory modules, and compare GC.Alloc counts before and after adding pooling around your heaviest instantiate/destroy hotspots. On a busy scene (a wave of enemies, a screen full of bullets), you should see GC.Alloc spikes drop to near zero once pooling is in place, and your frame time graph should flatten out noticeably.

This kind of before/after profiling is worth doing on every major system, not just object spawning. Architecture-level issues — tight coupling between systems, expensive Update() loops, or singleton overuse — can cause similar frame-time problems, and are worth ruling out alongside pooling. If you haven't audited your project for those yet, it's worth reading through common Unity architecture mistakes that quietly hurt mobile performance as a companion piece to this one — pooling fixes allocation-driven hitches, but architectural issues can cause performance problems that pooling alone won't solve.

When to Reach for a Pooling Library vs. Rolling Your Own

The ObjectPool class above is intentionally minimal so you can understand exactly what it's doing and extend it for your project's needs. For larger projects, you might want:

  • Multiple pools managed through a central PoolManager keyed by prefab or tag
  • Pools that scale down (return objects to the system) after periods of low demand, to save memory
  • Integration with Unity's built-in UnityEngine.Pool namespace (available since Unity 2021), which provides ObjectPool<T> and IObjectPool<T> generics with hooks for OnGet, OnRelease, and OnDestroy callbacks

Unity's built-in generic pool is a solid option if you want less boilerplate:

using UnityEngine.Pool;

private IObjectPool<Bullet> bulletPool;

private void Awake()
{
    bulletPool = new ObjectPool<Bullet>(
        createFunc: () => Instantiate(bulletPrefab),
        actionOnGet: bullet => bullet.gameObject.SetActive(true),
        actionOnRelease: bullet => bullet.gameObject.SetActive(false),
        actionOnDestroy: bullet => Destroy(bullet.gameObject),
        collectionCheck: true,
        defaultCapacity: 20,
        maxSize: 100
    );
}
Enter fullscreen mode Exit fullscreen mode

Either approach works — the important part is committing to the pattern for any object type that spawns and despawns frequently during gameplay.

Building Pooling In From the Start

If you're starting a new mobile project, it's worth designing your spawn systems around pooling from day one rather than retrofitting it later, since retrofitting means touching every place in the codebase that currently calls Instantiate()/Destroy() directly. This is one of the reasons well-structured starting points matter so much for mobile projects — a codebase that already has clean spawn/despawn abstractions in place makes it trivial to swap in pooling later without a rewrite. If you're evaluating a foundation for your next project rather than building architecture from scratch, it's worth looking at prebuilt, production-ready Unity source code that already has these performance patterns baked in, so you're customizing a proven base instead of debugging allocation spikes for the first time yourself.

Pooling UI Elements

Object pooling isn't just for gameplay objects — it applies just as well to UI. Games with scrolling lists (leaderboards, inventories, shop items), floating combat text, or notification popups often instantiate and destroy UI elements constantly, which is just as costly as pooling gameplay prefabs, since Canvas rebuilds triggered by enabling/disabling or instantiating UI elements can be surprisingly expensive on mobile.

The same ObjectPool pattern works for UI prefabs with a couple of adjustments:

public GameObject GetUIElement(Transform parent)
{
    GameObject obj = Get();
    obj.transform.SetParent(parent, false);
    obj.transform.SetAsLastSibling();
    return obj;
}
Enter fullscreen mode Exit fullscreen mode

Setting worldPositionStays to false in SetParent avoids unwanted position/scale drift when moving pooled UI elements between containers, which is a common source of visual glitches when reusing UI prefabs across different parent transforms.

Troubleshooting: When Pooling Doesn't Help as Much as Expected

If you've implemented pooling but you're still seeing frame hitches, check these common culprits before assuming pooling failed:

  • String concatenation in Update() — building strings every frame (for score text, timers, debug labels) allocates memory just as much as Instantiate() does. Cache formatted strings and only rebuild them when the underlying value actually changes.
  • LINQ in hot paths — LINQ queries allocate enumerators and closures under the hood. Avoid them inside Update(), FixedUpdate(), or any per-frame spawn logic.
  • Boxing value types — passing structs into methods that expect object (common with some event systems or non-generic collections) silently allocates on the heap. Watch for this in custom event/messaging systems.
  • Physics queries without NonAlloc variantsPhysics.OverlapSphere and similar calls allocate an array every call. Use the NonAlloc versions (Physics.OverlapSphereNonAlloc) with a pre-allocated buffer instead.

Pooling solves the Instantiate/Destroy half of the allocation problem, but mobile performance work usually means hunting down all four of these categories together, not just one.

Wrapping Up

Object pooling is one of the rare optimizations that's both easy to implement and consistently high-impact, especially for mobile games with frequent spawn/despawn cycles. The pattern is simple: stop destroying objects, start recycling them. Pair it with regular profiling so you can confirm the GC.Alloc spikes are actually going away, and keep an eye out for related architectural issues that can cause similar frame-time problems even after pooling is in place.

The good news is that pooling is one of the few optimizations you can implement incrementally. You don't need to refactor your entire project in one pass — start with whatever object type spawns most frequently in your busiest scene, add pooling around it, and re-profile. Once you can see the GC.Alloc graph flatten out for that one system, apply the same pattern to the next hotspot. Most projects only need pooling around a handful of prefab types (bullets, enemies, hit-effects, and maybe UI popups) to eliminate the vast majority of spawn-related frame hitches.

If you implement pooling in your project, I'd love to hear what kind of frame-time improvement you saw — drop a comment below with your before/after profiler numbers.

Top comments (0)