Enemy bullets firing every frame in a bullet hell shooter. Over a hundred enemies in a tower defense game. Damage numbers popping on every hit. They're all doing the same thing: calling Spawn Actor from Class to create something and Destroy Actor to remove it, over and over, at high frequency.
At small scale this is fine. But as the create-and-destroy count climbs, the creation and destruction themselves become CPU load and cause hitches. Object pooling avoids that. Instead of building and discarding, you reuse a fixed set of actors. This article covers why Spawn/Destroy is expensive, what to stop when putting an actor to sleep, how checkout and return work, and why measuring comes before adopting any of it.
What You'll Learn
- Why
Spawn Actor/Destroy Actorare expensive: memory allocation and GC- The pooling idea: put actors to sleep and reuse them instead of destroying them
- What to stop when sleeping ( visibility, collision, Tick, and motion )
- Checking out from stock and returning with state reset
- Two approaches for when the pool runs empty
- Measure first, then adopt (avoiding premature optimization)
- Hands-on: pool your bullets and confirm the spawn count stops growing
Why Spawn and Destroy Are Expensive
Spawn Actor from Class looks like a single node. Internally, it does a fair amount of work.

Here's roughly what happens on each side.
| Timing | What happens |
|---|---|
| Spawn | Allocates new memory, registers components, and runs Event BeginPlay |
| Destroy | Marks the actor as pending destruction. Actual memory release is deferred |
The destruction side is the problem. Calling Destroy Actor doesn't clean up memory on the spot. UE runs garbage collection (GC) at intervals to reclaim destroyed objects in batches. Destroy actors continuously at high volume and processing stalls the moment GC runs, which you see as a hitch lasting several frames.
One or two actors is noise. But create and destroy dozens per frame like a bullet hell shooter and two things pile up.
- The CPU cost of creation and destruction itself lands on every frame (Game thread)
- A spike each time GC runs, appearing periodically
Both show up in Game in stat unit. How to read stat unit is covered in the measurement article. Pooling eliminates both at once by reducing creation and destruction to a single occurrence at startup.
The Pooling Idea: Sleep Instead of Destroy
The concept is simple. Create as many as you need up front, then reuse them instead of destroying them. When an actor is done, you don't destroy it. You just hide it and stop it.

A warehouse analogy helps. Ordering a fresh bullet from the factory every time you fire, then throwing it away on impact: that's Spawn/Destroy. Pooling keeps 30 bullets in a warehouse, takes one out when you fire, and puts it back on impact. The factory (memory allocation) and the disposal service (GC) only run when you build the initial 30.
The bullets in the warehouse aren't gone, they're asleep. They stay where they are, invisible, non-colliding, and motionless. When called for, you wake them and reposition them.
| Spawn / Destroy | Pooling | |
|---|---|---|
| Firing a bullet | Spawn Actor every time | Wake one from stock |
| Removing a bullet | Destroy Actor every time | Return it to the warehouse and sleep it |
| Memory allocation | Every time you fire | Once at startup |
| GC | Accumulates on every destroy | Never happens |
The idea of sleeping and waking is the same ground as "stop things that don't need to run" from Tick-Free Blueprint Design.
Sleeping: The Four Things to Stop
Turning "sleep" into concrete nodes means stopping four things. Forget any one of them and your supposedly sleeping bullet will be visible, or collide, or move.

| What to stop | Node | Sleep (return) | Wake (checkout) |
|---|---|---|---|
| Visibility | Set Actor Hidden In Game | New Hidden on | New Hidden off |
| Collision | Set Actor Enable Collision | off | on |
| Tick | Set Actor Tick Enabled | off | on |
| Motion (bullets only) | Projectile Movement | Stop Movement Immediately + component Tick off | Set Velocity again |
The first three are the standard set that applies to any pooled actor. Together they stop the visuals, the hit detection, and the per-frame processing.
Disabling Actor Tick Doesn't Stop Components
The fourth is where every beginner gets caught. Set Actor Tick Enabled does not stop the Projectile Movement Component.
That's because Set Actor Tick Enabled only stops the actor's own Tick, and components each have their own independent Tick. If Projectile Movement keeps running on a bullet you thought was asleep, it flies off invisibly and shows up in a strange position when you wake it.
For actors with motion components like bullets, stop those components separately. For Projectile Movement, call Stop Movement Immediately to zero the velocity and turn off Set Component Tick Enabled (targeting the Projectile Movement Component). When waking, do the reverse: turn Tick back on and set Velocity again in the firing direction.
Note: Forgetting to reset state on return is the single biggest source of pooling bugs. If velocity, effects, HP, or timers carry over from last time, a reused bullet will fly off in the previous direction, or vanish the instant it appears. Always include resetting your variables to their initial values in your sleep routine.
Checkout and Return
The pool's contents live in an Array. Just make one array of BP_Bullet and line up your stock in it. Working with arrays in general is covered in the Array/Map/Set article.

