UE5 Soft References and Async Loading

Prove how UE5 pointer types and async loading affect memory in C++.

Introduction

30 Second Summary

Every open-world game you play is quietly loading and unloading thousands of assets behind the scenes. When a character equips a new weapon or walks into a new zone, the engine decides what to pull into memory and what to let go. Getting this wrong means stutters, bloated memory, or mysterious crashes in packaged builds.

In this project, you will build a series of focused C++ experiments inside a single Unreal Engine 5.7 project that prove exactly how each pointer type and async loading strategy behaves at the memory level.

What You'll Build

A set of isolated actor experiments that demonstrate hard vs soft references, async loading with handle lifecycle, the modern LoadAsync pattern, and soft class pointer registration for cooked builds.

By the end of this project, you'll have:

  • A ReferenceLabActor that demonstrates hard vs soft references side-by-side, verified with the Reference Viewer and obj list console command.
  • An AsyncLoadActor using FStreamableManager with proper FStreamableHandle lifecycle including load, release, cancel, and EndPlay cleanup.
  • A ModernLoadActor demonstrating the TSoftObjectPtr::LoadAsync pattern introduced in UE 5.6 for simpler single-asset loads.
  • A SoftClassActor demonstrating TSoftClassPtr with Asset Manager registration for cooked builds.
  • Secret Mission: Combine all patterns into a mini equip/unequip system that loads parts on demand and releases them on swap.

Are there any prerequisites?

You should be comfortable with UE5 C++ development basics like UPROPERTY macros, creating actor classes, and using BeginPlay. You also need Unreal Engine 5.7 and JetBrains Rider installed on your machine.

Set Up the RefLab Project

Before running any experiments, you need a sandbox project that already contains assets you can point at.

In this step, you will create a fresh C++ project in Unreal Engine with Starter Content enabled, open it in Rider, and set up an empty level for your tests.

In this step, get ready to:
  • Create a C++ project named RefLab with Starter Content enabled.
  • Verify the project compiles and Starter Content meshes are visible.
  • Create an empty test level called LabLevel.
Create a new C++ project in UE5.7

The Blank template gives you a clean slate with no gameplay logic. Enabling Starter Content adds pre-built StaticMesh assets (like chairs and couches) that you can reference in experiments without importing anything yourself.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Epic Games Launcher and press Enter to open it.
  • Navigate to the Unreal Engine tab on the left sidebar.
  • Click Launch next to your 5.7 engine version. The Project Browser will appear.
  • In the left column, select Games.
  • Select the Blank template.
  • Under Project Defaults, select C++ (not Blueprint).
  • Set the Starter Content option to With Starter Content.
  • In the Project Name field, type RefLab.
  • Click Create. Unreal will generate the project files and open the editor. This may take a minute while shaders compile.

Why Starter Content?

Starter Content includes StaticMesh assets like SM_Chair, SM_Couch, and SM_Door under Content/StarterContent/Props. These give you real assets to reference in your experiments without needing to import anything.

Project creation taking a long time?

The first time UE5 opens a new project, it compiles thousands of shaders. This can take several minutes depending on your hardware. Let it finish before proceeding.

If you see build errors, make sure you have the C++ development workload installed for your platform's compiler (Xcode Command Line Tools on macOS, MSVC on Windows).

Help me troubleshoot my UE5 project creation error.

Open the project in Rider and verify compilation

With the UE5 editor now open, you need to connect Rider so you can write C++ code. Rider can open the .uproject file directly without generating separate solution files.

  • In the Unreal Editor, click Edit in the top menu bar.
  • Select Editor Preferences.
  • In the left panel, navigate to General then Source Code.
  • In the Source Code Editor dropdown, select Rider (or Rider for Unreal if that option appears).
  • Click Tools in the top menu bar, then Open IDE. Rider will launch and load your RefLab project.

What if I don't see Rider in the dropdown?

Make sure Rider was installed via JetBrains Toolbox. Unreal detects Rider installations through the Toolbox registry entry. If you installed Rider manually, you may need to register its path in the RiderSourceCodeAccess plugin settings.

  • In Rider, wait for the project to finish indexing. Rider will prompt you to install the RiderLink plugin if it is not already installed. Accept the prompt.
  • Build the project by pressing Ctrl+Shift+B (Windows) or Cmd+Shift+B (macOS).
  • Confirm the build completes with no errors in the Build Output panel at the bottom.
  • Switch back to the Unreal Editor.
  • Open the Content Browser (if not already visible, go to Window then Content Browser).
  • Navigate to Content then StarterContent then Props.
  • Confirm you can see mesh assets like SM_Chair, SM_Couch, and SM_Door. These are the assets you will reference in later experiments.

Build failed in Rider?

Check that your Rider version supports Unreal Engine 5.7. Older Rider versions may not parse UE5.7 project files correctly.

If you see linker errors, close both Rider and the Unreal Editor, delete the Intermediate and Binaries folders inside your project directory, then reopen the project and rebuild.

Help me fix my Rider build errors for a UE5.7 C++ project.

Create an empty test level

Each experiment needs a clean level with no pre-existing actors or geometry. An empty level gives you full control over what gets loaded.

  • In the Unreal Editor, click File in the top menu bar.
  • Select New Level.
  • In the dialog that appears, choose Empty Level.
  • Save the level by pressing Ctrl+Shift+S (Windows) or Cmd+Shift+S (macOS).
  • In the Save dialog, name the level LabLevel and click Save.

Your viewport should now show a completely black scene with no objects. This is your test sandbox for all upcoming experiments.

Viewport is not empty?

If you see a sky or floor, you may have selected Basic or Time of Day instead of Empty Level in the New Level dialog. Delete the level, repeat the steps above, and make sure to select the Empty Level option.

My new level is not empty even though I selected Empty Level.

Your sandbox is ready. Next up, you will place your first actor and prove exactly how hard and soft references differ at the memory level.

Build the Hard vs Soft References Experiment

Your project is compiled and your test level is ready. Now it is time to explore the most fundamental question in Unreal Engine memory management: what actually happens when you reference an asset?

In this step, you will create an actor that holds two pointers to the same type of asset. One pointer is a hard reference (TObjectPtr). The other is a soft reference (TSoftObjectPtr). You will then prove their behavior difference using the Reference Viewer and console commands.

In this step, get ready to:
  • Create the AReferenceLabActor C++ class with hard and soft reference properties.
  • Place the actor in your level and verify dependencies in the Reference Viewer.
  • Confirm memory behavior using PIE and the obj list console command.
Create the AReferenceLabActor class

You need a simple actor that holds one hard reference and one soft reference to a StaticMesh. This gives you a controlled experiment where both pointers target the same asset type but behave differently at load time.

  • Switch to the Unreal Editor.
  • Click Tools in the top menu bar.
  • Click New C++ Class.
  • Select Actor as the parent class, then click Next.
  • Name the class ReferenceLabActor and click Create Class.

What does the C++ Class Wizard do?

The wizard generates a header (.h) and source (.cpp) file with UE5 boilerplate. It also registers the class in your project's build system so the Unreal Header Tool can process it.

  • The editor will compile automatically. Wait for the compilation to finish, then switch to Rider.
  • Open ReferenceLabActor.h in Rider. Replace the entire contents of the file with this code:
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "ReferenceLabActor.generated.h"

UCLASS()
class REFLAB_API AReferenceLabActor : public AActor
{
    GENERATED_BODY()

public:
    AReferenceLabActor();

protected:
    virtual void BeginPlay() override;

public:
    // Hard reference - forces the asset to load when this actor loads
    UPROPERTY(EditAnywhere, Category = "References")
    TObjectPtr<UStaticMesh> HardMesh;

