← Back to Tutorials
Intermediate~14 min readUE 5.5 / 5.6

Object Pooling Patterns

Pooling reduces SpawnActor cost (CDO copy + component register + BeginPlay + replication channel open) by ~60% on community-measured projectile bursts. The wins are real but the bug surface is large — cached pointers, double-acquire, ProjectileMovement state retention, GAS attribute leakage. This tutorial covers actor pools, projectile pools, GAS pooling, and replication-aware patterns.

1

Why pooling on UE5

The SpawnActor cost breakdown:

  • CDO copy — creating the actor instance from defaults.
  • Component register — attaching components to the world.
  • BeginPlay — running per-actor + per-component init logic.
  • Replication channel open — if replicated, opens a network channel.

Community-reported numbers (Hadiosh, UE 5.3 — methodology not published): pooled spawn ~60% faster than SpawnActor; 100-projectile burst ~45 ms with hitch on SpawnActor vs ~6 ms stable when pooled. Treat as directional rather than authoritative — the magnitude is right (multi-x speedup), the precise percentage will vary heavily with what the actor's BeginPlay + components do. The orthogonal win is GC pressure: pooled objects don't enter GC, so a churn-heavy game stops paying for repeated mark/sweep cycles.

2

Subsystem choice (World vs GameInstance)

  • UWorldSubsystem — level-scoped, auto-tears-down. Right for projectile / VFX pools that don't need to survive level changes.
  • UGameInstanceSubsystem — cross-level persistence. Right for AI pools, character pools, long-lived game objects.
UProjectilePoolSubsystem.h
UCLASS()
class UProjectilePoolSubsystem : public UWorldSubsystem
{
    GENERATED_BODY()
public:
    virtual void Initialize(FSubsystemCollectionBase& Collection) override;
    virtual void Deinitialize() override;

    AProjectile* Acquire(TSubclassOf<AProjectile> Class, const FTransform& Transform);
    void Release(AProjectile* Projectile);

private:
    TMap<UClass*, TArray<AProjectile*>> FreePool;
    void PrewarmPool(TSubclassOf<AProjectile> Class, int32 Count);
};
3

The IPoolable interface contract

Per the BFObjectPooling pattern:

  • OnAcquiredFromPool() — called on pool reuse; reset state, activate components.
  • OnReleasedToPool() — called on pool return; deactivate components, clear references.
  • ResetForReuse() — explicit reset for predictable re-init.
  • Don't cache pooled handles outside lifetime — the same pointer can be re-acquired by another caller.

Use TWeakObjectPtr with a generation counter for any external reference to a pooled actor — or RAII-wrap with a FScopedPooledHandle that check()s the in-pool flag on access.

4

Lifecycle: hibernation pattern

Per Lyra's pooling pattern, the soft hibernate recipe on Release:

C++
void AProjectile::OnReleasedToPool()
{
    // Hide visually
    SetActorHiddenInGame(true);

    // Disable collision
    SetActorEnableCollision(false);

    // Stop ticking
    SetActorTickEnabled(false);

    // Detach from physics scene if simulating
    if (CollisionMesh->IsSimulatingPhysics())
    {
        CollisionMesh->SetSimulatePhysics(false);
    }

    // Stop ProjectileMovement explicitly
    if (ProjectileMovement)
    {
        ProjectileMovement->StopMovementImmediately();
        ProjectileMovement->HomingTargetComponent = nullptr;
        ProjectileMovement->SetActive(false);
    }
}
5

Warmup at level load

Pre-spawn pools in the subsystem's Initialize, but spread across frames to avoid load-time hitches:

C++ trickled warmup
void UProjectilePoolSubsystem::PrewarmPool(TSubclassOf<AProjectile> Class, int32 Count)
{
    // Spawn N actors across N frames using a Timer
    int32 Remaining = Count;
    GetWorld()->GetTimerManager().SetTimer(WarmupTimer, [this, Class, Remaining]() mutable
    {
        if (Remaining-- > 0)
        {
            AProjectile* New = GetWorld()->SpawnActor<AProjectile>(Class);
            New->OnReleasedToPool();
            FreePool.FindOrAdd(Class).Add(New);
        }
    }, 0.001f, true);
}

