Loading Time Optimization
UE 5.5 narrowed BlockTillLevelStreamingCompleted's scope from "flush every in-flight async load" to "flush only this streaming level's requests" — reducing PIE entry time on large projects significantly. UE 5.6 added unified streaming budgets. UE 5.7 adds StaticLoadAsset and LoadAssetAsync for ergonomic async paths. This tutorial walks the soft-vs-hard-reference decision, FStreamableManager lifecycle, level streaming budgets, blocking-load detection in Insights, and the patterns that make UE5 boot in 5 seconds.
The loading pipeline in 5.5/5.6
UE5 loading flows through:
- Asset Registry — metadata lookup; warmed at boot.
- Zen Loader (5.5+ default) — replaces deprecated EDL. Cuts runtime dependency-graph processing "from seconds to milliseconds" by moving prep offline.
- FStreamableManager — the async load coordinator.
- Level Streaming —
UWorld::AddToWorldpipeline; per-tick time budgets.
Critical 5.5 change: UWorld::BlockTillLevelStreamingCompleted now flushes only outstanding ULevelStreaming::AsyncRequestIDs rather than all global async loads. Set s.World.ForceFlushAllAsyncLoadsDuringLevelStreaming=1 to revert if your custom ULevelStreaming subclass relies on the old behavior.
Hard vs soft references
A hard reference (UPROPERTY() UStaticMesh* Mesh) means the asset loads when the owner does. A soft reference (UPROPERTY() TSoftObjectPtr<UStaticMesh> Mesh) means the asset loads only when explicitly requested via LoadSynchronous() or RequestAsyncLoad.
Hard references cascade. A Blueprint that hard-refs a 200MB hero mesh on its CDO pulls that 200MB into memory at level load — even if the actor never spawns. Soft refs let you load on demand.
The trap covered in Gotcha #3: Cast nodes create hard references too, so "avoiding casts" is sometimes correct (when the goal is reducing the hard-ref footprint), but the underlying issue is reference type, not Cast specifically.
The four soft pointer types and their methods:
TSoftObjectPtr<T>— specific asset reference (e.g., a particularUStaticMesh).TSoftClassPtr<T>— specific class reference (e.g., a Blueprint class to spawn).FSoftObjectPath— untyped path; serializable string form.FSoftClassPath— untyped class path.
Methods on TSoftObjectPtr: Get() returns the raw T* if already loaded (nullptr otherwise; does not load); LoadSynchronous() blocks the game thread to load; IsPending() tests whether load is still queued; ToSoftObjectPath() converts to FSoftObjectPath.
FStreamableManager patterns
FSoftObjectPath AssetPath = MySoftRef.ToSoftObjectPath();
TSharedPtr<FStreamableHandle> Handle = UAssetManager::GetStreamableManager().RequestAsyncLoad(
AssetPath,
FStreamableDelegate::CreateLambda([this, AssetPath]()
{
UStaticMesh* Loaded = Cast<UStaticMesh>(AssetPath.ResolveObject());
// Use Loaded
}),
FStreamableManager::AsyncLoadHighPriority,
false, // bManageActiveHandle: false = caller owns handle
false, // bStartStalled
TEXT("MyDebugName")); // Helps in Insights
// Store Handle as a member to keep the asset alive!
ActiveLoadHandle = Handle;FStreamableHandle as a member, not in a local that goes out of scope. Per Tom Looman: storing the handle in a local variable that ends at end of BeginPlay is the #1 "why did my texture pop in" bug. Preload auto-releases when handle drops; Load keeps it pinned.
Asset Manager & Primary Asset Types
Asset Manager is the senior pattern for organizing async loads. Define Primary Asset Types in DefaultGame.ini:
[/Script/Engine.AssetManagerSettings]
+PrimaryAssetTypesToScan=(PrimaryAssetType="Monster",AssetBaseClass="/Script/MyGame.MonsterData",
bHasBlueprintClasses=False,bIsEditorOnly=False,
Directories=((Path="/Game/Monsters")),
SpecificAssets=,Rules=(CookRule=AlwaysCook))Then load with:
UAssetManager& Manager = UAssetManager::Get();
FPrimaryAssetId MonsterId(TEXT("Monster"), FName(TEXT("Goblin_Boss")));
TSharedPtr<FStreamableHandle> Handle = Manager.LoadPrimaryAsset(
MonsterId,
{TEXT("Combat"), TEXT("UI")}, // Asset Bundles to load
FStreamableDelegate::CreateUObject(this, &UMyClass::OnMonsterLoaded));Asset Bundles (declared via meta=(AssetBundles="UI,Combat") on UPROPERTYs) let you partial-load a primary asset — saving 30–70% RAM on heavy data assets without changing the type.
Level streaming budgets
The per-tick cost knobs:
s.LevelStreamingActorsUpdateTimeLimit— per-frame seconds budget forAddToWorld().s.PriorityLevelStreamingActorsUpdateExtraTime— extra time for high-priority levels.s.LevelStreamingComponentsRegistrationGranularity— components registered perAddToWorld()slice.s.GLevelStreamingForceGCAfterLevelStreamedOut=0to suppress forced GC after unload (often the visible hitch right after a sublevel goes "Unloaded").
UE 5.6 adds s.UseUnifiedTimeBudgetForStreaming — unified budget across ProcessAsyncLoading and UpdateLevelStreaming; donates unused streaming time to asset loading.
Detecting blocking loads in Insights
YourGame.exe -trace=loadtime,loadtimechannel,asset,assetmetadata,frame,bookmark
Open the trace in Insights → Asset Loading panel (UE 5.5+: enable via the "Other" dropdown if not visible). Five tabs: Event Aggregation, Object Type Aggregation, Package Details, Export Details, Requests.
Look for the "Flush All Async Loads GT" marker (5.5+) — any occurrence after the loading screen closes is a blocking load you can fix. Also use LoadTimes.DumpReport [FILE] [LOWTIME=0.05] for per-package load-time tables filtered above 50 ms.
-trace=loadtime
Teams routinely capture sessions and miss the entire bottleneck because they didn't include the loadtime channel.
IoStore + Zen Loader
IoStore (.utoc/.ucas containers) replaces .pak as the cooked output format for shipping UE5. Zen Loader runs on top of IoStore by default in 5.5+:
- Zen Loader chunk graph — loaded chunks are dependency-resolved at cook time, not runtime.
- FileOpenOrder — cook-time on-disk ordering. Even on NVMe, sequential layout helps.
- IoStoreOnDemand — install-on-demand patterns; only download chunks the player needs.
EDL (Event-Driven Loader) is deprecated. Turning off IoStore (bUseIoStore=False) to "fix" a packaging issue puts you on a removal path.
Common anti-patterns
StaticLoadObjectin gameplay code. TriggersFlushAsyncLoading, draining the entire async queue on the game thread. Mixed into otherwise-async code, creates correlated hitches that look like GC spikes.LoadSynchronouson a soft ref fromBeginPlayof a streamed actor. Stalls the game thread for the entire async batch.- Cyclic soft refs — two data assets soft-referencing each other will load each other on first dereference. Soft refs don't appear in Reference Viewer's hard-graph; use Size Map and AssetRegistry filtering.
- Asset bundle name typos — bundle names are case-sensitive
FNames; a typo silently loads zero refs. - Forgetting
bManageActiveHandle=truefrom a transient context — without it, the manager's active list won't pin the load and you race against GC. - Storing
FStreamableHandleonly in a local stack variable — handle goes out of scope at end of method; load gets cancelled. Always store as a class member.
/Game/UEDPIE_0_MyAsset). If that CDO survives a save, it breaks packaged builds. Mitigation: FSoftObjectPath::FixupForPIE at the right point, and audit any tooling that saves assets after PIE has run. Tracked at issues.unrealengine.com/issue/UE-217968.
Packaged-build ResolveObject() vs TryLoad() behave differently — ResolveObject only succeeds if the asset is already loaded; TryLoad attempts a synchronous load. Don't use TryLoad as a fast path.
What to ban from BeginPlay
The pre-submit checklist:
- No
StaticLoadObject— ever, in any code path. - No
LoadSynchronouson cold soft refs — useRequestAsyncLoadwith a callback. - No hard
UPROPERTY(UClass*)on data assets — soft-class refs only. - No construction-script-heavy actors with hard asset references. Construction scripts re-run on
AddToWorldand bypass async loading. - No
BlockTillLevelStreamingCompletedoutside fast-travel hard boundaries. - Always store
FStreamableHandleas a member, not a local.
PerfGuard can boot a cooked build with -trace=loadtime,asset,assetmetadata,frame + -LoadTimeFile, open a target map, and stop on a "ready" bookmark. Diff four numbers vs baseline: total package load time, count of packages over 100ms, count of "Flush All Async Loads GT" markers (5.5+), per-level AddToWorld time. CI gate: any new package over 100ms or any new GT-flush marker fails the PR.
- Cook & Packaging — Zen Loader and IoStore configuration.
- Memory & VRAM Optimization — the memory side of soft-vs-hard refs.
- Object Pooling Patterns — complementary runtime allocation pattern.