    // Soft reference - stores only a path, does NOT force-load the asset
    UPROPERTY(EditAnywhere, Category = "References")
    TSoftObjectPtr<UStaticMesh> SoftMesh;

private:
    UPROPERTY(VisibleAnywhere)
    TObjectPtr<UStaticMeshComponent> MeshComponent;
};

What does this header declare?

HardMesh is a TObjectPtr. Any asset assigned to it becomes a hard dependency. The engine loads it automatically when the actor loads.

SoftMesh is a TSoftObjectPtr. It stores only a path string. The asset it points to does NOT load until you explicitly request it.

MeshComponent is a visual component so you can see the hard-referenced mesh rendered in the viewport.

  • Save ReferenceLabActor.h.

Now open ReferenceLabActor.cpp in Rider. You will build it in two parts: the constructor and BeginPlay.

  • Replace the contents of ReferenceLabActor.cpp with the constructor:
#include "ReferenceLabActor.h"

AReferenceLabActor::AReferenceLabActor()
{
    PrimaryActorTick.bCanEverTick = false;

    // Create a mesh component so the hard-referenced mesh renders in the viewport
    MeshComponent = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("MeshComponent"));
    RootComponent = MeshComponent;
}

What does the constructor do?

PrimaryActorTick.bCanEverTick = false disables per-frame ticking since this actor does not need it.

CreateDefaultSubobject creates the mesh component at construction time. Setting it as RootComponent makes it the actor's root in the scene hierarchy.

  • Add the BeginPlay implementation below the constructor:
void AReferenceLabActor::BeginPlay()
{
    Super::BeginPlay();

    // Apply the hard-referenced mesh to the component
    if (HardMesh)
    {
        MeshComponent->SetStaticMesh(HardMesh);
        UE_LOG(LogTemp, Warning, TEXT("HardMesh is loaded: %s"), *HardMesh->GetName());
    }

    // Attempt to access the soft-referenced mesh WITHOUT loading it
    UObject* SoftResult = SoftMesh.Get();
    if (SoftResult)
    {
        UE_LOG(LogTemp, Warning, TEXT("SoftMesh is already loaded: %s"), *SoftResult->GetName());
    }
    else
    {
        UE_LOG(LogTemp, Warning, TEXT("SoftMesh.Get() returned nullptr - asset is NOT loaded (only a path: %s)"), *SoftMesh.ToString());
    }
}

What does BeginPlay prove?

SoftMesh.Get() returns nullptr because soft references do not force-load assets. The asset at that path simply is not in memory yet.

HardMesh is already loaded because hard references are required dependencies. The engine loaded it alongside the actor.

  • Save ReferenceLabActor.cpp.
  • Compile the project in Rider (Ctrl+Shift+B on Windows or Cmd+Shift+B on macOS). Confirm you see a successful build message with zero errors.

Seeing a compile error?

Check that your class name in the .cpp matches AReferenceLabActor exactly (with the A prefix). Also verify that REFLAB_API in the header matches your project module name. If you named your project something other than RefLab, update the API macro accordingly.

help me fix my ReferenceLabActor compile error

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before continuing.

ⓧ I'd like to double check the full code

Here is the complete ReferenceLabActor.h:

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "ReferenceLabActor.generated.h"

UCLASS()
class REFLAB_API AReferenceLabActor : public AActor
{
    GENERATED_BODY()

public:
    AReferenceLabActor();

protected:
    virtual void BeginPlay() override;

public:
    UPROPERTY(EditAnywhere, Category = "References")
    TObjectPtr<UStaticMesh> HardMesh;

    UPROPERTY(EditAnywhere, Category = "References")
    TSoftObjectPtr<UStaticMesh> SoftMesh;

private:
    UPROPERTY(VisibleAnywhere)
    TObjectPtr<UStaticMeshComponent> MeshComponent;
};

And here is the complete ReferenceLabActor.cpp:

#include "ReferenceLabActor.h"

AReferenceLabActor::AReferenceLabActor()
{
    PrimaryActorTick.bCanEverTick = false;

    MeshComponent = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("MeshComponent"));
    RootComponent = MeshComponent;
}

void AReferenceLabActor::BeginPlay()
{
    Super::BeginPlay();

    if (HardMesh)
    {
        MeshComponent->SetStaticMesh(HardMesh);
        UE_LOG(LogTemp, Warning, TEXT("HardMesh is loaded: %s"), *HardMesh->GetName());
    }

    UObject* SoftResult = SoftMesh.Get();
    if (SoftResult)
    {
        UE_LOG(LogTemp, Warning, TEXT("SoftMesh is already loaded: %s"), *SoftResult->GetName());
    }
    else
    {
        UE_LOG(LogTemp, Warning, TEXT("SoftMesh.Get() returned nullptr - asset is NOT loaded (only a path: %s)"), *SoftMesh.ToString());
    }
}
Place the actor and inspect with the Reference Viewer

With the class compiled, you can place an instance in your level and observe how the engine tracks each reference type differently.

  • Switch back to the Unreal Editor.
  • Make sure LabLevel is open in the viewport.
  • In the Content Browser, expand C++ Classes and locate the RefLab folder.
  • Drag ReferenceLabActor from the Content Browser into the viewport to place it in the level.
  • With the actor selected, find the Details panel on the right side.
  • Under the References category, click the dropdown next to HardMesh and select SM_Chair from the Starter Content props.
  • Click the dropdown next to SoftMesh and select SM_Couch.
  • Save the level with Ctrl+S (Windows) or Cmd+S (macOS).

You should see the SM_Chair mesh rendered on the actor in the viewport. The SM_Couch is not visible because nothing has loaded it yet.

  • In the Content Browser, navigate to the folder where LabLevel is saved.
  • Right-click LabLevel and select Reference Viewer from the context menu.

In the Reference Viewer graph, you will see SM_Chair appear as a dependency line coming from the level. SM_Couch does NOT appear because it is a soft reference.

Why does SM_Couch not appear?

Hard references are required dependencies that must be loaded at the same time as the source asset. The Reference Viewer shows these as solid lines.

Soft references are just path strings. They do not create load-time dependencies. The cooker and the loader both ignore them unless you explicitly request a load.

Show soft references in the graph

Press S while the Reference Viewer is focused to toggle soft reference visibility. With soft references shown, you will see SM_Couch appear as a dashed line. This confirms the path is stored, but it is not a load dependency.

Verify memory behavior with PIE and obj list

The Reference Viewer shows you the dependency graph at the asset level. Now you will verify what the engine actually loaded into memory at runtime.

  • Click the Play button in the toolbar (or press Alt+P) to start PIE (Play In Editor).
  • Open the Output Log panel (Window > Output Log if it is not already visible).