Per bennystarfighter's pool docs: pre-spawning >~200 actors hits a community-noted threshold where component auto-activate cost dominates warmup. Disable auto-activate on Niagara/audio components, activate in OnAcquiredFromPool.

6

Pool sizing strategy

  • 95th-percentile concurrent count + headroom — measure during gameplay; size pool to handle worst realistic burst.
  • Auto-grow vs fixed-cap — auto-grow handles unexpected bursts but never shrinks. Fixed-cap returns failures or stalls. Hybrid: warm size + bounded growth.
  • ~200-actor warmup ceiling — per community guidance; above this, component activation cost dominates.
7

Replication-aware pooling

Server-authoritative pools must be on the server only. Client-side pools must not allocate replicated UObjects. The pattern:

  • Use SetNetDormancy(DORM_DormantAll) on Release rather than Destroy(). The actor stays in the replication graph but doesn't replicate.
  • FlushNetDormancy() on Acquire to wake the actor for replication.
  • Don't toggle bHidden for replication — that flips relevancy churn. Dormancy is the correct primitive.
  • Channel-close cost — even pooled actors that survive a cycle keep their channel; clear any client-side caches keyed on AActor*.
8

GAS / AbilitySystemComponent pooling

ASC is non-persistent unless the owning pawn is pooled. On pooled-pawn release:

  • Reset attribute sets via RemoveActiveEffectsWithGrantedTags().
  • Clear active gameplay effects — without this, you get "ghost" buffs across pool cycles.
  • Reseat Owner / Avatar — ASC retains old pointers.

Per Vorixo's GAS guide: ASCs accumulate ActiveGameplayEffects and don't auto-reset.

9

ProjectileMovement reset trap

UProjectileMovementComponent retains last velocity and homing target across pool cycles. Per the Epic forums thread:

On Acquire
ProjectileMovement->StopMovementImmediately();
ProjectileMovement->HomingTargetComponent = nullptr;
ProjectileMovement->Velocity = LaunchVelocity;
ProjectileMovement->SetActive(false);
ProjectileMovement->SetActive(true, true); // reset=true; required for re-activation

The double SetActive call is from the community thread — Activate ignores re-activation if the component thinks it's already active. SetActive(false) first, then SetActive(true, /*bReset*/true).

10

Mass integration

For high-density actor pools (>500 entities), Mass Entity is often the better path:

  • IMassActorPoolableInterface on representation actors.
  • UMassActorSpawnerSubsystem recycle path.
  • Don't mix Mass with hand-rolled pools for the same content — Mass owns lifecycle.
MassEntity recycled actors remain valid UObject* Per Epic forums, event handlers comparing pointers across a Mass pool cycle will incorrectly match. Use generational handles, not raw pointers.

Pre-submit checklist

  1. External references use TWeakObjectPtr + version, not raw pointers.
  2. RAII handle wrapper with check() on the in-pool flag.
  3. ProjectileMovement explicitly reset on Release: stop, clear homing, deactivate.
  4. BehaviorTree / Blackboard cleared on Release. StopLogic(), clear all keys.
  5. AIController UnPossess if AI-controlled.
  6. GAS active effects swept on Release.
  7. Component auto-activate disabled for pooled-actor components; activate in OnAcquiredFromPool.
  8. Replicated pooled actors use dormancy, not visibility toggle.
  9. Pre-spawn count under ~200 per pool to avoid load-time hitch.

PerfGuard "pool-health scenario" warms pool at level start, fires N projectiles in fixed cadence, captures stat game SpawnActorTime, GC stall count, obj list UObject delta. CI catches three pool bug classes automatically: pool not warming (high SpawnActorTime on first burst), pool leak (UObject count drifts upward across cycles), double-acquire (collision/tick re-enable spikes inside a single hidden-state window).