Appearance
RPC Serialization Formats
ActualLab.Rpc supports multiple serialization formats with automatic version negotiation between clients and servers. This enables gradual upgrades and interoperability between different Fusion versions.
Overview
RPC serialization has two layers:
- Argument serialization: How method arguments are encoded
- Message serialization: How RPC messages (containing arguments) are framed
Each combination of these is packaged as an RpcSerializationFormat.
Available Formats
Text Formats (JSON)
| Format | Key | Description |
|---|---|---|
SystemJsonV5 | json5 | System.Text.Json with V4 arguments, V3 messages |
SystemJsonV5NP | json5np | System.Text.Json, no-polymorphism variant (plain JSON, no TypeRef wrapper) |
NewtonsoftJsonV5 | njson5 | Newtonsoft.Json with V4 arguments, V3 messages. Not client-selectable by default — see Client-Selectable Formats |
NewtonsoftJsonV5NP | njson5np | Newtonsoft.Json, no-polymorphism variant (plain JSON, no TypeRef wrapper). Not client-selectable by default |
The "NP" (no-polymorphism) variants skip the type-decorating TypeRef wrapper entirely, producing plain JSON without type metadata. Use them when all argument and result types are concrete (non-abstract) and no polymorphic dispatch is needed.
Binary Formats (MemoryPack)
| Format | Key | Description |
|---|---|---|
MemoryPackV5 | mempack5 | V4 args, V4 messages |
MemoryPackV5C | mempack5c | Compact variant of V5 |
MemoryPackV6 | mempack6 | V4 args, V5 messages |
MemoryPackV6C | mempack6c | Compact variant of V6 |
MemoryPackV6_LZ4 | mempack6-lz4 | V6 + LZ4 frame compression, server → client |
MemoryPackV6C_LZ4 | mempack6c-lz4 | V6C + LZ4 frame compression, server → client |
MemoryPackV6_LZ4F | mempack6-lz4f | V6 + LZ4 frame compression, both directions |
MemoryPackV6C_LZ4F | mempack6c-lz4f | V6C + LZ4 frame compression, both directions |
Binary Formats (MessagePack)
| Format | Key | Description |
|---|---|---|
MessagePackV5 | msgpack5 | V4 args, V4 messages |
MessagePackV5C | msgpack5c | Compact variant of V5 |
MessagePackV6 | msgpack6 | V4 args, V5 messages |
MessagePackV6C | msgpack6c | Compact variant of V6 |
MessagePackV6_LZ4 | msgpack6-lz4 | V6 + LZ4 frame compression, server → client |
MessagePackV6C_LZ4 | msgpack6c-lz4 | V6C + LZ4 frame compression, server → client |
MessagePackV6_LZ4F | msgpack6-lz4f | V6 + LZ4 frame compression, both directions |
MessagePackV6C_LZ4F | msgpack6c-lz4f | V6C + LZ4 frame compression, both directions |
Binary Formats (Nerdbank.MessagePack)
These formats require the ActualLab.Serialization.NerdbankMessagePack package. They are not registered by default — call RpcNerdbankSerializationFormat.Register() at startup to enable them.
| Format | Key | Description |
|---|---|---|
NerdbankMessagePackV6 | nmsgpack6 | Nerdbank.MessagePack, V4 args, V5 messages |
NerdbankMessagePackV6C | nmsgpack6c | Compact variant of V6 |
NerdbankMessagePackV6_LZ4 | nmsgpack6-lz4 | V6 + LZ4 frame compression, server → client |
NerdbankMessagePackV6C_LZ4 | nmsgpack6c-lz4 | V6C + LZ4 frame compression, server → client |
NerdbankMessagePackV6_LZ4F | nmsgpack6-lz4f | V6 + LZ4 frame compression, both directions |
NerdbankMessagePackV6C_LZ4F | nmsgpack6c-lz4f | V6C + LZ4 frame compression, both directions |
Format Selection
Default Format
The default is MemoryPackV6 (mempack6) — it's the key RpcSerializationFormatResolver.Default is created with. Override it process-wide by assigning a new resolver:
cs
RpcSerializationFormatResolver.Default = new RpcSerializationFormatResolver(
RpcSerializationFormat.MessagePackV6C.Key);Client-Server Negotiation
When a client connects, it requests its preferred serialization format via a URL parameter (e.g., <endpoint>?f=msgpack6&clientId=...). The server accepts the connection if it supports that format and allows clients to select it. Once connected, both parties simultaneously exchange RpcHandshake messages:
Accessing All Formats
cs
// All registered formats
ImmutableList<RpcSerializationFormat> all = RpcSerializationFormat.All;
// Find by key
var format = RpcSerializationFormat.All.First(f => f.Key == "mempack6c");Client-Selectable Formats
A registered format isn't automatically a format a client may pin via ?f=…. RpcSerializationFormatResolver.ClientDeniedFormatKeys is the allow-list's complement: every RPC server endpoint (WebSocket, HTTP/2, OWIN) rejects a connection whose requested key is in it. An empty f still means "server picks", so it's always accepted.
Since v14.2, njson5 and njson5np are denied to clients by default. Newtonsoft-backed formats deserialize with TypeNameHandling.Auto and no SerializationBinder, so they honor nested $type markers — a gadget surface the other formats lack. Server-to-server and in-process use of these formats is unaffected; only client-pinned selection is blocked.
To restore the previous behavior:
cs
RpcSerializationFormatResolver.DefaultClientDeniedFormatKeys = ImmutableHashSet<string>.Empty;Or to deny more, e.g. the legacy V5 formats:
cs
RpcSerializationFormatResolver.DefaultClientDeniedFormatKeys
= RpcSerializationFormatResolver.DefaultClientDeniedFormatKeys
.Add(RpcSerializationFormat.MemoryPackV5.Key)
.Add(RpcSerializationFormat.MessagePackV5.Key);DefaultClientDeniedFormatKeys seeds ClientDeniedFormatKeys on every resolver created after it's assigned, so set it at startup, before the first RpcSerializationFormatResolver is built.
Format Structure
Each RpcSerializationFormat consists of:
cs
public sealed class RpcSerializationFormatExample(
string key,
Func<RpcArgumentSerializer> argumentSerializerFactory,
Func<RpcPeer, RpcMessageSerializer> messageSerializerFactory)
{
public string Key { get; } = key;
public RpcArgumentSerializer ArgumentSerializer { get; } = argumentSerializerFactory();
public Func<RpcPeer, RpcMessageSerializer> MessageSerializerFactory { get; } = messageSerializerFactory;
}| Property | Description |
|---|---|
Key | Unique string identifier for negotiation |
ArgumentSerializer | Serializes method arguments |
MessageSerializerFactory | Creates message serializers per peer |
Version Differences
Argument Serializer Versions
| Version | Description |
|---|---|
| V4 | Latest, best performance |
Message Serializer Versions
| Version | Variants | Description |
|---|---|---|
| V4 | Normal, Compact | Was optimal up to Fusion v11.5.X |
| V5 | Normal, Compact | Saves 1 byte per message over V4 |
Compact vs Normal
Compact variants (*C suffix) use smaller message framing at a slight CPU cost. Choose compact for:
- Lower bandwidth scenarios
- When message overhead is significant relative to payload
Frame Compression
The -lz4 and -lz4f variants compress whole frames — a frame is one batch of messages, so a batch compresses as a unit and the LZ4 dictionary carries across frames.
Which directions are compressed is part of the format, not something the peers negotiate: -lz4 compresses server → client only, -lz4f compresses both. A client's outbound traffic is mostly small call headers, where compression usually costs more than it saves, so -lz4 is the better default. Both peers derive the same answer from the format key, which they already agree on when the connection is established, so there is nothing extra on the wire and no way for the two to disagree.
Text formats have no compressed variants: a compressed frame is binary, and a text peer reads text frames.
RpcCompressionFormat names the codec pair and carries the policy its compressor follows (RpcCompressionOptions): the minimum frame size worth compressing, and how many frames or bytes a compression context may span before it's reset. Resetting bounds both the memory a connection pins and how far a BREACH-style probe can correlate across frames — it does not make compressing a secret alongside attacker-controlled data within one frame safe.
To compress with different settings, register a format of your own:
cs
var lz4 = new RpcCompressionFormat("lz4",
static () => new LZ4ByteCompressor(),
static () => new LZ4ByteDecompressor(),
new RpcCompressionOptions { MinCompressedFrameSize = 1024 });
var format = new RpcSerializationFormat("msgpack6c-lz4-1k",
() => new RpcByteArgumentSerializerV4(MessagePackByteSerializer.Default),
peer => new RpcByteMessageSerializerV5Compact(peer),
lz4, RpcCompressionMode.ServerToClient);Configuring Formats
Enabling Nerdbank.MessagePack Formats
Add the ActualLab.Serialization.NerdbankMessagePack package and call Register() at startup:
cs
// Register nmsgpack6 / nmsgpack6c formats
RpcNerdbankSerializationFormat.Register();Registering Additional Formats
cs
RpcSerializationFormat.All = RpcSerializationFormat.All.Add(
new RpcSerializationFormat(
"custom",
() => new MyArgumentSerializer(),
peer => new MyMessageSerializer(peer)));Removing Formats
To disable older formats for security:
cs
// To disable older formats for security:
RpcSerializationFormat.All = RpcSerializationFormat.All
.RemoveAll(f => f.Key.StartsWith("mempack5") || f.Key.StartsWith("msgpack5"));Format Selection Factors
When choosing formats, consider:
| Factor | Recommendation |
|---|---|
| Performance | Binary formats (MemoryPack > MessagePack > JSON) |
| Debugging | JSON formats (human-readable) |
| Compatibility | MessagePack for .NET Standard clients |
| Bandwidth | Compact variants (*C) |
| Security | Latest versions; keep the Newtonsoft formats out of clients' reach (the default) |
Serialization in RPC Pipeline
- Client serializes method arguments using
ArgumentSerializer - Arguments are wrapped in an
RpcMessageand serialized byMessageSerializer - Binary data is sent over WebSocket
- Server deserializes in reverse order
Size Limits
Every RPC size ceiling is explicit and enforced on both the send and the receive path. They were tightened substantially in v14.2 — the pre-14.2 values are shown for comparison, since a peer that relied on the old headroom will now be rejected:
| Limit | Declared on | v14.2 | Before |
|---|---|---|---|
| Max frame (= one batch of messages) | RpcFrameBasedTransport.DefaultMaxFrameSize | 16,711,680 (16 MiB − 64 KiB) | 33,489,152 |
| Max pre-handshake frame | RpcFrameBasedTransport.DefaultMaxPreHandshakeFrameSize | 16,384 | same as the frame limit |
| Max argument data per message | RpcByteMessageSerializer.Defaults.MaxArgumentDataSize, RpcTextMessageSerializer.Defaults.MaxArgumentDataSize | 16,252,928 (15.5 MiB) | 16 MiB |
| Max method reference (UTF-8) | RpcMethodRef.MaxUtf8NameLength, RpcByteMessageSerializer.MaxMethodRefSize | 1,024 | 65,536 |
| Max single header value | RpcByteMessageSerializer.MaxHeaderSize | 1,024 | 65,536 |
| Max headers per message (text formats) | RpcTextMessageSerializerV3.MaxHeaderCount | 31 | 31 |
| Max text envelope | RpcTextMessageSerializerV3.MaxEnvelopeSize | 244,297 | 12,261,961 |
| Max API version set | RpcHandshake.MaxApiVersionSetCount / MaxApiVersionSetLength | 16 scopes / 512 chars | unbounded |
The frame limit sits 64 KiB below the 16 MiB ArrayPool bucket on purpose: ArrayPoolBuffer rounds every capacity request up to the next power of two, and RpcStreamTransport buffers a 4-byte length prefix plus read-ahead alongside the frame — without the reserve, a maximum-size frame would push its receive buffer into the next (32 MiB) bucket. MaxArgumentDataSize is 15.5 MiB rather than a round 16 MiB for the same reason: the payload, the worst-case envelope of the most expensive registered format and the frame delimiter must all fit one frame.
An over-limit header, method reference or payload is rejected while reading, and the message is dropped without an error reply — the remote peer sees the call as never answered. Raise MaxArgumentDataSize (on both serializer base classes) if you genuinely move payloads this large, but keep the frame ceiling in mind: RpcWebSocketTransportSizeTest pins that a maximum-size message still fits a maximum-size frame in every registered format.
W3C trace context is bounded by its own spec limits too: an over-length tracestate is dropped while its traceparent is still adopted.
Polymorphic Serialization
By default, ActualLab.Rpc treats abstract types and object as polymorphic. When a method argument or result is polymorphic, the serializer wraps it with a TypeRef so the actual runtime type can be restored on the other side.
This is determined by RpcArgumentSerializer.IsPolymorphic(Type):
cs
// These are considered polymorphic by default:
IsPolymorphic(typeof(ITuple)) // true - it's an interface (abstract)
IsPolymorphic(typeof(object)) // true
// Concrete types are not:
IsPolymorphic(typeof(string)) // false
IsPolymorphic(typeof(int)) // false
// Since v14.2 arrays are seen through — the element type decides:
IsPolymorphic(typeof(Shape[])) // true if Shape is polymorphic
IsPolymorphic(typeof(int[])) // falseBefore v14.2 an array was never polymorphic (an array type isn't abstract), which forced the stream-batch path to widen its declared argument type to object just to reach the polymorphic serializer — and the declared type is also the bound that a wire-supplied type name is checked against. Recursing into the element type keeps the declared type exact, so the accepted set equals the producible set.
Opting Out with [RpcSerializable]
When the underlying serializer already handles polymorphism (e.g., via [JsonDerivedType], [MemoryPackUnion], or [Union]), the RPC layer's TypeRef wrapping is redundant overhead. Apply [RpcSerializable] to the base type to tell RPC that the type can be serialized directly:
cs
// The underlying serializers handle polymorphism via union attributes,
// so we mark this type as RPC-serializable to opt out of TypeRef wrapping.
[RpcSerializable]
[MemoryPackable]
[MemoryPackUnion(0, typeof(ShapeCircle))]
[MemoryPackUnion(1, typeof(ShapeRect))]
[MessagePackObject]
[Union(0, typeof(ShapeCircle))]
[Union(1, typeof(ShapeRect))]
[JsonDerivedType(typeof(ShapeCircle), "circle")]
[JsonDerivedType(typeof(ShapeRect), "rect")]
public abstract partial class Shape
{
[DataMember, MemoryPackOrder(0), Key(0)]
public string? Name { get; set; }
}
[DataContract, MemoryPackable(GenerateType.VersionTolerant), MessagePackObject]
public partial class ShapeCircle : Shape
{
[DataMember, MemoryPackOrder(1), Key(1)]
public double Radius { get; set; }
}
[DataContract, MemoryPackable(GenerateType.VersionTolerant), MessagePackObject]
public partial class ShapeRect : Shape
{
[DataMember, MemoryPackOrder(1), Key(1)]
public double Width { get; set; }
[DataMember, MemoryPackOrder(2), Key(2)]
public double Height { get; set; }
}With this attribute, RpcArgumentSerializer.IsPolymorphic(typeof(Shape)) returns false, so methods like Task<Shape> GetShape(...) use regular serialization. The discriminated union support in each serializer takes care of preserving the actual runtime type.
The attribute uses Inherited = true, so derived types also inherit the opt-out.
When to Use
Use [RpcSerializable] when:
- Your abstract base type or interface has serializer-level union support (
[JsonDerivedType],[MemoryPackUnion],[Union]) - You want to avoid the overhead of RPC's
TypeRefwrapping - All concrete subtypes are declared in the union configuration
Related Topics
- Core Serialization - General serialization infrastructure
- RPC Key Concepts - RPC architecture overview
- Configuration Options - RPC configuration