You should see two log lines from your actor. The first confirms HardMesh is loaded. The second confirms SoftMesh.Get() returned nullptr.

  • While still in PIE, open the console by pressing the backtick key (`) or tilde (~).
  • Run the following command to list all StaticMesh objects currently in memory:
obj list class=StaticMesh

Scan the output. You will find SM_Chair listed because it was force-loaded as a hard dependency. SM_Couch will NOT appear because nothing has explicitly loaded it.

What is the core insight?

Soft references are just paths until you load them. They do not pull assets into memory. They do not create cook-time dependencies. They do not appear in the Reference Viewer's default dependency graph.

This is exactly what makes them essential for large projects. You control when memory gets used, instead of letting the engine load everything up front.

  • Press Esc to stop PIE.

Don't see the log messages?

Make sure the Output Log filter is set to show All or Warnings. The UE_LOG calls use the Warning verbosity level. Also confirm you assigned both meshes in the Details panel before playing.

help me debug my missing log output from AReferenceLabActor

You now have concrete proof that soft references are just paths. They cost zero memory until you explicitly load them. In the next step, you will learn how to actually load a soft-referenced asset asynchronously using FStreamableManager and control its lifetime with handles.

Implement FStreamableManager and Handle Lifecycle

You proved in the last step that soft references are just paths. They do not load anything into memory on their own.

The next question is: how do you actually load a soft-referenced asset at runtime? The answer is FStreamableManager. It gives you handle-based lifetime control over loaded assets, letting you request async loads, cancel in-progress loads, and release completed ones.

In this step, get ready to:
  • Create an AAsyncLoadActor with async load functionality using FStreamableManager.
  • Implement proper handle cleanup with CancelHandle, ReleaseHandle, and EndPlay.
  • Verify handle lifecycle behavior in PIE using Output Log and console commands.
Create AAsyncLoadActor with async loading

This actor will hold a soft reference to a mesh, load it asynchronously on demand, and store the returned handle to keep the asset alive.

  • In the UE5 editor, go to Tools > New C++ Class.
  • Select Actor as the parent class and click Next.
  • Name the class AsyncLoadActor and click Create Class.
  • Wait for Rider to reload the project files after the class is created.
  • In Rider, open AsyncLoadActor.h and replace its contents with the following:
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Engine/StreamableManager.h"
#include "AsyncLoadActor.generated.h"

UCLASS()
class REFLAB_API AAsyncLoadActor : public AActor
{
    GENERATED_BODY()

public:
    AAsyncLoadActor();

protected:
    virtual void BeginPlay() override;
    virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;

public:
    // The soft reference to load asynchronously
    UPROPERTY(EditAnywhere, Category = "Loading")
    TSoftObjectPtr<UStaticMesh> SoftMesh;

    // Trigger the async load
    UFUNCTION(BlueprintCallable, Category = "Loading")
    void LoadMesh();

    // Release a completed load (asset becomes GC-eligible)
    UFUNCTION(BlueprintCallable, Category = "Loading")
    void ReleaseMesh();

    // Cancel an in-progress load or release a completed one
    UFUNCTION(BlueprintCallable, Category = "Loading")
    void AbortLoad();

private:
    // Called when the async load completes
    void OnMeshLoaded();

    // Handle that keeps the loaded asset alive
    TSharedPtr<FStreamableHandle> LoadHandle;

    UPROPERTY(VisibleAnywhere)
    TObjectPtr<UStaticMeshComponent> MeshComponent;
};

What does this header declare?

  • TSoftObjectPtr<UStaticMesh> SoftMesh is the soft reference you will assign in the editor. It holds only a path until something loads it.
  • TSharedPtr<FStreamableHandle> LoadHandle stores the handle returned by the streaming system. As long as this handle is active, loaded assets stay in memory.
  • The three BlueprintCallable functions give you explicit control: load, release, and abort.
  • The Engine/StreamableManager.h include brings in FStreamableManager and FStreamableHandle types.
  • Save AsyncLoadActor.h.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Engine/StreamableManager.h"
#include "AsyncLoadActor.generated.h"

UCLASS()
class REFLAB_API AAsyncLoadActor : public AActor
{
    GENERATED_BODY()

public:
    AAsyncLoadActor();

protected:
    virtual void BeginPlay() override;
    virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;

public:
    UPROPERTY(EditAnywhere, Category = "Loading")
    TSoftObjectPtr<UStaticMesh> SoftMesh;

    UFUNCTION(BlueprintCallable, Category = "Loading")
    void LoadMesh();

    UFUNCTION(BlueprintCallable, Category = "Loading")
    void ReleaseMesh();

    UFUNCTION(BlueprintCallable, Category = "Loading")
    void AbortLoad();

private:
    void OnMeshLoaded();

    TSharedPtr<FStreamableHandle> LoadHandle;

    UPROPERTY(VisibleAnywhere)
    TObjectPtr<UStaticMeshComponent> MeshComponent;
};

Now you will implement the actor's constructor, BeginPlay, and the loading logic.

  • Open AsyncLoadActor.cpp in Rider and replace its contents with the includes, constructor, and BeginPlay:
#include "AsyncLoadActor.h"
#include "Engine/AssetManager.h"

AAsyncLoadActor::AAsyncLoadActor()
{
    PrimaryActorTick.bCanEverTick = false;

    // Create a mesh component to display the loaded asset
    MeshComponent = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("MeshComponent"));
    RootComponent = MeshComponent;
}

void AAsyncLoadActor::BeginPlay()
{
    Super::BeginPlay();
}

What does this code do?

  • The Engine/AssetManager.h include gives you access to UAssetManager::Get().GetStreamableManager() which is the global entry point for async loading.
  • The constructor creates a UStaticMeshComponent so you can visually confirm when a mesh loads.
  • BeginPlay is intentionally empty. You will trigger loading through a console command during testing.
  • Below the BeginPlay function, add the LoadMesh implementation:
void AAsyncLoadActor::LoadMesh()
{
    if (SoftMesh.IsNull())
    {
        UE_LOG(LogTemp, Warning, TEXT("SoftMesh path is null, nothing to load."));
        return;
    }

    // Get the global streamable manager from the Asset Manager singleton
    FStreamableManager& StreamableManager = UAssetManager::Get().GetStreamableManager();

    // Request an async load and bind our callback
    LoadHandle = StreamableManager.RequestAsyncLoad(
        SoftMesh.ToSoftObjectPath(),
        FStreamableDelegate::CreateUObject(this, &AAsyncLoadActor::OnMeshLoaded)
    );

    UE_LOG(LogTemp, Warning, TEXT("Async load requested for: %s"), *SoftMesh.ToString());
}

What does LoadMesh do?

RequestAsyncLoad takes the soft object path and a delegate for the completion callback. It returns a TSharedPtr<FStreamableHandle>. As long as you hold that handle, the loaded asset stays in memory and will not be garbage collected.

The delegate uses CreateUObject to bind to a member function. This ensures the callback fires on the game thread.

  • Below LoadMesh, add the OnMeshLoaded callback:
void AAsyncLoadActor::OnMeshLoaded()
{
    // After load completes, SoftMesh.Get() resolves to the loaded object
    UStaticMesh* Mesh = SoftMesh.Get();
    if (Mesh)
    {
        MeshComponent->SetStaticMesh(Mesh);
        UE_LOG(LogTemp, Warning, TEXT("Mesh loaded and applied: %s"), *Mesh->GetName());
    }
    else
    {
        UE_LOG(LogTemp, Error, TEXT("Async load completed but SoftMesh.Get() returned nullptr."));
    }
}

Why does SoftMesh.Get() work now?

In the previous step you saw that SoftMesh.Get() returns nullptr when the asset is not loaded. After RequestAsyncLoad completes, the asset exists in memory and the soft pointer resolves. This is the moment where the path becomes a real object.

  • Save AsyncLoadActor.cpp. Do not compile yet. You will add the cleanup functions next.
Implement proper handle cleanup

A handle that outlives its owner is a memory leak. A callback that fires on a destroyed actor is a crash. Proper cleanup requires three functions: one to release a completed load, one to abort an in-progress load, and one to run automatically when the actor is destroyed.

  • Below OnMeshLoaded in AsyncLoadActor.cpp, add the ReleaseMesh function:
void AAsyncLoadActor::ReleaseMesh()
{
    if (LoadHandle.IsValid() && LoadHandle->HasLoadCompleted())
    {
        // ReleaseHandle drops the handle's hold on the asset
        LoadHandle->ReleaseHandle();
        LoadHandle.Reset();
        MeshComponent->SetStaticMesh(nullptr);
        UE_LOG(LogTemp, Warning, TEXT("Handle released. Asset is now GC-eligible."));
    }
    else
    {
        UE_LOG(LogTemp, Warning, TEXT("Cannot release: handle invalid or load not complete."));
    }
}

What does ReleaseHandle do?

ReleaseHandle tells the streaming system that you no longer need the loaded asset. After this call, the asset becomes eligible for garbage collection. It does not unload the asset immediately. The next GC cycle will collect it if nothing else references it.

  • Below ReleaseMesh, add the AbortLoad and EndPlay functions:
void AAsyncLoadActor::AbortLoad()
{
    if (LoadHandle.IsValid())
    {
        if (!LoadHandle->HasLoadCompleted())
        {
            // CancelHandle stops in-progress loads and drops asset references
            LoadHandle->CancelHandle();
            UE_LOG(LogTemp, Warning, TEXT("In-progress load cancelled."));
        }
        else
        {
            // If already completed, release the hold instead
            LoadHandle->ReleaseHandle();
            UE_LOG(LogTemp, Warning, TEXT("Completed load released during abort."));
        }
        LoadHandle.Reset();
    }
}

void AAsyncLoadActor::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    // Prevent callbacks from firing on a destroyed actor
    AbortLoad();
    Super::EndPlay(EndPlayReason);
}

CancelHandle vs ReleaseHandle

CancelHandle stops in-progress loads and drops the handle's asset references. Use it when the load has not finished yet.

ReleaseHandle frees completed loads. Use it when the asset is loaded and you no longer need it.

Calling CancelHandle on a completed handle is a no-op but it still holds references. That is why AbortLoad checks HasLoadCompleted() and picks the right call.

  • Save AsyncLoadActor.cpp.
  • Compile the project in Rider (Ctrl+Shift+B on Windows, Cmd+Shift+B on macOS). Confirm the build succeeds with no errors.

Seeing a compile error?

  • If you see an unresolved symbol for UAssetManager, make sure you have #include "Engine/AssetManager.h" at the top of your .cpp file.
  • If FStreamableHandle or FStreamableDelegate is not found, verify you have #include "Engine/StreamableManager.h" in your header.
  • Double check your class name matches AAsyncLoadActor everywhere. A mismatch between the header declaration and the .cpp scope resolution will cause linker errors.

Help me fix my AAsyncLoadActor compile error

✔️ Awesome, I've got everything!

Great. Double check you have saved both files and the compile succeeded.

ⓧ I'd like to double check the full code

#include "AsyncLoadActor.h"
#include "Engine/AssetManager.h"

AAsyncLoadActor::AAsyncLoadActor()
{
    PrimaryActorTick.bCanEverTick = false;

    MeshComponent = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("MeshComponent"));
    RootComponent = MeshComponent;
}

void AAsyncLoadActor::BeginPlay()
{
    Super::BeginPlay();
}

void AAsyncLoadActor::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    AbortLoad();
    Super::EndPlay(EndPlayReason);
}

void AAsyncLoadActor::LoadMesh()
{
    if (SoftMesh.IsNull())
    {
        UE_LOG(LogTemp, Warning, TEXT("SoftMesh path is null, nothing to load."));
        return;
    }

    FStreamableManager& StreamableManager = UAssetManager::Get().GetStreamableManager();

    LoadHandle = StreamableManager.RequestAsyncLoad(
        SoftMesh.ToSoftObjectPath(),
        FStreamableDelegate::CreateUObject(this, &AAsyncLoadActor::OnMeshLoaded)
    );

    UE_LOG(LogTemp, Warning, TEXT("Async load requested for: %s"), *SoftMesh.ToString());
}

void AAsyncLoadActor::OnMeshLoaded()
{
    UStaticMesh* Mesh = SoftMesh.Get();
    if (Mesh)
    {
        MeshComponent->SetStaticMesh(Mesh);
        UE_LOG(LogTemp, Warning, TEXT("Mesh loaded and applied: %s"), *Mesh->GetName());
    }
    else
    {
        UE_LOG(LogTemp, Error, TEXT("Async load completed but SoftMesh.Get() returned nullptr."));
    }
}

void AAsyncLoadActor::ReleaseMesh()
{
    if (LoadHandle.IsValid() && LoadHandle->HasLoadCompleted())
    {
        LoadHandle->ReleaseHandle();
        LoadHandle.Reset();
        MeshComponent->SetStaticMesh(nullptr);
        UE_LOG(LogTemp, Warning, TEXT("Handle released. Asset is now GC-eligible."));
    }
    else
    {
        UE_LOG(LogTemp, Warning, TEXT("Cannot release: handle invalid or load not complete."));
    }
}

void AAsyncLoadActor::AbortLoad()
{
    if (LoadHandle.IsValid())
    {
        if (!LoadHandle->HasLoadCompleted())
        {
            LoadHandle->CancelHandle();
            UE_LOG(LogTemp, Warning, TEXT("In-progress load cancelled."));
        }
        else
        {
            LoadHandle->ReleaseHandle();
            UE_LOG(LogTemp, Warning, TEXT("Completed load released during abort."));
        }
        LoadHandle.Reset();
    }
}
Test the handle lifecycle in PIE

You will now place the actor, trigger each function, and verify memory behavior with console commands.

  • Switch back to the UE5 editor.
  • In the Content Browser, navigate to C++ Classes > RefLab.
  • Drag AsyncLoadActor into the LabLevel viewport.
  • With the actor selected, find the Loading category in the Details panel.
  • Set SoftMesh to a Starter Content mesh (e.g. SM_Door from StarterContent > Props).
  • Save the level with Ctrl+S (Windows) or Cmd+S (macOS).

To test LoadMesh, you will use the in-game console command ke * LoadMesh which calls a BlueprintCallable function on all actors that have it.

  • Click Play to enter PIE.
  • Press the backtick key (`) to open the in-game console.
  • Type the following command and press Enter:
ke * LoadMesh
  • Check the Output Log panel. You should see two messages: Async load requested for: followed by Mesh loaded and applied:.
  • Look at the viewport. The mesh should now be visible on the actor.

Now test that ReleaseMesh drops the handle and makes the asset GC-eligible.

  • Open the console again and run:
ke * ReleaseMesh
  • Confirm the Output Log shows Handle released. Asset is now GC-eligible..
  • The mesh disappears from the viewport because the component was cleared.
  • Now force a garbage collection cycle by running this in the console:
obj gc
  • After GC, check what meshes are still in memory by running:
obj list class=StaticMesh
  • Look through the output. The mesh you assigned to SoftMesh (e.g. SM_Door) should no longer appear in the list. This proves the handle was the only thing keeping it alive.

Key insight: the handle keeps assets alive

FStreamableHandle is the anchor. While you hold it, the loaded asset cannot be garbage collected. ReleaseHandle drops that anchor. CancelHandle stops an in-progress load and drops the anchor at the same time. EndPlay calls AbortLoad automatically, preventing crashed callbacks on a destroyed actor.

  • Stop PIE by pressing Esc or clicking the Stop button.

Mesh still shows in obj list after ReleaseMesh?

  • Make sure you ran obj gc before running obj list class=StaticMesh. Without forcing GC, the asset lingers even after the handle is released.
  • If the hard-referenced mesh from your AReferenceLabActor is the same as the one you assigned to AAsyncLoadActor, it will stay loaded because the hard reference keeps it alive. Use a different Starter Content mesh (like SM_Door) to see a clean disappearance.
  • If the console command ke * LoadMesh produces no output, verify that the actor is placed in the level and that you compiled successfully before entering PIE.