The logic splits into two functions.
Checkout (GetBullet) searches the array for one that's currently asleep and wakes it. The easiest sleep test is Get Actor Hidden In Game (hidden means asleep). Once found, set its location and rotation to the firing point and run the wake routine from the previous section.
Return (ReturnBullet) gets called when the bullet hits something or its lifetime expires. Run the sleep routine from the previous section, leave the position alone, and let it lie there.
One caveat: you can't use Initial Life Span on pooled bullets. Life Span calls Destroy Actor when time runs out, which would destroy your pool stock. Instead, set a Timer when waking the bullet, and have it call ReturnBullet rather than Destroy when the time is up. The goal of cleaning up stray bullets is the same; only the destination changes from destruction to return. The basics of Initial Life Span are in the projectiles article.
When the Pool Runs Empty
All 30 bullets are in use and you try to fire the 31st. What happens? Every pool needs a decided exhaustion policy. There are two options.
| Policy | Behavior | Where it fits |
|---|---|---|
| Grow the pool | Spawn one more and add it to the array | Easy when overflow is occasional. Stock grows only at peak |
| Reuse the oldest | Force the oldest in-use actor to return and wake it again | When you want a hard cap. Lets a shooter keep "at most N bullets on screen" |

For a bullet hell shooter where you want a cap on simultaneous actors, the latter fits. Old bullets get pushed out by new ones, so the actor count never runs away and the load stays predictable.
Building with "grow" first is practical: run it, watch how far the array stretches at peak, and use that as your initial size going forward.
Where to Put the Pool
You also need to decide where the pool manager (the array and the two functions) lives.
| Location | Characteristics |
|---|---|
A dedicated pool Actor (BP_BulletPool) | Place one in the level. Visible and easy to reason about. Plenty for a start |
| GameInstance Subsystem | Keeps the pool across levels. Good when multiple systems share it |
For solo development, a single dedicated Actor is the clearest and the one I'd recommend starting with. The shooter (your character) just gets that pool Actor from the level and calls GetBullet. If you later want to extract the whole pool as a reusable part, you can turn it into a component and attach it to other Actors.
Measure First. Pool Second
If you got this far and thought "my game fires bullets, so I'll pool from the start," stop for a moment. Pooling is an optimization you adopt after measuring and confirming you need it.
Two reasons.
- More code: compared to a straightforward Spawn/Destroy implementation, you add an array, checkout, return, and state reset. More parts to manage means more places for bugs
- It doesn't always help: in a game with only a few actors alive at once, creation and destruction costs are small to begin with, and pooling won't change how it feels. All you're left with is the management overhead
The right order is to build the straightforward version from the projectiles article first and measure with stat unit. Only once you've confirmed the Game thread is the bottleneck and per-frame creation and destruction is why do you swap in a pool. Adopt it because you measured it as slow, not because it looks slow. That applies to every optimization, not just bullets (see the measurement article). When stat alone can't confirm that Spawn Actor is really the cost, record a trace and check with Unreal Insights.
Hands-On: Pooling Bullets So Spawns Stop Growing
Enemy bullets in a bullet hell shooter, arrows in a tower defense game, magic projectiles in a top-down ARPG. Bullets created and destroyed in bulk are where pooling pays off regardless of genre. Here we'll put a straightforward Spawn/Destroy version next to a pooled version and see exactly how the stat unit numbers change.
The Finished Result
Holding the left mouse button fires bullets forward from your character in rapid succession. In the straightforward implementation, holding fire makes the Game thread wave up and down with a spike on every GC. In the pooled version, the same rapid fire keeps the Game thread flat, and the bullet count in the World Outliner stays at the pool size.

