Use less RPCs

Hello, I wanted to talk about a rather opinionated topic.

I think RPCs get over-used. RPCs (Remote-Procedure-Calls) are a way of executing a procedure on a remote instance. For example, Server -> Client | Client -> Server.

Here’s where I think a problem arises, it’s very very easy in Unreal to end up writing a copious amount of RPCs scattered all over the place through your code-base. I dislike this for a number of reasons.

  1. Debugging becomes a nightmare
  2. More RPCs that must get processed.
  3. More chance for data to not reach the intended destination (unreliable rpcs).

What’s an answer I recommend? use less RPCs.

There are so many times when a bunch of RPCs could be moved to contain just 1 or 2.

I have an inventory system which processes transactions, the entire inventory control (moving, swapping, add, remove, etc) is combined into one single RPC called a transaction. These transactions can be batched, compressed, and can hold custom serialization thanks to Unreal’s struct system and the NetSerializer struct trait.

Disclaimer

This blog post assumes default UE networking, without Iris or any other custom features. It may become outdated very soon.

The Technical Case for RPC Consolidation

Let me be clear upfront: this is purely my opinion based on years of working with Unreal’s networking stack. Other engineers may have perfectly valid reasons for preferring a more distributed RPC architecture, and I respect that. However, I’ve found that consolidating RPCs into command-pattern structures yields measurably better results in terms of maintainability, performance, and debuggability.

The Hidden Cost of RPC Proliferation

When you declare an RPC in Unreal, you’re not just creating a simple function call. Under the hood, Unreal’s networking system must:

  1. Register the RPC in the function name table - Each RPC gets assigned a function index that must be synchronized between client and server
  2. Serialize parameters individually - Each parameter goes through Unreal’s reflection system for serialization
  3. Queue the RPC in the outgoing bunch - RPCs get bundled into packets, but each RPC has overhead in the bunch header
  4. Process reliability and ordering semantics - Even for unreliable RPCs, the system must track and manage the call
  5. Invoke the RPC on the receiving end - Which involves reflection lookup and parameter deserialization

Now multiply this by dozens or hundreds of scattered RPCs firing every frame. Each one adds to your replication overhead, not just in bandwidth, but in CPU cycles spent on serialization, bunch management, and function dispatch.

The Problem with Semantic Granularity

Consider a typical item pickup flow that I’ve seen in many codebases:

UFUNCTION(Server, Reliable)
void ServerRequestPickup(AActor* Item);

UFUNCTION(Client, Reliable)
void ClientConfirmPickup(int32 ItemID);

UFUNCTION(Server, Reliable)
void ServerAddToInventory(int32 ItemID, int32 Slot);

UFUNCTION(Client, Reliable)
void ClientUpdateInventoryUI(int32 Slot, FItemData ItemData);

UFUNCTION(NetMulticast, Unreliable)
void MulticastPlayPickupEffect(AActor* Item);

This seems logical at first glance, each RPC has a clear, single responsibility. But look at what’s actually happening:

  • 5 separate network messages for a single logical operation
  • 5 separate reliability channels to manage (reliable RPCs use the reliable buffer, which can cause head-of-line blocking)
  • No transactional semantics - if ClientConfirmPickup arrives but ServerAddToInventory gets dropped on a bad connection, you could have a partial state
  • Debugging requires tracing through 5 different call sites across potentially different classes

The Command Pattern Approach

Instead, consider consolidating this into a single transactional RPC:

USTRUCT()
struct FInventoryTransaction
{
    GENERATED_BODY()

    UPROPERTY()
    ETransactionType Type;

    UPROPERTY()
    int32 SourceSlot;

    UPROPERTY()
    int32 DestSlot;

    UPROPERTY()
    FGuid ItemGuid;

    UPROPERTY()
    TArray<uint8> CustomPayload;

    // Custom NetSerializer for optimal packing
    bool NetSerialize(FArchive& Ar, class UPackageMap* Map, bool& bOutSuccess);
};

template<>
struct TStructOpsTypeTraits<FInventoryTransaction> : public TStructOpsTypeTraitsBase2<FInventoryTransaction>
{
    enum { WithNetSerializer = true };
};

UFUNCTION(Server, Reliable)
void ServerProcessTransaction(const FInventoryTransaction& Transaction);

Now you have:

  • One network message for the entire logical operation
  • One reliability channel to manage
  • Transactional semantics - either the entire transaction succeeds or fails atomically
  • One debug breakpoint to set to trace all inventory operations
  • Batching potential - you can extend this to TArray<FInventoryTransaction> to batch multiple operations in a single RPC

Bandwidth and Serialization Efficiency

Here’s something non-obvious: custom NetSerializers can be significantly more efficient than default replication. When you use standard UPROPERTY replication or basic RPC parameters, Unreal’s serialization is generic and safe, but not optimized for your specific data.

With a custom NetSerializer on your transaction struct, you can:

  • Pack bit-flags tightly - Use Ar.SerializeBits() to pack enums and bools into minimal bits
  • Quantize floats - Serialize positions/rotations with controlled precision using Ar.SerializeInt() with custom ranges
  • Delta compress - Serialize only what changed since the last transaction
  • Use custom compression - Apply domain-specific compression to your payload data