Help me debug my AAsyncLoadActor handle lifecycle test

You now have a working async loading pipeline with proper handle lifecycle. Next up, you will explore the modern TSoftObjectPtr::LoadAsync pattern that simplifies single-asset loads.

Use the Modern TSoftObjectPtr::LoadAsync Pattern

In the previous step you built async loading the foundational way, managing an FStreamableManager and an FStreamableHandle yourself. That gives you fine-grained control over cancellation and release. But for simple one-off loads, it is more ceremony than you need.

UE 5.6 introduced a LoadAsync method directly on TSoftObjectPtr. It eliminates the need to manually obtain the FStreamableManager. In this step you will build an actor that uses the modern pattern and compare it head-to-head with the handle-based approach.

In this step, get ready to:
  • Create an AModernLoadActor that uses TSoftObjectPtr::LoadAsync with a lambda callback.
  • Store a strong reference to keep the loaded asset alive without a handle.
  • Compare the modern pattern against FStreamableManager in PIE.
Create the ModernLoadActor header

This actor needs the same TSoftObjectPtr<UStaticMesh> you used before, plus a TObjectPtr<UStaticMesh> to hold a strong reference after loading. The strong reference is what keeps the asset alive instead of a handle.

  • In Rider, create a new C++ class derived from AActor called ModernLoadActor.
  • Replace the generated contents of ModernLoadActor.h with the following:
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "UObject/SoftObjectPtr.h"
#include "ModernLoadActor.generated.h"

UCLASS()
class REFLAB_API AModernLoadActor : public AActor
{
    GENERATED_BODY()

public:
    AModernLoadActor();

protected:
    virtual void BeginPlay() override;

public:
    // The soft pointer stores only a path until we explicitly load it
    UPROPERTY(EditAnywhere, Category = "Loading")
    TSoftObjectPtr<UStaticMesh> SoftMesh;

    UFUNCTION(BlueprintCallable, Category = "Loading")
    void LoadMesh();

    UFUNCTION(BlueprintCallable, Category = "Loading")
    void UnloadMesh();

private:
    // Strong reference keeps the loaded asset alive (replaces handle)
    UPROPERTY()
    TObjectPtr<UStaticMesh> LoadedMesh;

    UPROPERTY(VisibleAnywhere)
    TObjectPtr<UStaticMeshComponent> MeshComponent;
};

What does this header declare?

The key difference from AAsyncLoadActor is the absence of TSharedPtr<FStreamableHandle>. Instead, LoadedMesh (a TObjectPtr) acts as the strong reference that prevents garbage collection.

Notice the include for UObject/SoftObjectPtr.h. This header provides the LoadAsync method on TSoftObjectPtr.

  • Save the header file.
Implement the actor skeleton
  • Open ModernLoadActor.cpp and replace its contents with the constructor and BeginPlay by pasting this code:
#include "ModernLoadActor.h"

AModernLoadActor::AModernLoadActor()
{
    PrimaryActorTick.bCanEverTick = false;

    // Create the mesh component to visualize loaded assets
    MeshComponent = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("MeshComponent"));
    RootComponent = MeshComponent;
}

void AModernLoadActor::BeginPlay()
{
    Super::BeginPlay();
}

What does this code do?

This is the same boilerplate pattern you used in AAsyncLoadActor. The constructor creates the mesh component, and BeginPlay stays empty because you will trigger loads manually.

Implement LoadMesh with the LoadAsync pattern

The LoadAsync method lives directly on the soft pointer. You pass it an FLoadSoftObjectPathAsyncDelegate bound to a lambda. When the load finishes, the delegate fires with the loaded path so you can resolve the object.

  • Add the LoadMesh function below BeginPlay by pasting this code:
void AModernLoadActor::LoadMesh()
{
    if (SoftMesh.IsNull())
    {
        UE_LOG(LogTemp, Warning, TEXT("SoftMesh is null, nothing to load."));
        return;
    }

    // If the asset is already loaded (e.g., by another actor), grab it immediately
    if (SoftMesh.Get())
    {
        LoadedMesh = SoftMesh.Get();
        MeshComponent->SetStaticMesh(LoadedMesh);
        UE_LOG(LogTemp, Warning, TEXT("Asset was already loaded: %s"), *LoadedMesh->GetName());
        return;
    }

    // Modern pattern: call LoadAsync directly on the soft pointer
    SoftMesh.LoadAsync(
        FLoadSoftObjectPathAsyncDelegate::CreateLambda([this](const FSoftObjectPath& Path)
        {
            UStaticMesh* Mesh = Cast<UStaticMesh>(Path.ResolveObject());
            if (Mesh)
            {
                // Store strong ref to prevent GC (no handle needed)
                LoadedMesh = Mesh;
                MeshComponent->SetStaticMesh(LoadedMesh);
                UE_LOG(LogTemp, Warning, TEXT("LoadAsync completed: %s"), *Mesh->GetName());
            }
            else
            {
                UE_LOG(LogTemp, Error, TEXT("LoadAsync completed but resolve failed for: %s"), *Path.ToString());
            }
        })
    );

    UE_LOG(LogTemp, Warning, TEXT("LoadAsync initiated for: %s"), *SoftMesh.ToString());
}

What's happening here?

  • The null check and already-loaded early return handle edge cases before starting the async operation.
  • SoftMesh.LoadAsync() triggers the async load without you needing to obtain the FStreamableManager yourself.
  • The delegate receives a const FSoftObjectPath&. You call Path.ResolveObject() to get the loaded UObject, then Cast it to the expected type.
  • Storing the result in LoadedMesh (a UPROPERTY strong reference) is what keeps the asset alive. Without a handle, this is your only GC protection.

Why no FStreamableHandle here?

LoadAsync does not return a handle. The delegate fires on the game thread when the asset is ready (or if it fails). You trade handle-based lifetime control for simplicity. If you need cancellation or batch loading, stick with FStreamableManager.

Implement UnloadMesh

Without a handle, unloading is straightforward. Null the strong reference and the asset becomes GC-eligible.

  • Add the UnloadMesh function below LoadMesh by pasting this code:
void AModernLoadActor::UnloadMesh()
{
    // Clear strong ref so GC can reclaim the asset
    LoadedMesh = nullptr;
    MeshComponent->SetStaticMesh(nullptr);
    UE_LOG(LogTemp, Warning, TEXT("Strong reference cleared. Asset is now GC-eligible."));
}

What does this code do?

Setting LoadedMesh to nullptr removes the only strong reference keeping the asset alive. Once garbage collection runs, the asset is eligible for collection. Compare this to AAsyncLoadActor where you had to call ReleaseHandle() and Reset() on the handle.

  • Save ModernLoadActor.cpp.
  • Compile the project in Rider. Confirm no errors in the build output.

Seeing a compile error about FLoadSoftObjectPathAsyncDelegate?

  • Make sure your header includes #include "UObject/SoftObjectPtr.h". This is where the delegate type is declared.
  • Confirm you are targeting UE 5.7. The LoadAsync method was introduced in UE 5.6.

