Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/ProwlEngine/Prowl.Echo/llms.txt

Use this file to discover all available pages before exploring further.

Prowl.Echo is built with performance as a first-class concern. Out of the box it is already faster than System.Text.Json and Newtonsoft.Json. With a handful of opt-in techniques you can push throughput higher still — outpacing even MessagePack on deserialization — while simultaneously shrinking output size.

Benchmark Results

These numbers are from Echo’s own benchmark suite, run with [GenerateSerializer] and [FixedEchoStructure] enabled on a complex object graph: 20 nested objects with 100-element arrays, 50-entry dictionaries, and collections.
  Serialize:
    MessagePack          Avg:   0.0297 ms  (1.64x faster)
    Echo                 Avg:   0.0487 ms
    System.Text.Json     Avg:   0.0708 ms  (2.39x slower)
    Newtonsoft.Json      Avg:   0.1040 ms  (3.51x slower)

  Deserialize:
    Echo                 Avg:   0.0200 ms
    MessagePack          Avg:   0.0470 ms  (2.35x slower)
    System.Text.Json     Avg:   0.1113 ms  (5.56x slower)
    Newtonsoft.Json      Avg:   0.1570 ms  (7.84x slower)

  Round-trip:
    Echo                 Avg:   0.0644 ms
    MessagePack          Avg:   0.0714 ms  (1.11x slower)
    System.Text.Json     Avg:   0.1776 ms  (2.76x slower)
    Newtonsoft.Json      Avg:   0.2533 ms  (3.93x slower)
Echo is the fastest deserializer in the comparison by a significant margin, and leads the round-trip category too. Serialization is close to MessagePack — with the advantage that Echo preserves the full richness of the type system (circular references, polymorphism, type metadata) rather than requiring a stripped-down schema.

Techniques for Maximum Speed

1. Use [GenerateSerializer]

The single most impactful change you can make. Adding [GenerateSerializer] to a partial class or struct triggers Echo’s source generator, which emits a fully type-specialized ISerializable implementation at compile time.
[GenerateSerializer]
public partial class Player
{
    public string Name = "Player";
    public int Health = 100;
    public float Speed = 5.0f;
    public List<string> Inventory = new();
}
The generated code inlines primitive construction, bypasses AnyObjectFormat’s reflection loop entirely, and avoids all dictionary lookups and attribute resolution at runtime. Zero reflection overhead on the hot path.
The class or struct must be declared partial. The generator respects all serialization attributes ([SerializeField], [SerializeIgnore], [FormerlySerializedAs], etc.) and applies them at code-generation time rather than at runtime.

2. Use [FixedEchoStructure] on Small, Stable Types

For types whose field order will never change — math primitives, protocol messages, network packets — mark them with [FixedEchoStructure]. Echo serializes these positionally rather than by name, skipping the cost of writing and reading field name strings.
[GenerateSerializer]
[FixedEchoStructure]
public partial struct Vector3
{
    public float X;
    public float Y;
    public float Z;
}

[GenerateSerializer]
[FixedEchoStructure]
public partial class NetworkPacket
{
    public ushort OpCode;
    public uint SequenceNumber;
    public byte[] Payload = Array.Empty<byte>();
}
Combining [GenerateSerializer] with [FixedEchoStructure] yields the maximum possible speed: generated code with positional layout, no reflection, and no field-name overhead.
Adding, removing, or reordering fields on a [FixedEchoStructure] type is a breaking change for existing serialized data. Only apply this to types you consider permanently stable.

3. Use Binary Format for I/O

Text formats carry significant overhead from UTF-8 encoding, string escaping, and parsing. For any workload where I/O speed matters, use EchoBinaryFormat:
var echo = Serializer.Serialize(myObject);

// Write to file using the binary format
var format = new EchoBinaryFormat();
format.WriteToFile(echo, "data.bin");

// Read back
var loaded = format.ReadFromFile("data.bin");
var result = Serializer.Deserialize<MyObject>(loaded);

Techniques for Minimum Serialized Size

1. [FixedEchoStructure] Skips Field Names

Because positional serialization omits field name strings from the output, [FixedEchoStructure] reduces size as well as improving speed. For types with many short-lived fields — such as game component state or network messages — the savings compound quickly.

2. BinaryEncodingMode.Size with LEB128