Setup
Create a new project from the Third Person template (Blueprint) and prepare these two assets. Building the bullet itself works the same as in the projectiles article, so only the differences are shown here.
1. The bullet (BP_Bullet)
| Component | Settings |
|---|---|
| Sphere Collision (root) | Sphere Radius 16.0 / Simulation Generates Hit Events on |
| Static Mesh (child) | Shape_Sphere / Scale 0.3 / Collision Presets NoCollision |
| Projectile Movement | Initial Speed 3000.0 / Max Speed 3000.0 / Gravity Scale 0.0 |
Add these variables to BP_Bullet.
| Variable | Type | Default | Purpose |
|---|---|---|---|
OwningPool | BP_BulletPool (object reference) | None | The pool to return to |
ReturnHandle | Timer Handle | — | Manages the auto-return timer |
Leave Initial Life Span at 0.0 (pools don't use Life Span).
2. The pool (BP_BulletPool)
Create it with Actor as the parent class and give it these variables.
| Variable | Type | Default | Purpose |
|---|---|---|---|
Pool | Array of BP_Bullet | Empty | The stock |
PoolSize | Integer | 30 | Initial stock count |
Place one of these pool Actors in the level.
The Pool's Graph
Build three things in BP_BulletPool: initialization, checkout, and sleep.

BP_BulletPool
Event BeginPlay
→ ForLoop (First Index 0, Last Index PoolSize - 1)
Loop Body →
Spawn Actor from Class (Class: BP_Bullet, Collision Handling: Always Spawn, Ignore Collisions)
Return Value →
Set (OwningPool = Self) // on the bullet's variable
→ Sleep (below) // sleep it right after creating it
→ Add (append to the Pool array)
Function GetBullet → return value BP_Bullet
→ ForEachLoop (Pool)
Loop Body →
Get Actor Hidden In Game (Array Element)
True (= asleep) → Return Node (Return Value: Array Element)
Completed (nobody is asleep = exhausted) →
Spawn Actor from Class (BP_Bullet) → Set OwningPool → Add (Pool) → Return
Function Sleep (parameter Target: BP_Bullet)
→ Set Actor Hidden In Game (Target, New Hidden: true)
→ Set Actor Enable Collision (Target, false)
→ Set Actor Tick Enabled (Target, false)
→ Stop Movement Immediately (Target → Projectile Movement)
→ Set Component Tick Enabled (Target → Projectile Movement, false)
BeginPlay spawns all 30 at once, sleeps them on the spot, and puts them in the array. The Spawn calls that cause allocation cost and feed GC happen exactly once, here. After that, GetBullet only wakes existing stock.
The Shooter and Bullet Graphs
In BP_ThirdPersonCharacter, check out a bullet from the pool and wake it.
BP_ThirdPersonCharacter
Left Mouse (Pressed)
→ Get the pool Actor (reference to the BP_BulletPool placed in the level)
→ GetBullet
Return Value (BP_Bullet) → call Activate (below)
On the bullet side, build Activate to wake it plus the paths that return it on hit or timeout.
BP_Bullet
Function Activate (parameters SpawnLocation, SpawnRotation)
→ Set Actor Location And Rotation (SpawnLocation, SpawnRotation)
→ Set Actor Hidden In Game (false)
→ Set Actor Enable Collision (true)
→ Set Actor Tick Enabled (true)
→ Set Component Tick Enabled (Projectile Movement, true)
→ Set Velocity (Projectile Movement)
= Get Actor Forward Vector × 3000.0
→ Set Timer by Function Name (Function: ReturnToPool, Time: 3.0)
Return Value → store into ReturnHandle
On Component Hit (Sphere)
→ ReturnToPool
Function ReturnToPool
→ Clear and Invalidate Timer by Handle (ReturnHandle)
→ OwningPool → Sleep (Target: Self)
Both paths, hitting something (On Component Hit) and not hitting anything for three seconds (Timer), go through ReturnToPool. The only difference from the straightforward implementation is that the destination is a return to the pool instead of Destroy.
Verifying
First, prepare the straightforward Spawn/Destroy version from the projectiles article for comparison, launch with Standalone Game, and run stat unitgraph. Hold the left mouse button and keep firing.
- Straightforward implementation: the Game thread line stays high and wavy, with a large spike each time GC runs. Watch the World Outliner and the bullet actor count keeps rising and falling
- Pooled implementation: with the same rapid fire, the Game thread line is flat. The bullet actor count sits at 30 and doesn't move
For example, a Game thread that centered around 2.5ms with periodic 7 to 9ms spikes in the straightforward version flattens out around 2.5ms with pooling (exact values vary by machine). The spikes disappearing is your proof that GC has been stopped.
If it isn't working, here's how to narrow it down.
- Bullets are visible from the start → you're not calling
Sleepright after creating them inBeginPlay. All 30 are sitting there on screen - Only the first shot fires →
GetBulletcan't find a sleeping bullet. Check thatSleepis turning onSet Actor Hidden In Game - Woken bullets don't fly, or sit still →
Activateisn't setting Velocity again. Also check that you turned the Projectile Movement Tick back on - Bullets fly in the previous direction → velocity isn't reset on return. Put
Stop Movement ImmediatelyinSleep - Something invisible is colliding → you forgot to turn off
Set Actor Enable Collision. Sleeping bullets still have collision - The pooled version still spikes → check whether
ReturnToPoolis callingDestroy Actor. A single remaining Destroy brings GC back
Two things to take away.
- Confine creation and destruction to "once, at the start": the essence of pooling is limiting
SpawntoBeginPlayinitialization and drivingDestroyto zero. Remove the per-frame create-and-destroy cycle and both the Game thread load and the GC spikes go with it - Write sleep and wake symmetrically: everything
Sleepstops (visibility, collision, Tick, motion, timers) gets restored one by one inActivate. Write only one side and you get invisible bullets that collide, or restored bullets that won't fly. Put the two functions side by side and check that what you stopped matches what you restore
Impact sparks and explosions are also a good fit for pooling. Effect actors and Niagara systems can be reused with the same idea (see the Niagara article).
Bonus: Good to Know for Later
- Niagara and audio have their own pooling: for particles, deactivating on the Niagara side and reusing is lighter than calling
Spawn System at Locationevery time. When you find a "part that gets created and destroyed a lot," check first whether the engine already has a reuse mechanism for it - Warm up the pool during a loading lull: creating 30 actors at once in
BeginPlayconcentrates the creation cost into that instant. Build them right after level start or behind a loading screen so the creation spike never lands mid-gameplay - Be careful pooling AI and characters: simple things like bullets, which hold very little state, are ideal for pooling. Characters carrying AI controllers and behavior trees make it easy to miss a state reset. Start with light, high-count things like bullets, effects, and damage numbers
- Missed returns become silent bugs: forget to call
ReturnToPoolon any code path and that bullet never sleeps, drops out of the stock, and the pool slowly drains. Enumerate every path that should return (hit, timeout, offscreen, level transition) and make sure they all converge onReturnToPool - Measure, then measure again: after pooling, run
stat unitonce more. With Game reduced, the GPU or rendering may now be your bottleneck. Optimization is a loop of measure, fix, measure again (see the measurement article)
Summary
- Repeated
Spawn Actor/Destroy Actorweighs down the Game thread through memory allocation and GC - Pooling keeps a fixed set of actors and sleeps them for reuse instead of destroying them, confining creation and destruction to a single occurrence at startup
- When sleeping, stop visibility, collision, and Tick, and for bullets also stop Projectile Movement's motion separately (disabling actor Tick doesn't stop components)
- Hold stock in an array and reset state on return. Forget and last run's state carries over
- Decide up front whether an empty pool grows or reuses the oldest
- Above all, measure before you adopt it. If the counts are small, you don't need it
What are you creating and destroying every frame in the game you're building right now? Start by building it straightforwardly, running stat unit, and confirming whether the Game thread really is heavy. If the real problem turns out to be slow loading or growing memory, head to Soft References and Async Loading next.