help me fix a compile error in ModernLoadActor

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "UObject/SoftObjectPtr.h"
#include "ModernLoadActor.generated.h"

UCLASS()
class REFLAB_API AModernLoadActor : public AActor
{
    GENERATED_BODY()

public:
    AModernLoadActor();

protected:
    virtual void BeginPlay() override;

public:
    UPROPERTY(EditAnywhere, Category = "Loading")
    TSoftObjectPtr<UStaticMesh> SoftMesh;

    UFUNCTION(BlueprintCallable, Category = "Loading")
    void LoadMesh();

    UFUNCTION(BlueprintCallable, Category = "Loading")
    void UnloadMesh();

private:
    UPROPERTY()
    TObjectPtr<UStaticMesh> LoadedMesh;

    UPROPERTY(VisibleAnywhere)
    TObjectPtr<UStaticMeshComponent> MeshComponent;
};
#include "ModernLoadActor.h"

AModernLoadActor::AModernLoadActor()
{
    PrimaryActorTick.bCanEverTick = false;

    MeshComponent = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("MeshComponent"));
    RootComponent = MeshComponent;
}

void AModernLoadActor::BeginPlay()
{
    Super::BeginPlay();
}

void AModernLoadActor::LoadMesh()
{
    if (SoftMesh.IsNull())
    {
        UE_LOG(LogTemp, Warning, TEXT("SoftMesh is null, nothing to load."));
        return;
    }

    if (SoftMesh.Get())
    {
        LoadedMesh = SoftMesh.Get();
        MeshComponent->SetStaticMesh(LoadedMesh);
        UE_LOG(LogTemp, Warning, TEXT("Asset was already loaded: %s"), *LoadedMesh->GetName());
        return;
    }

    SoftMesh.LoadAsync(
        FLoadSoftObjectPathAsyncDelegate::CreateLambda([this](const FSoftObjectPath& Path)
        {
            UStaticMesh* Mesh = Cast<UStaticMesh>(Path.ResolveObject());
            if (Mesh)
            {
                LoadedMesh = Mesh;
                MeshComponent->SetStaticMesh(LoadedMesh);
                UE_LOG(LogTemp, Warning, TEXT("LoadAsync completed: %s"), *Mesh->GetName());
            }
            else
            {
                UE_LOG(LogTemp, Error, TEXT("LoadAsync completed but resolve failed for: %s"), *Path.ToString());
            }
        })
    );

    UE_LOG(LogTemp, Warning, TEXT("LoadAsync initiated for: %s"), *SoftMesh.ToString());
}

void AModernLoadActor::UnloadMesh()
{
    LoadedMesh = nullptr;
    MeshComponent->SetStaticMesh(nullptr);
    UE_LOG(LogTemp, Warning, TEXT("Strong reference cleared. Asset is now GC-eligible."));
}
Test the modern pattern in PIE
  • Switch back to the UE5 editor.
  • Open LabLevel if it is not already open.
  • Place an AModernLoadActor into the level by dragging it from the Place Actors panel (search for ModernLoadActor).
  • In the Details panel, set SoftMesh to a Starter Content mesh (e.g., SM_Door under StarterContent > Props).
  • Save the level.

Now trigger the load. Since LoadMesh is marked BlueprintCallable, you can call it from BeginPlay with a timer delay or bind it to a key input. The simplest approach for this experiment is to call it directly from BeginPlay.

  • In ModernLoadActor.cpp, find the BeginPlay function. Add a call to LoadMesh() after Super::BeginPlay() for testing purposes.
  • Compile and PIE (Play In Editor).

Check the Output Log. You should see two messages in sequence: LoadAsync initiated for: followed by LoadAsync completed: with the mesh name. The mesh should appear on the actor in the viewport.

  • Open the console (press ~) and run:
obj list class=StaticMesh

Your assigned mesh should appear in the list because LoadedMesh holds it alive.

  • Now test unloading. Call UnloadMesh (either by adding a timer delay in BeginPlay or by binding it to a key). After calling it, run obj gc in the console to force garbage collection, then run obj list class=StaticMesh again.

The mesh should disappear from the actor and become eligible for collection. The Output Log should show Strong reference cleared. Asset is now GC-eligible..

Mesh not appearing after PIE?

  • Make sure you added the LoadMesh() call inside BeginPlay. Without triggering it, the actor has no reason to load anything.
  • Check the Output Log for SoftMesh is null, nothing to load. If you see this, you did not assign a mesh to SoftMesh in the Details panel.
  • If the log shows LoadAsync completed but resolve failed, confirm the path is valid. Reassign the mesh in the Details panel.

help me debug why my ModernLoadActor mesh is not loading

When to use each pattern

FStreamableManager is better for batch loads, combined handles, and when you need explicit cancellation (e.g., aborting loads during EndPlay, swapping equipment mid-load).

TSoftObjectPtr::LoadAsync is simpler for single-asset loads where you just need the object and will hold it via a strong reference. No handle management overhead.

Both are valid production patterns. Choose based on whether you need handle lifecycle control.

  • Remove the test LoadMesh() call from BeginPlay so it is empty again (just Super::BeginPlay()). Save the file.

You now have two proven async loading patterns in your toolkit. Next up, you will tackle TSoftClassPtr and discover why soft class references silently fail in packaged builds without Asset Manager registration.

Implement TSoftClassPtr and Asset Manager Registration

You now have two async loading patterns in your toolkit. In the last step, you proved that LoadAsync offers a simpler single-asset load path while FStreamableManager gives you handle-based lifetime control.

This step tackles a different problem: loading entire Blueprint classes at runtime. TSoftClassPtr stores a path to a UClass instead of a UObject. The critical catch is that soft class references are invisible to Unreal's packaging system. Without explicit registration, your Blueprint won't make it into the final build.

In this step, get ready to:
  • Create a Blueprint actor and async-load its class at runtime using TSoftClassPtr.
  • Register the asset with Asset Manager so packaged builds include it.
  • Add production-ready fallback handling for load failures.
Create ASoftClassActor with TSoftClassPtr

Before writing the C++ class, you need a Blueprint actor to reference. This gives you something to async-load and spawn at runtime.

  • In the Unreal Editor Content Browser, right-click and select New Folder.
  • Name the folder Accessories.
  • Right-click inside the Accessories folder and select Blueprint Class.
  • Choose Actor as the parent class.
  • Name it BP_Accessory.
  • Double-click BP_Accessory to open the Blueprint Editor.
  • Click Add Component and choose Static Mesh.
  • In the Details panel, set the Static Mesh to any Starter Content mesh (e.g., SM_TableRound).
  • Click Compile, then Save.

Why a Blueprint actor?

TSoftClassPtr references a UClass at the path level. Blueprints are the most common use case because they package gameplay logic, components, and art into a single spawnable class. Loading them on demand avoids pulling everything into memory at level load.

Now create the C++ actor that will async-load this Blueprint class. In the Unreal Editor, create a new C++ class:

  • Click Tools in the top menu bar, then New C++ Class.
  • Select Actor as the parent class and click Next.
  • Name the class SoftClassActor and click Create Class.
  • Switch to Rider. The generated SoftClassActor.h and SoftClassActor.cpp files should appear automatically.
  • Replace the contents of SoftClassActor.h with the following:
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Engine/StreamableManager.h"
#include "SoftClassActor.generated.h"