The Reliability Problem

Unreliable RPCs are often used for “unimportant” events like cosmetic effects. But here’s the issue: when you have 20 different unreliable RPCs being called from various systems, you have no holistic view of packet priority or importance.

With a consolidated approach, you can implement sophisticated prioritization:

USTRUCT()
struct FGameplayEventBatch
{
    GENERATED_BODY()

    // High priority events sent reliably
    UPROPERTY()
    TArray<FGameplayEvent> CriticalEvents;

    // Lower priority events sent unreliably, can be dropped
    UPROPERTY()
    TArray<FGameplayEvent> CosmeticEvents;

    // Spatially-partitioned events only relevant to nearby clients
    UPROPERTY()
    TArray<FGameplayEvent> ProximityEvents;
};

This gives you fine-grained control over what gets reliable delivery and what can be safely dropped under packet loss, all within a single RPC structure.

Debugging and Observability

When debugging network issues, scattered RPCs are a nightmare. You need to:

  • Set breakpoints in multiple locations
  • Enable RPC logging for specific functions.
  • Trace through multiple callstacks
  • Hope you can reproduce the issue consistently

With consolidated RPCs, you have:

  • Single choke point - One function to set a breakpoint in
  • Centralized logging - One place to log all transactions with full context
  • Replay support - Easy to record and replay transactions for debugging
  • Metrics collection - Simple to instrument and track transaction patterns

State Synchronization Guarantees

Scattered RPCs make it nearly impossible to reason about state synchronization. Consider this scenario:

// Called in quick succession
ServerSetHealth(50.0f);
ServerSetArmor(25.0f);
ServerSetAmmo(10);

On a poor connection with packet loss, you might receive them out of order, or only receive 2 out of 3. Your client state is now inconsistent, and you have no way (or a very difficult time) to detect or recover from this.

With a consolidated state update RPC:

USTRUCT()
struct FPlayerStateUpdate
{
    GENERATED_BODY()

    uint32 TransactionID; // Monotonically increasing
    float Health;
    float Armor;
    int32 Ammo;
    // ... other state
};

UFUNCTION(Server, Reliable)
void ServerUpdatePlayerState(const FPlayerStateUpdate& Update);

Now you have:

  • Ordering guarantees - You can detect out-of-order updates via TransactionID
  • Atomic updates - All state changes arrive together or not at all
  • Easy rollback - If an update fails validation, you can reject the entire transaction

The Actor Channel Bunch Budget

Here’s a technical detail many developers miss: Unreal’s Actor Channels have a limited bunch size. When you replicate properties and fire RPCs, they all get packed into bunches for that actor.

If you have many small RPCs, you’re fragmenting your bunch space inefficiently. Each RPC has overhead:

  • Function name index (2 bytes typically)
  • Parameter headers
  • Bunch control bits

With consolidated RPCs using tightly-packed structs, you maximize your bunch utilization. You can fit more meaningful data in each packet, reducing the number of packets needed per frame.

Performance Considerations: Function Dispatch Overhead

Every RPC invocation requires:

  1. Function lookup in the UFunction map (hash table lookup)
  2. Reflection system invocation to deserialize parameters
  3. Virtual function dispatch to call the actual implementation

When you have hundreds of RPCs being fired per second across multiple actors, this overhead accumulates. By consolidating to fewer RPC entry points, you:

  • Reduce hash table lookups
  • Amortize reflection overhead across batched operations

Counterarguments and Trade-offs

I want to be fair here - this approach isn’t perfect and has trade-offs:

  1. A command-pattern system is more complex upfront. You need to design your transaction structures carefully, implement custom serialization, and handle different transaction types in a single function.

  2. With scattered RPCs, you get type safety from C++ function signatures. With a consolidated approach, you’re often switching on enums and casting payloads, which is more error-prone.

  3. New developers on your project can more easily understand individual RPCs like ServerFireWeapon() than a generic ServerProcessGameplayCommand() that handles 50 different command types.

When to Use Which Approach

Based on my experience, here’s when I use each approach:

Consolidated RPCs for:

  • High-frequency operations (input, inventory, ability systems)
  • Operations that need transactional semantics
  • Systems with many related operations
  • Performance-critical replication

Individual RPCs for:

  • One-off events (level transitions, login/logout)
  • Very simple operations with no related state
  • Prototyping and early development
  • When integrating with third-party code that expects individual RPCs

Conclusion

This is admittedly an opinionated stance, and I don’t claim it’s the only valid approach. Some teams prefer the clarity and simplicity of individual RPCs, and that’s perfectly legitimate.

However, I’ve found that as projects scale, more systems, more players, more network traffic, the consolidated RPC approach pays dividends in debuggability, performance, and maintainability. The upfront investment in designing clean transaction structures and serialization has consistently saved me time in the long run.

If you’re working on a networked Unreal project, I’d encourage you to at least experiment with this approach in one of your core systems. You might find, as I did, that the benefits far outweigh the initial complexity cost.

As with all architectural decisions, measure, profile, and make informed choices based on your specific project requirements. What works for a 100-player battle royale might not work for a 4-player co-op game, and vice versa.