The Real Cause
Every performance audit starts with the same assumption: there's a single expensive function somewhere that, once found, fixes everything. In practice, that's rarely true. The codebases that come to us with persistent latency complaints — missed frame budgets, stutters during ability activations, multiplayer desyncs under load — almost never have one catastrophic function. They have dozens of small, individually-reasonable decisions that compound into something unacceptable.
The irony is that many of these projects are built by competent engineers using modern tools. The problem isn't skill; it's pattern. Certain architectural patterns produce latency predictably: per-frame redundancy, unbounded replication, and deferred allocations in hot paths. C++ helper functions, when designed with single-responsibility and cache behavior in mind, eliminate these patterns without requiring a full system rewrite.
This post documents three real audit findings from production UE5 projects — all NDA-protected, details anonymized — and the helper function implementations that resolved them. Each includes profiling data captured via Unreal Insights and a before/after benchmark.
All three cases were captured on targets running UE5.4–5.5, server builds on Windows, with 16–32 simulated clients. Profiling used Unreal Insights CPU trace with GPU markers disabled to isolate game-thread cost. Benchmarks represent averages over a 60-second stress window, not single-frame outliers.
01 — Tick Overhead
The Pattern
In the first project — a networked melee-combat game — ability status checks were being performed inside AActor::Tick on every active character, every frame. The check involved a GetAbilitySystemComponent() call, followed by tag queries with HasMatchingGameplayTag, and a property read from a replicated UAbilitySystemComponent. Each call was cheap in isolation: 0.04–0.08ms. With 24 characters on screen, that totalled over 1.9ms per frame — almost the entire 2ms budget for the game thread's "world tick" slice at 60hz.
The deeper issue was that these checks almost never returned a different result between frames. The tags being queried (movement state, attack state) changed at most once every 200ms. The per-frame evaluation was completely redundant for ~98% of its invocations.
HasMatchingGameplayTag call stack expanded across 24 character ticks, summing to ~1.9ms per frame before the cache fix.The Fix: Cached State Helper
The solution was a helper function paired with a dirty-flag cache. Rather than querying the ASC each tick, ability state was written into a struct on the character component whenever a relevant tag changed, via a GameplayTagCountChangedDelegate binding. The tick path read from the cached struct — a single member read with no virtual dispatch.
// Cached ability state — updated on tag change, not per-tick
USTRUCT(BlueprintType)
struct FArenaAbilityStateCache
{
GENERATED_BODY()
bool bIsAttacking = false;
bool bIsStunned = false;
bool bIsInvulnerable = false;
bool bCanMove = true;
};
// Helper — bind to ASC tag delegate, call once on init
static void RebuildStateCache(
UAbilitySystemComponent* ASC,
FArenaAbilityStateCache& OutCache)
{
if (!ASC) return;
OutCache.bIsAttacking = ASC->HasMatchingGameplayTag(TAG_State_Attacking);
OutCache.bIsStunned = ASC->HasMatchingGameplayTag(TAG_State_Stun);
OutCache.bIsInvulnerable = ASC->HasMatchingGameplayTag(TAG_State_Invulnerable);
OutCache.bCanMove = !ASC->HasMatchingGameplayTag(TAG_State_Immobilized);
}
The tick path was reduced to a single struct member dereference. The RebuildStateCache helper fires only when a relevant tag is added or removed — which for this codebase averaged once every 8–12 frames under heavy combat. Total game-thread cost across 24 characters dropped from 1.9ms to 0.07ms.
The ASC tag query isn't inherently slow. The mistake was calling it at tick frequency when the data changes at event frequency. Match the query rate to the data change rate. If state changes 5 times per second, querying 60 times per second is a 12x waste.
02 — Replication Spam
The Pattern
The second project was a wave-survival shooter. The client reported visible hitching every 8–12 seconds under full lobby conditions (16 players). Unreal Insights flagged a spike in UNetDriver::ServerReplicateActors — not continuously elevated, but in sharp bursts. The cause was a replicated TMap<FGameplayTag, float> on a per-character combat component.
TMap is not natively replicable via UE's property replication system — the team had worked around this by serializing it to a TArray<FKeyValuePair> inside GetLifetimeReplicatedProps and marking the array with DOREPLIFETIME_CONDITION. The problem: every gameplay effect application dirtied the entire array, triggering full array replication to all connected clients — regardless of which entry changed.
ServerReplicateActors spikes every 8–12s (left, full-array dirty) versus the flat profile after the FastArray migration (right).The Fix: FastArray + Granular Dirty
The map was replaced with an FFastArraySerializer-backed struct. Rather than replicating the full container, only the entries that actually changed are sent. A helper function, ApplyTaggedFloat, handles the write path: it locates the existing entry or creates a new one, writes the value, and calls MarkItemDirty — dirtying only that entry, not the full array.
// Item type — FastArray entry
USTRUCT()
struct FTaggedFloatEntry : public FFastArraySerializerItem
{
GENERATED_BODY()
UPROPERTY() FGameplayTag Tag;
UPROPERTY() float Value = 0.f;
};
// Container
USTRUCT()
struct FTaggedFloatContainer : public FFastArraySerializer
{
GENERATED_BODY()
UPROPERTY() TArray<FTaggedFloatEntry> Items;
bool NetDeltaSerialize(FNetDeltaSerializeInfo& Parms) {
return FFastArraySerializer::FastArrayDeltaSerialize<FTaggedFloatEntry,
FTaggedFloatContainer>(Items, Parms, *this);
}
};
// Helper — granular write, marks only the changed entry dirty
void ApplyTaggedFloat(
FTaggedFloatContainer& Container,
const FGameplayTag& Tag,
float NewValue)
{
for (auto& Entry : Container.Items) {
if (Entry.Tag == Tag) {
Entry.Value = NewValue;
Container.MarkItemDirty(Entry);
return;
}
}
// Not found — add new
FTaggedFloatEntry& New = Container.Items.AddDefaulted_GetRef();
New.Tag = Tag;
New.Value = NewValue;
Container.MarkItemDirty(New);
}
The replication spike disappeared. Under the same 16-player stress test, ServerReplicateActors time for the affected component dropped by 71%. The hitching interval — previously every 8–12 seconds during heavy combat — was eliminated entirely in subsequent testing sessions.
"Replication cost scales with what changed, not with the size of the container. FastArray is the correct primitive for any replicated collection where elements update independently."
— Reignance Audit Note, Client B · Wave Survival, UE5.503 — Trace Redundancy
The Pattern
The third audit involved an ability-driven melee system. The combat designer had wired three separate abilities — heavy attack, parry window detection, and a counter-trigger — each with their own UAbilityTask_PlayMontageAndWait and each performing their own sphere trace inside UGameplayAbility::ActivateAbility or inside a Notify-driven C++ function. All three ran in the same frame window during the same animation, tracing against the same collision channel, against the same set of potential targets.
A single trace call in UE5 with a medium-radius sphere and 8–12 potential hit actors costs roughly 0.15–0.25ms on the game thread. Three redundant traces in the same frame window, across up to 6 simultaneous melee characters, was producing 2.7ms of trace cost per frame — during the exact moment players care most about responsiveness.
DrawDebugSphere enabled — three overlapping sweep volumes from heavy-attack, parry, and counter abilities firing in the same frame against the same target set.The Fix: Shared Trace Cache with Frame Token
A helper class, UArenaTraceCache (a UActorComponent), stores the last trace result per collision channel alongside the frame number it was captured in. Any ability requesting a trace checks the cache first — if the frame number matches the current GFrameCounter, the cached result is returned directly without issuing a new physics query.
// Cache entry — per trace channel
struct FTraceCacheEntry
{
TArray<FHitResult> HitResults;
uint64 CapturedFrame = 0;
};
// Helper — issue once, share result within frame
const TArray<FHitResult>& UArenaTraceCache::GetOrTrace(
ECollisionChannel Channel,
const FVector& Origin,
float Radius)
{
FTraceCacheEntry& Entry = CacheMap.FindOrAdd(Channel);
if (Entry.CapturedFrame == GFrameCounter) {
return Entry.HitResults; // same frame — return cached
}
// Stale or first access — issue new trace
Entry.HitResults.Reset();
FCollisionQueryParams Params;
Params.AddIgnoredActor(GetOwner());
GetWorld()->SweepMultiByChannel(
Entry.HitResults, Origin, Origin,
FQuat::Identity,
Channel,
FCollisionShape::MakeSphere(Radius),
Params
);
Entry.CapturedFrame = GFrameCounter;
return Entry.HitResults;
}
Each ability calls GetOrTrace instead of issuing its own trace. The first caller in a frame pays the full 0.15–0.25ms. Every subsequent caller in the same frame returns the cached result in under 0.01ms. With three abilities and six melee characters, trace overhead dropped from 2.7ms to 0.4ms — a net saving of 2.3ms per frame during active combat.
Frame-cached traces assume trace origin and radius are consistent within a frame for a given channel — true for melee detection in this codebase, but not universally safe. Do not share a trace cache across abilities with meaningfully different origins or radii within the same frame. The cache key must be specific enough to be valid.
05 — Before & After
Taken individually, each fix targets a different subsystem. Together, they address the same root cause: operations being executed at a higher frequency than their data change rate justifies. The table below summarizes results from all three audits, measured over equivalent 60-second stress windows.
| Problem | Before | After | Saving |
|---|---|---|---|
| Per-tick ASC tag queries (24 chars) | 1.90ms / frame | 0.07ms / frame | 1.83ms |
| Replication spike (TMap → FastArray) | 71% higher net overhead | Baseline restored | No hitches |
| Redundant traces (3 abilities × 6 chars) | 2.70ms / frame | 0.40ms / frame | 2.30ms |
| Total game-thread savings | ~4.6ms / frame | ~0.5ms / frame | ~4.1ms |
At 60hz, a 16.67ms frame budget means these three fixes alone recovered roughly 25% of the total game-thread budget in each respective project. None of the fixes required architectural rewrites; all three were isolated to new helper functions and small changes to the call sites.
Conclusion
The cases documented here are not edge cases. Per-tick tag queries, poorly scoped replication containers, and redundant collision traces appear in the majority of UE5 codebases that reach us for audit. They're not bugs — they compile cleanly and run correctly at small scale. They become problems when the player count grows, when combat density increases, and when the number of simultaneously active abilities climbs past what the original dev environment tested.
The lesson isn't that these patterns are wrong in principle. It's that they need to be matched to the correct execution frequency. Data that changes at event rate shouldn't be queried at tick rate. Replicated containers with independent entries shouldn't be treated as monolithic blobs. Physics queries that produce the same result for multiple callers in the same frame shouldn't be issued multiple times.
C++ helper functions are the right tool for this class of fix precisely because they encapsulate the correct behavior once, at the call site level, without requiring the upstream caller to reason about caching strategy or replication granularity. The ability code stays clean. The performance constraint is enforced by the helper. Maintenance and extension become straightforward.
Performance is not found in one big refactor. It is recovered incrementally, one correctly-scoped operation at a time.
— Reignance Studios · Technical Audit SeriesIf your project is exhibiting any of the patterns described here — or if you're seeing unexplained frame spikes, replication hitches, or degrading performance as content scales — a focused audit is the fastest path to a clear picture and an actionable fix plan.
Part 2 will cover GAS Execution Calculation overhead — specifically how redundant attribute reads inside UGameplayEffectExecutionCalculation::Execute_Implementation compound across simultaneous effects, and the caching pattern that resolves it without changing the GE data model.