UCLASS()
class REFLAB_API ASoftClassActor : public AActor
{
    GENERATED_BODY()

public:
    ASoftClassActor();

protected:
    virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;

public:
    // Soft reference to a Blueprint class - just a path until loaded
    UPROPERTY(EditAnywhere, Category = "Loading")
    TSoftClassPtr<AActor> AccessoryClass;

    // Hard reference fallback used when async load fails
    UPROPERTY(EditAnywhere, Category = "Loading")
    TSubclassOf<AActor> FallbackClass;

    UFUNCTION(BlueprintCallable, Category = "Loading")
    void LoadAndSpawn();

private:
    void OnClassLoaded();

    TSharedPtr<FStreamableHandle> ClassHandle;

    UPROPERTY()
    TObjectPtr<AActor> SpawnedAccessory;
};

What does this header declare?

  • TSoftClassPtr<AActor> AccessoryClass stores a soft path to any actor-derived Blueprint class. It does not force the engine to load that class at level start.
  • TSubclassOf<AActor> FallbackClass is a hard reference to a default class. If the async load fails, you spawn this instead.
  • TSharedPtr<FStreamableHandle> ClassHandle keeps the loaded class alive in memory and gives you cancel/release control.
  • TObjectPtr<AActor> SpawnedAccessory holds the spawned actor instance to prevent garbage collection.
  • Now replace the contents of SoftClassActor.cpp with the includes, constructor, and EndPlay cleanup:
#include "SoftClassActor.h"
#include "Engine/AssetManager.h"

ASoftClassActor::ASoftClassActor()
{
    PrimaryActorTick.bCanEverTick = false;
}

void ASoftClassActor::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    // Cancel or release the handle to prevent callbacks on a destroyed actor
    if (ClassHandle.IsValid())
    {
        if (!ClassHandle->HasLoadCompleted())
        {
            ClassHandle->CancelHandle();
        }
        else
        {
            ClassHandle->ReleaseHandle();
        }
        ClassHandle.Reset();
    }
    Super::EndPlay(EndPlayReason);
}

Why EndPlay cleanup matters for class loading

If the actor is destroyed while the class is still loading, the delegate would fire on a dead object. EndPlay checks whether the load finished: if not, it cancels. If it did finish, it releases the handle so the loaded class becomes GC-eligible. This is the same lifecycle pattern you built in AAsyncLoadActor.

  • Add the LoadAndSpawn function below EndPlay in SoftClassActor.cpp:
void ASoftClassActor::LoadAndSpawn()
{
    if (AccessoryClass.IsNull())
    {
        UE_LOG(LogTemp, Warning, TEXT("AccessoryClass is null."));
        return;
    }

    // Get the streamable manager and request async load of the class path
    FStreamableManager& SM = UAssetManager::Get().GetStreamableManager();

    ClassHandle = SM.RequestAsyncLoad(
        AccessoryClass.ToSoftObjectPath(),
        FStreamableDelegate::CreateUObject(this, &ASoftClassActor::OnClassLoaded)
    );

    UE_LOG(LogTemp, Warning, TEXT("Async class load requested: %s"), *AccessoryClass.ToString());
}

What does LoadAndSpawn do?

It converts the TSoftClassPtr to a FSoftObjectPath via ToSoftObjectPath() and hands it to the FStreamableManager. When the class is loaded into memory, OnClassLoaded fires on the game thread.

The returned handle is stored in ClassHandle so the loaded class stays alive and you retain cancel/release control.

  • Add the OnClassLoaded callback below LoadAndSpawn in SoftClassActor.cpp:
void ASoftClassActor::OnClassLoaded()
{
    UClass* LoadedClass = AccessoryClass.Get();

    // Fallback: if the soft class failed to load, use the hard-referenced fallback
    if (!LoadedClass)
    {
        UE_LOG(LogTemp, Error, TEXT("Class load failed. Using fallback."));
        LoadedClass = FallbackClass;
    }

    // Spawn the loaded (or fallback) class above this actor
    if (LoadedClass && GetWorld())
    {
        FVector SpawnLocation = GetActorLocation() + FVector(0.f, 0.f, 100.f);
        SpawnedAccessory = GetWorld()->SpawnActor<AActor>(LoadedClass, SpawnLocation, FRotator::ZeroRotator);
        UE_LOG(LogTemp, Warning, TEXT("Spawned: %s"), *SpawnedAccessory->GetName());
    }
}

What does OnClassLoaded do?

Once the async load completes, AccessoryClass.Get() resolves the soft path to a live UClass*. If it returns nullptr (the load failed), the code falls through to the hard-referenced FallbackClass. Either way, it spawns the actor 100 units above the current position.

  • Save both files in Rider.
  • Build the project by selecting Build > Build Startup Project in Rider's main menu (or press Cmd+B on macOS / Ctrl+B on Windows).
  • Confirm the build succeeds with no errors in the Build Output panel.

Build failing with unresolved symbols?

  • Make sure #include "Engine/StreamableManager.h" is in your header file. Without it, FStreamableHandle and FStreamableDelegate are undefined.
  • Verify #include "Engine/AssetManager.h" is in the .cpp file. This provides UAssetManager::Get().
  • Check that the class name uses the A prefix in the .cpp implementations (ASoftClassActor::) but not in the file name.

Ask for help: My SoftClassActor won't compile. Here are my error messages.

Test in PIE and register with Asset Manager

The code compiles, but there is a hidden problem. In the editor, everything works because all content is available in memory. In a packaged (cooked) build, soft references are invisible to the cooker. Without Asset Manager registration, BP_Accessory would not be included in the .pak file and the load would silently fail.

  • Switch back to the Unreal Editor.
  • Drag ASoftClassActor from the Place Actors panel into your LabLevel.
  • Select the actor in the viewport, then in the Details panel, set AccessoryClass to BP_Accessory.
  • Set FallbackClass to any default actor class (e.g., the built-in StaticMeshActor).
  • Save the level with Ctrl+S (Windows) or Cmd+S (macOS).

Now test that the async class load works in the editor.

  • Click Play to start PIE.
  • Open the console with the ~ key (backtick).
  • Call LoadAndSpawn on the actor. You can bind it to BeginPlay temporarily, or use the console command ke * LoadAndSpawn to call it on all actors with that function.
  • Observe the Output Log. You should see the "Async class load requested" message followed by "Spawned:" with the instance name.
  • Look at the viewport. A new actor (the BP_Accessory with its mesh) should appear above ASoftClassActor's position.
  • Stop PIE.

Why does this work in-editor but would fail in a package?

During PIE, all Content Browser assets are available in memory. The cooker (packaging system) only includes assets that have a hard dependency chain from the level or an explicit Asset Manager registration. Soft references deliberately break that chain. Without registration, the cooked build simply omits BP_Accessory from the .pak file.

Register the asset so it survives packaging.

  • In the Unreal Editor, open Edit > Project Settings.
  • In the left sidebar, scroll down to Game and select Asset Manager.
  • Under Primary Asset Types To Scan, click the + button to add a new entry.
  • Set Primary Asset Type to Accessory.
  • Set Asset Base Class to AActor.
  • Check the Has Blueprint Classes checkbox.
  • Under Directories, add /Game/Accessories.
  • Set Cook Rule to AlwaysCook.

What does Asset Manager registration do?

This tells the cooker to scan /Game/Accessories for Blueprint actors and include them in the final build even though no hard reference chain leads to them. The AlwaysCook rule ensures they ship regardless of whether any level references them directly.

