You've profiled your draw calls, your frame time is fine, and yet every minute or so the game just… stops. Briefly. Regularly. Like clockwork.
That's the garbage collector, and the interval is a project setting called Time Between Purging Pending Kill Objects — 60 seconds by default. Now you know what to search for.
What actually happens
Unreal manages UObject memory with a mark-and-sweep collector. Two phases:
Reachability analysis — start from the root set and walk every reference. Anything you can reach is alive. Anything you can't is garbage. This is the expensive one, and historically it runs in a single frame. All UObject processing — gameplay included — stops until it's done.
Purge — actually free the unreachable objects.
The key relationship: the pause scales with how many objects the collector has to scan, not with how many are garbage. An empty map with 500,000 UObjects still pays for the walk. This is why your hitch appears as your project grows and never goes away.
Only reflected references count
The collector finds references through the reflection system. That means:
UPROPERTY()pointers are visible to GC.- Raw pointers without
UPROPERTYare invisible. The object they point at can be collected out from under you — and now you have a dangling pointer instead of a hitch.
In Blueprints you get this for free; every variable is reflected. In C++ it's the number one source of "it works until GC runs" bugs. If a UObject pointer is a member of a UObject, it wants UPROPERTY().
Incremental reachability: the big one
Since UE 5.4, you can split reachability analysis across multiple frames with a soft per-frame time limit. Instead of one 40 ms spike, you get several small slices nobody notices.
CB0
That last value is a 2 ms soft budget per frame. Profile it in Unreal Insights and you'll see the spike replaced by wider, flat bars.
Two honest caveats, straight from the docs and the bug tracker:
- Reference tracking between iterations goes through
TObjectPtr, not raw pointers. Assigning to aTObjectPtr-exposedUPROPERTYmarks the object reachable mid-collection. RawUPROPERTYpointers don't participate the same way. - It is not fully thread safe. An object manipulated on a worker thread may not get marked reachable while the collector is scanning, and can be collected prematurely.
There have also been reported issues with incremental reachability and World Partition streaming — UWorld/ULevel objects not being destroyed — and a loading-screen deadlock. Turn it on, measure, and test streaming and loading screens specifically. This is a big win with sharp edges, not a free toggle.
The three levers, in order of payoff
1. Have fewer objects. Nothing else comes close. Every UObject is scan cost forever. This is where soft references and instancing pay off a second time: 5000 instances in one component are one object; 5000 actors are 5000.
2. Pool instead of spawn. Spawn/Destroy churn creates garbage on a schedule. Bullets, hit effects, floating damage numbers — pool them. Same objects, reused, never collected.
3. Tune the interval. Raising Time Between Purging Pending Kill Objects makes hitches rarer but bigger; lowering it makes them more frequent but smaller. It doesn't remove work. Useful for pushing collections into a loading screen; not a fix.
Disabling GC during gameplay is a real technique in shipped games. It's also how you ship a memory leak. Know what you're doing before you reach for it.
Measuring it
stat GC and stat Memory in the console. Unreal Insights if you want to see the actual spike shape. And run obj gc manually to force a collection — if your frame hitches exactly the way it does every 60 seconds, you've confirmed the diagnosis in ten seconds.
Profile the packaged build. Editor GC behaviour is not shipping GC behaviour.
Common pitfalls
- Raw UObject pointers without
UPROPERTY. Not a hitch — a crash. The GC can't see them and collects the target. - Blaming your code for a 60-second periodic stutter. Check
stat GCfirst. It's the collector until proven otherwise. - Enabling incremental GC and calling it done. Test streaming, test loading screens, test worker-thread work. Known issues exist.
- Assuming incremental GC works with raw pointers. It tracks through
TObjectPtr. - Raising the GC interval to hide the problem. You made the hitch rarer and worse.
- Spawning and destroying per-frame objects. That's a GC schedule you built by hand.
30-second recap
- The 60-second hitch is reachability analysis, and it's a project setting.
- Pause time scales with total object count, not garbage count.
- No
UPROPERTYmeans invisible to GC — dangling pointers, not stutters. - UE 5.4+:
gc.AllowIncrementalReachability=1spreads the pause over frames. RequiresTObjectPtr, not fully thread safe, known streaming issues. - Fewer objects > pooling > interval tuning. In that order.
stat GC,obj gc, Unreal Insights. Packaged build.
Next Sunday: Lumen for beginners — Software vs Hardware ray tracing, what actually costs frames, and why most of the tuning advice online is from 2023.
— Marco
Comments
Leave a comment