EchoBinaryFormat supports two encoding modes. Set EncodingMode = BinaryEncodingMode.Size to switch from fixed-width integers to LEB128 variable-length encoding and LZW string compression:
var format = new EchoBinaryFormat
{
    Options = new BinarySerializationOptions
    {
        EncodingMode = BinaryEncodingMode.Size
    }
};

var echo = Serializer.Serialize(myObject);

// Compact binary output
byte[] compact = format.WriteToBytes(echo);

BinaryEncodingMode.Performance

Fixed-width integers. Fastest read/write speed. Larger output. Default mode.

BinaryEncodingMode.Size

LEB128 integers, LZW-compressed strings. Smallest possible output. Slightly slower I/O. Best for network transmission or storage-constrained environments.

The Primitive Fast Path

Primitives and strings bypass the full formatter pipeline entirely. When Echo encounters a primitive value type or string as the declared field type, it short-circuits directly to a switch on TypeCode and returns a pre-allocated EchoObject with no format lookup, no cache access, and no attribute evaluation. Types that take this fast path: int, float, double, bool, string, long, byte, char, uint, short, ulong, ushort, sbyte, decimal, byte[] This means that objects composed primarily of primitive fields — which covers the majority of game data — are serialized and deserialized with minimal overhead even without source generation.

TypeMode.None — Skip Type Embedding

By default (TypeMode.Auto), Echo embeds type information in the output when the runtime type differs from the declared field type. This supports polymorphism and round-trips where the caller doesn’t know the type ahead of time. If you always know the exact type at the call site, use TypeMode.None to skip embedding type metadata entirely. Output is smaller and the serialize/deserialize path is shorter:
var context = new SerializationContext(TypeMode.None);

// Serialize without any $type or $t/$v wrappers
var echo = Serializer.Serialize(typeof(PlayerState), player, context);

// Deserialize — you must provide the correct type
var restored = Serializer.Deserialize<PlayerState>(echo);
With TypeMode.None, deserialization will fail or produce incorrect results if the data was created from a derived type. Only use this mode when both the writer and reader agree on the exact concrete type.

Cache Management

Reuse SerializationContext Across Calls

SerializationContext carries the reference-tracking dictionaries (objectToId / idToObject) used to detect and resolve circular references. Allocating a new context per call is fine for isolated objects, but if you are serializing a set of objects that share references — for example, a scene graph or an asset bundle — reuse the same context across all calls to avoid re-allocating those dictionaries and to preserve cross-object reference identity:
var context = new SerializationContext();

var echoA = Serializer.Serialize(typeof(Node), nodeA, context);
var echoB = Serializer.Serialize(typeof(Node), nodeB, context);
// nodeA and nodeB's shared children are tracked as the same object in both outputs

Serializer.ClearCache()

Echo caches the format resolution result for every type it has seen, the serializable field list for every type (with pre-computed attribute data), and the type name registry. These caches are permanent for the lifetime of the process under normal conditions. Call Serializer.ClearCache() after hot-reloading assemblies, when a new version of a type has been loaded into the process and you need Echo to re-inspect it:
// After hot-reload of game scripts
assemblyLoader.Reload();
Serializer.ClearCache(); // Force re-resolution of all type metadata
ClearCache() clears the format cache, the serializable field cache, and the type name registry all at once. The next serialization call for each type will pay the full resolution cost — format lookup, field reflection, attribute scanning — before results are re-cached. Avoid calling this on the hot path.

Summary

1

Add [GenerateSerializer] to your types

Mark hot types as partial and add [GenerateSerializer]. This is the largest single improvement available and eliminates reflection entirely for those types.
2

Add [FixedEchoStructure] to stable small types

Apply to math structs, protocol messages, and any type whose field order is frozen. Reduces both size and serialization time.
3

Switch to EchoBinaryFormat for file/network I/O

Replace text formats with EchoBinaryFormat. Use BinaryEncodingMode.Size when output size is a constraint.
4

Use TypeMode.None when the type is always known

Eliminates type envelope overhead when both the serializer and the deserializer know the concrete type at compile time.
5

Reuse SerializationContext for related object graphs

Avoids dictionary reallocation and preserves reference identity across multiple serialize calls.

Build docs developers (and LLMs) love