Without this entry, your LoadAndSpawn call would receive nullptr in a packaged build because the asset simply would not exist on disk.

Don't see the spawned actor in PIE?

  • Check that AccessoryClass in the Details panel actually points to BP_Accessory. If it shows None, the soft reference is null and LoadAndSpawn returns early.
  • Make sure BP_Accessory has a visible Static Mesh Component with a mesh assigned. Without geometry, the actor spawns but is invisible.
  • Look 100 units above the SoftClassActor. The spawn location is offset upward, so the spawned actor may be outside your current camera view.

Ask for help: My SoftClassActor's LoadAndSpawn isn't spawning anything in PIE.

Understand the fallback pattern for production builds

The OnClassLoaded callback you wrote already includes fallback handling. This is the production pattern: always handle load failure gracefully.

  • In Rider, open SoftClassActor.cpp and find the OnClassLoaded function.
  • Notice the if (!LoadedClass) check that falls through to FallbackClass. In a packaged build without Asset Manager registration, this is where execution would land.

Why always handle load failure?

Soft class references are invisible to the cooker. Even with Asset Manager registration, future refactors (renamed assets, moved folders, deleted Blueprints) can break the path at runtime. The fallback pattern ensures your game never crashes or silently does nothing. Instead, it spawns a placeholder and logs an error you can investigate.

This is the key learning for this step: TSoftClassPtr requires both Asset Manager registration (to include the asset in cooked builds) and fallback handling (to survive broken paths gracefully).

Verify the fallback works by temporarily pointing the reference at a nonexistent class.

  • In the Unreal Editor, select your ASoftClassActor in the level.
  • Clear the AccessoryClass field (set it to None), but keep FallbackClass set.
  • PIE and trigger LoadAndSpawn.
  • Check the Output Log. You should see the early return log "AccessoryClass is null" because IsNull() catches the empty path before it even attempts to load.
  • Stop PIE and set AccessoryClass back to BP_Accessory.
  • Save the level.

Testing the fallback path in a real scenario

To truly test the if (!LoadedClass) fallback branch, you would need to package the build without Asset Manager registration and point at a valid-looking path that is not cooked. In PIE, AccessoryClass.Get() always succeeds for valid content because the editor has all assets loaded. The IsNull() early return is a different guard. For now, trust that the pattern is correct and remember to verify in a cooked build when you ship.

✔️ Awesome, I've got everything!

Great. Double check you have saved both SoftClassActor.h and SoftClassActor.cpp, and that the Asset Manager entry is configured in Project Settings.

ⓧ I'd like to double check the full code

Here is the complete SoftClassActor.h:

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Engine/StreamableManager.h"
#include "SoftClassActor.generated.h"

UCLASS()
class REFLAB_API ASoftClassActor : public AActor
{
    GENERATED_BODY()

public:
    ASoftClassActor();

protected:
    virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;

public:
    UPROPERTY(EditAnywhere, Category = "Loading")
    TSoftClassPtr<AActor> AccessoryClass;

    UPROPERTY(EditAnywhere, Category = "Loading")
    TSubclassOf<AActor> FallbackClass;

    UFUNCTION(BlueprintCallable, Category = "Loading")
    void LoadAndSpawn();

private:
    void OnClassLoaded();

    TSharedPtr<FStreamableHandle> ClassHandle;

    UPROPERTY()
    TObjectPtr<AActor> SpawnedAccessory;
};

Here is the complete SoftClassActor.cpp:

#include "SoftClassActor.h"
#include "Engine/AssetManager.h"

ASoftClassActor::ASoftClassActor()
{
    PrimaryActorTick.bCanEverTick = false;
}

void ASoftClassActor::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    if (ClassHandle.IsValid())
    {
        if (!ClassHandle->HasLoadCompleted())
        {
            ClassHandle->CancelHandle();
        }
        else
        {
            ClassHandle->ReleaseHandle();
        }
        ClassHandle.Reset();
    }
    Super::EndPlay(EndPlayReason);
}

void ASoftClassActor::LoadAndSpawn()
{
    if (AccessoryClass.IsNull())
    {
        UE_LOG(LogTemp, Warning, TEXT("AccessoryClass is null."));
        return;
    }

    FStreamableManager& SM = UAssetManager::Get().GetStreamableManager();

    ClassHandle = SM.RequestAsyncLoad(
        AccessoryClass.ToSoftObjectPath(),
        FStreamableDelegate::CreateUObject(this, &ASoftClassActor::OnClassLoaded)
    );

    UE_LOG(LogTemp, Warning, TEXT("Async class load requested: %s"), *AccessoryClass.ToString());
}

void ASoftClassActor::OnClassLoaded()
{
    UClass* LoadedClass = AccessoryClass.Get();

    if (!LoadedClass)
    {
        UE_LOG(LogTemp, Error, TEXT("Class load failed. Using fallback."));
        LoadedClass = FallbackClass;
    }

    if (LoadedClass && GetWorld())
    {
        FVector SpawnLocation = GetActorLocation() + FVector(0.f, 0.f, 100.f);
        SpawnedAccessory = GetWorld()->SpawnActor<AActor>(LoadedClass, SpawnLocation, FRotator::ZeroRotator);
        UE_LOG(LogTemp, Warning, TEXT("Spawned: %s"), *SpawnedAccessory->GetName());
    }
}

Secret mission

Build a Mini Equip/Unequip System

You've proven each pattern in isolation. Now combine them all into a single cohesive system that loads parts on demand, releases them on swap, and cancels in-flight loads on destruction. This is the production pattern you'll use in your character customization system.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project as a reference for your character customization work or delete it entirely.

Resources you used:

  • The RefLab UE5 C++ project folder (includes all source files, Starter Content, levels, DataAssets, and Asset Manager settings).

Keep everything running

No action needed. Choose this if you plan to apply these patterns to your character customization system soon.

  • Your RefLab project stays on disk as a working reference for soft references, async loading, and handle lifecycle patterns.
  • The project uses no background processes and consumes no resources when the editor is closed.

Pause - I'll come back to this later

Since this is a local project with no running processes, there is nothing to pause. Close Rider and the UE5 editor when you are done.

  • Close the UE5 editor.
  • Close Rider.
  • Your RefLab folder remains on disk ready to reopen whenever you need it.

Delete - I don't want to use this again

Remove the entire project folder to free up disk space. Starter Content makes UE5 projects several gigabytes.

  • Close Rider and the UE5 editor.
  • Open Finder (macOS) or File Explorer (Windows) and navigate to the folder where you created the RefLab project.
  • Delete the entire RefLab folder.
  • Empty your Trash (macOS) or Recycle Bin (Windows) to reclaim the disk space.

That's a Wrap!

That's a Wrap!

Nice work. You now have hands-on, verified proof of how UE5's reference and async loading systems behave at the memory level.

You've learned how to:

  • Prove that hard references (TObjectPtr) create load-time dependencies visible in the Reference Viewer, while soft references (TSoftObjectPtr) are just paths until explicitly loaded.
  • Build production-ready async loading code using both FStreamableManager with proper FStreamableHandle lifecycle (CancelHandle, ReleaseHandle, EndPlay cleanup) and the modern TSoftObjectPtr::LoadAsync pattern.
  • Diagnose why TSoftClassPtr fails in packaged builds without Asset Manager registration, and fix it with a Primary Asset Type configuration.
  • Secret Mission: Combined all patterns into an equip/unequip system that loads meshes on demand, releases handles on swap, and cancels in-flight loads on destruction.

Ready to quiz yourself?