WitRPC offers remarkable flexibility: five transport options and four serialization formats. That's twenty possible combinations, each with different performance characteristics. How do you choose the right one?
Today, we'll demystify these options with real benchmark data, practical guidance, and concrete recommendations for common scenarios.
The Performance Equation
WitRPC performance depends on two key choices:
Total Latency = Transport Time + Serialization Time + Framework OverheadTransport determines how bytes move between processes:
- Memory-Mapped Files (MMF): shared memory, no network stack
- Named Pipes: OS-level IPC, optimized for local communication
- TCP: raw sockets, full network capability
- WebSockets: HTTP upgrade, firewall-friendly
- REST: standard HTTP, maximum compatibility
Serialization determines how objects become bytes:
- JSON: human-readable, largest size
- MessagePack: compact binary, good compatibility
- MemoryPack: zero-copy, fastest for .NET
- ProtoBuf: Google's format, cross-language support
Let's examine each option with real data.
Transport Deep Dive
Memory-Mapped Files (MMF): The Speed Champion
Memory-mapped files share a region of memory between processes. There's no network stack, no kernel transitions for data transfer; just memory reads and writes.
Configuration:
// Server
var server = WitServerBuilder.Build(options =>
{
options.WithService(new MyService());
options.WithMemoryMappedFile("MyApp-RPC", size: 10_000_000); // 10 MB buffer
options.WithMemoryPack();
});
// Client
var client = WitClientBuilder.Build(options =>
{
options.WithMemoryMappedFile("MyApp-RPC");
options.WithMemoryPack();
});Benchmark Results (10 MB payload, 50 iterations):
| Serializer | One-Way | Round-Trip |
|---|---|---|
| MemoryPack | 15.58 ms | 46.70 ms |
| ProtoBuf | 24.08 ms | 53.97 ms |
| JSON | 27.48 ms | 80.36 ms |
| MessagePack | 35.51 ms | 74.55 ms |
One detail worth noticing: on large raw-byte payloads, MessagePack lands behind ProtoBuf and even JSON here. MemoryPack's near-zero-copy handling of byte arrays is what puts it firmly in front.
When to use MMF:
- Desktop app communicating with background service
- Plugin host ↔ plugin process communication
- Any scenario where both processes are on the same machine
- Maximum performance is critical
Limitations:
- Same machine only
- Typically one client per MMF channel
- Requires coordinating buffer size between client and server
- Memory is reserved even when not in use
Sizing the buffer:
// Rule of thumb: largest expected message × 2 + overhead
// For 5 MB max messages:
options.WithMemoryMappedFile("MyApp", size: 12_000_000);Named Pipes: The Reliable Local Choice
Named Pipes are an OS-provided IPC mechanism. They handle synchronization automatically and support multiple clients.
Configuration:
// Server
var server = WitServerBuilder.Build(options =>
{
options.WithService(new MyService());
options.WithNamedPipe("MyApp-Pipe");
options.WithMessagePack();
});
// Client
var client = WitClientBuilder.Build(options =>
{
options.WithNamedPipe("MyApp-Pipe");
options.WithMessagePack();
});Benchmark Results (10 MB payload, 50 iterations):
| Serializer | One-Way | Round-Trip |
|---|---|---|
| MemoryPack | 16.66 ms | 36.67 ms |
| ProtoBuf | 28.97 ms | 55.93 ms |
| JSON | 39.57 ms | 90.73 ms |
| MessagePack | 40.00 ms | 75.50 ms |
When to use Named Pipes:
- Local IPC with multiple clients
- Windows services communicating with desktop apps
- Simpler setup than MMF (no size coordination)
- Need automatic client management
Limitations:
- Same machine only (Windows; limited Linux support)
- Slightly slower than MMF
- Pipe names are global on the machine (use unique names)
TCP: The Network Workhorse
TCP sockets provide reliable, ordered delivery across networks. WitRPC's TCP transport is optimized for performance.
Configuration:
// Server
var server = WitServerBuilder.Build(options =>
{
options.WithService(new MyService());
options.WithTcp(5000, maxNumberOfClients: 100);
options.WithMessagePack();
options.WithEncryption(); // Recommended for network
});
// Client
var client = WitClientBuilder.Build(options =>
{
options.WithTcp("server.example.com", 5000);
options.WithMessagePack();
options.WithEncryption();
});Benchmark Results:
Local (10 MB payload, 50 iterations):
| Serializer | One-Way | Round-Trip |
|---|---|---|
| MemoryPack | 23.43 ms | 59.16 ms |
| JSON | 33.16 ms | 85.89 ms |
| MessagePack | 35.10 ms | 77.22 ms |
| ProtoBuf | 36.10 ms | 68.38 ms |
Remote (1 MB payload, Israel → Germany, medians over 50 runs):
| Serializer | One-Way | Round-Trip |
|---|---|---|
| MemoryPack | 0.29 s | 0.39 s |
| MessagePack | 0.37 s | 0.40 s |
| ProtoBuf | 0.37 s | 0.25 s |
| JSON | 0.48 s | 0.60 s |
Numbers are medians over 50 runs; internet timings are noisy, and the ProtoBuf round-trip landing below its own one-way is exactly that session-to-session variance at work.
When to use TCP:
- Backend microservices communication
- LAN communication between servers
- Need raw performance over network
- Full control over port configuration
Limitations:
- Requires firewall configuration
- NAT traversal can be complex
- Not directly accessible from browsers
Secure TCP:
// Server with TLS
var certificate = new X509Certificate2("server.pfx", "password");
options.WithTcpSecure(5000, maxNumberOfClients: 100, certificate);
// Client connects to TLS endpoint
options.WithTcpSecure("server.example.com", 5000);WebSockets: The Firewall-Friendly Option
WebSockets upgrade from HTTP, making them firewall and proxy-friendly. They work in browsers (via Blazor) and behind load balancers.
Configuration:
// Server
var server = WitServerBuilder.Build(options =>
{
options.WithService(new MyService());
options.WithWebSocket("http://0.0.0.0:8080", maxClients: 100);
options.WithMessagePack();
});
// Client
var client = WitClientBuilder.Build(options =>
{
options.WithWebSocket("ws://server.example.com:8080");
options.WithMessagePack();
});Benchmark Results (10 MB payload, local, 50 iterations):
| Serializer | One-Way | Round-Trip |
|---|---|---|
| MemoryPack | 48.38 ms | 117.55 ms |
| ProtoBuf | 58.32 ms | 137.64 ms |
| MessagePack | 67.26 ms | 139.08 ms |
| JSON | 71.14 ms | 168.77 ms |
When to use WebSockets:
- Blazor WebAssembly clients
- Communication through corporate firewalls/proxies
- Load-balanced environments
- Need to use standard HTTP ports (80/443)
Limitations:
- Higher overhead than raw TCP
- HTTP upgrade adds initial latency
- More complex infrastructure (reverse proxies)
REST: The Compatibility Mode
REST mode provides stateless HTTP endpoints. It's useful for interoperability but doesn't support WitRPC's full feature set.
Configuration:
// Server
var server = WitServerRestBuilder.Build(options =>
{
options.WithUrl("http://0.0.0.0:8080/api/");
options.WithService(new MyService());
options.WithJson(); // REST typically uses JSON
});
// Client (can be any HTTP client)
var client = WitClientBuilder.Build(options =>
{
options.WithRest("http://server.example.com:8080");
options.WithJson();
});When to use REST:
- Interoperability with non-.NET clients
- Debugging/testing with tools like curl or Postman
- Integration with systems that only support HTTP
Limitations:
- No event support (server → client push)
- Request-response only
- Higher overhead than other transports
- Stateless (no persistent connection)
Serialization Deep Dive
JSON: The Debuggable Default
JSON is human-readable and universally understood. It's WitRPC's default for a reason: it works everywhere.
options.WithJson();Characteristics:
- ✅ Human-readable (great for debugging)
- ✅ No special attributes needed on your types
- ✅ Excellent tooling support
- ❌ Largest payload size
- ❌ Slowest serialization
Payload comparison (same object):
JSON: {"Id":1,"Name":"Task","Status":"Active","Items":[1,2,3]}
→ 58 bytes
Binary: [compact binary representation]
→ ~25 bytesBest for:
- Development and debugging
- Small payloads where size doesn't matter
- When you need to inspect traffic
MessagePack: The Balanced Choice
MessagePack is a binary format that's compact and fast, with excellent .NET support.
options.WithMessagePack();Characteristics:
- ✅ ~50% smaller than JSON
- ✅ ~3x faster than JSON
- ✅ Works with most .NET types automatically
- ✅ Good cross-language support
- ❌ Not human-readable
Best for:
- Production workloads
- When you need a balance of speed and compatibility
- Cross-platform .NET communication
MemoryPack: The Performance King
MemoryPack is a zero-copy serializer designed specifically for .NET. It's the fastest option available.
options.WithMemoryPack();Characteristics:
- ✅ Fastest serialization
- ✅ Smallest payloads
- ✅ Zero-copy for many scenarios
- ❌ Requires
[MemoryPackable]attribute on custom types - ❌ .NET only (no cross-language support)
Type requirements:
[MemoryPackable]
public partial class TaskInfo
{
public int Id { get; set; }
public string Name { get; set; }
public TaskStatus Status { get; set; }
}Best for:
- Maximum performance
- Large payload transfers
- When both sides are .NET
ProtoBuf: The Cross-Language Standard
Protocol Buffers is Google's serialization format, widely used in gRPC and cross-language systems.
options.WithProtoBuf();Characteristics:
- ✅ Excellent cross-language support
- ✅ Compact binary format
- ✅ Well-documented schema evolution
- ❌ Requires
[ProtoContract]and[ProtoMember]attributes - ❌ Slightly slower than MemoryPack
Type requirements:
[ProtoContract]
public class TaskInfo
{
[ProtoMember(1)]
public int Id { get; set; }
[ProtoMember(2)]
public string Name { get; set; }
[ProtoMember(3)]
public TaskStatus Status { get; set; }
}Best for:
- When you might need non-.NET clients later
- Interoperability with gRPC systems
- Strict schema versioning requirements
Benchmark Summary
Local IPC (10 MB payload)
| Transport + Serializer | One-Way | Round-Trip | Relative Speed |
|---|---|---|---|
| MMF + MemoryPack | 15.58 ms | 46.70 ms | 1.0x (fastest) |
| Named Pipes + MemoryPack | 16.66 ms | 36.67 ms | 1.1x |
| TCP + MemoryPack | 23.43 ms | 59.16 ms | 1.5x |
| MMF + JSON | 27.48 ms | 80.36 ms | 1.8x |
| TCP + JSON | 33.16 ms | 85.89 ms | 2.1x |
| WebSocket + MemoryPack | 48.38 ms | 117.55 ms | 3.1x |
| WebSocket + JSON | 71.14 ms | 168.77 ms | 4.6x |
Remote (1 MB payload, intercontinental)
| Serializer | One-Way (median) |
|---|---|
| MemoryPack | 0.29 s |
| MessagePack | 0.37 s |
| ProtoBuf | 0.37 s |
| JSON | 0.48 s |
All remote runs went through the same path: the same client machine in Israel, the same Hetzner server in Germany behind an NGINX reverse proxy with SSL termination. The full methodology and cross-framework comparison are in Comparing RPC Frameworks in .NET Applications, and the benchmark suite is open source.
Decision Framework
Quick Reference Table
| Scenario | Transport | Serializer |
|---|---|---|
| Desktop app + background service | MMF or Named Pipes | MemoryPack |
| Microservices on same network | TCP | MessagePack |
| Blazor WebAssembly | WebSocket | MessagePack |
| Through corporate firewall | WebSocket | MessagePack |
| Development/debugging | Any | JSON |
| Maximum performance, same machine | MMF | MemoryPack |
| Cross-language potential | TCP | ProtoBuf |
| REST API fallback | REST | JSON |
Decision Flowchart
Performance Tuning Tips
1. Match Client and Server Configuration
Both sides must use the same serializer:
// Server
options.WithMessagePack();
// Client: MUST match!
options.WithMessagePack();Mismatched serializers cause silent failures or cryptic errors.
2. Size Your MMF Buffer Appropriately
Too small: large messages fail Too large: wastes memory
// Calculate based on your largest expected message
var maxMessageSize = 5_000_000; // 5 MB
var bufferSize = maxMessageSize * 2 + 1_000_000; // Safety margin
options.WithMemoryMappedFile("MyApp", size: bufferSize);3. Use Binary Serializers for Large Payloads
The difference is dramatic for large data:
1 MB object:
JSON: ~1.2 MB on wire, ~45 ms to serialize
MessagePack: ~0.5 MB on wire, ~12 ms to serialize
MemoryPack: ~0.4 MB on wire, ~5 ms to serialize4. Consider Compression for Network Transfers
For very large payloads over slow networks, compress before sending:
// Manual compression for large data
public async Task<byte[]> GetLargeDataAsync()
{
var data = await GetRawDataAsync();
using var output = new MemoryStream();
using (var gzip = new GZipStream(output, CompressionLevel.Fastest))
{
await gzip.WriteAsync(data);
}
return output.ToArray();
}5. Enable Encryption Only When Needed
Encryption adds overhead. Use it for network communication, skip it for same-machine IPC in trusted environments:
// Network: enable encryption
options.WithTcp("server", 5000);
options.WithEncryption();
// Same machine, trusted: skip encryption for speed
options.WithNamedPipe("MyApp");
// Don't call WithEncryption()6. Profile Your Specific Workload
Benchmarks are guidelines, not guarantees. Profile with your actual:
- Message sizes
- Message frequency
- Network conditions
- Hardware
var sw = Stopwatch.StartNew();
for (int i = 0; i < 1000; i++)
{
await service.ProcessAsync(testData);
}
Console.WriteLine($"1000 calls: {sw.ElapsedMilliseconds} ms");
Console.WriteLine($"Average: {sw.ElapsedMilliseconds / 1000.0} ms/call");Common Pitfalls
Pitfall 1: Using JSON for Large Binary Data
JSON base64-encodes binary data, increasing size by 33%:
// Bad: sending images/files over JSON
options.WithJson();
await service.UploadFileAsync(largeByteArray); // Slow!
// Good: use binary serializer for binary data
options.WithMessagePack();
await service.UploadFileAsync(largeByteArray); // Fast!Pitfall 2: WebSockets for Local IPC
WebSockets add unnecessary HTTP overhead for local communication:
// Wasteful for same-machine communication
options.WithWebSocket("ws://localhost:8080");
// Much faster
options.WithNamedPipe("MyApp");
// or
options.WithMemoryMappedFile("MyApp");Pitfall 3: Forgetting to Await Async Methods
Unawaited calls can cause connection issues:
// Bad: fire-and-forget can cause problems
service.ProcessAsync(data); // Missing await!
// Good: always await
await service.ProcessAsync(data);Pitfall 4: Not Testing Under Load
A configuration that works for 10 clients may fail at 100:
// Test with realistic load
var tasks = Enumerable.Range(0, 100)
.Select(_ => service.ProcessAsync(testData));
await Task.WhenAll(tasks);Conclusion
WitRPC's flexibility means you can optimize for your specific scenario:
- Local IPC: MMF + MemoryPack for maximum speed
- Network services: TCP + MessagePack for the best balance
- Web clients: WebSocket + MessagePack for compatibility
- Development: Any transport + JSON for debuggability
The key is understanding the trade-offs:
| Dimension | Fast | Compatible |
|---|---|---|
| Transport | MMF/Named Pipes | WebSocket/REST |
| Serializer | MemoryPack | JSON |
Start with reasonable defaults (TCP + MessagePack for network, Named Pipes + MessagePack for local), measure your actual performance, and optimize based on real data.
The benchmarks don't lie: the right combination can make your application 4-5x faster than a naive choice. Take the time to choose wisely.
Next up: Building Resilient RPC Clients: Auto-Reconnect and Retry Policies, keeping connections alive on real networks.
This is part 9 of a series on WitRPC. See previous posts: Introducing WitRPC, Getting Started, Real-World Benefits, WitRPC vs. gRPC, Migrating to WitRPC, Under the Hood, Composite Services, and ASP.NET Core Integration.