Building distributed .NET applications means answering the same question every time: how do the parts talk to each other? REST needs hand-written HTTP clients and offers no compile-time safety. SignalR invokes methods by string name, which makes refactoring risky. gRPC brings type safety at the price of .proto files and a code-generation step. WCF had the right programming model and stayed behind on the legacy framework.
WitRPC takes the part worth keeping, the interface-driven contract, and builds it for modern .NET. Define a C# interface once and use it everywhere: on the server as the implementation contract, on the client as the proxy.
The model in thirty seconds
A service contract is a plain interface, events included:
public interface IChatService
{
Task SendMessage(string user, string message);
Task<List<string>> GetActiveUsers();
event Action<string, string> MessageReceived;
}The client obtains a proxy and calls it like a local object, with IntelliSense, refactoring, and type checking intact:
var chatService = client.GetService<IChatService>();
await chatService.SendMessage("Alice", "Hello, everyone!");
var users = await chatService.GetActiveUsers();
chatService.MessageReceived += (user, message) =>
Console.WriteLine($"{user}: {message}");When the server raises MessageReceived, every subscribed client gets it in real time. Communication is full-duplex over one connection: no polling, no separate push channel. Transports range from memory-mapped files and named pipes for local IPC to TCP and WebSockets for the network, with a REST layer for callers that are not .NET at all.
Why version 3 breaks the wire
Every release until now kept the wire format stable, and some of the framework's oldest debts lived exactly there: a global lock that serialized every request across all connections, encryption without authentication, dispatch that routed calls by method name alone, and a retry policy that would happily re-run failed business logic. None of that could be fixed without changing the bytes on the wire. WitRPC 3 changes them once, and makes it the last time: payload models are version-tolerant from 3.0 on, so future 3.x releases can add fields without breaking older peers.
The consequence to plan for: protocol 3 is not wire-compatible with 2.x. A 3.x server refuses a 2.x client with a readable version message in its log; a 2.x client cannot read 3.x bytes at all. Every connection updates both ends in one wave.
What the break buys
Real concurrency. 2.x serialized every request and callback through one global semaphore, so one slow call stalled every client on the server. 3.x invokes service methods concurrently across connections. The flip side is a new obligation: implementations must be thread-safe. For a service written before 3.0, options.WithMaxConcurrentRequests(1) restores the old fully serialized behavior while you audit, and the cap lifts per server when the service is ready.
Authenticated encryption. AES-CBC gave confidentiality and nothing else. 3.x encrypts with AES-256-GCM (separate keys per direction derived via HKDF-SHA256, strictly ordered frame counters), so a tampered, replayed, reordered, or dropped frame raises WitExceptionEncryption instead of silently producing garbage. The standard, BouncyCastle, and Blazor Web Crypto encryptors all moved together, and the new path benchmarks 4.5–6× faster than the CBC it replaces.
Contract-scoped dispatch. Calls and events now carry deterministic contract and method ids computed from namespace-qualified names. Two registered interfaces with identical method signatures no longer collide, events are delivered only to proxies of the contract that declares them, and parameters deserialize against the method's declared types, with no per-call reflection scans and no Type.GetType on wire-supplied names.
Retries that tell the truth. 3.x splits client-local failures (Timeout, TransportError) from service faults (InternalServerError), retries only the former by default, and only on methods you declare idempotent. With no declarations the policy is inert, so a command never silently runs twice:
options.WithRetryPolicy(retry =>
{
retry.MarkIdempotent(nameof(IMyService.GetStatus), nameof(IMyService.ListItems));
});Behind that, every invocation carries a stable InvocationId, and the server answers a duplicate from a bounded cache instead of re-executing the method. This closes the lost-response case that classic retry schemes get wrong.
Hardening across the board. A protocol version handshake refuses mismatched clients readably. Handshakes are time-bounded, frame sizes are capped before allocation, failed authorization closes the transport, and events reach only authorized connections. A throwing service method no longer tears down its connection, in-flight calls fail promptly when a connection drops, and a latent ordering bug (an event overtaking the response it preceded) is gone. The NativeAOT wire path is proven in CI on every run: an AOT-published client completes an encrypted round-trip against a live server.
Serializers become plugins (3.1)
The core packages now carry only what every setup needs: MemoryPack for the message envelope and JSON as the default payload serializer, with WithMemoryPack() for binary payloads. MessagePack and protobuf-net moved to opt-in packages, and a third plugin is new:
OutWit.Communication.Serializers.MessagePack: models annotated for MessagePack-CSharp (SignalR's MessagePack protocol) move over unchanged;OutWit.Communication.Serializers.ProtoBuf: code-first protobuf-net models, as used with protobuf-net.Grpc;OutWit.Communication.Serializers.GoogleProtobuf: protoc-generatedIMessagetypes travel as protobuf wire bytes, exactly as gRPC would send them, which closes the gap for proto-first gRPC migrations.
Call sites stay as they were: add the package on both ends and a using line. Everyone else stops carrying those dependencies, which every Blazor WebAssembly bundle notices.
REST, rebuilt as what it is (3.2)
REST was always meant to be the door for callers that are not WitRPC at all, and 3.2 rebuilds it around that idea, as a compatibility layer with its own host rather than a transport of the protocol. POST {base}/{MethodName} with the arguments as plain JSON, named or positional, and the return value comes back as plain JSON. No envelope, no type names, no handshake; curl, a browser, and a Python script are interchangeable on the wire (so is the .NET WitClientRestBuilder client, a test's way to verify the host or, more rarely, a typed way to consume an external service that agreed on the same protocol; .NET consumers inside the system belong on the persistent transports):
var server = WitServerRestBuilder.Build(options =>
{
options.WithUrl("http://localhost:5000/api/example/");
options.WithService<IExampleService>(new ExampleService());
});
server.StartWaitingForConnection();Several contracts can share one host through WithServices(), and the DependencyInjection packages gained the full REST surface: AddWitRpcRestServer(...) with auto-start, AddWitRpcRestClient<TService>(...) injecting the interface as a proxy, and IHttpClientFactory integration on the client side.
Migrating from 2.x
For most codebases the mechanical part is small: the builder API is the same, and a typical 2.x setup compiles on 3.x unchanged. The checklist that matters:
- Update both ends of every connection in one wave. There is no mixed mode; plan the deployment around edges, not repositories.
- Set
WithMaxConcurrentRequests(1)on every server for the first deploy, then audit each service for shared state and lift the cap where it is safe. - Declare idempotent methods where you relied on retries. In 2.x,
InternalServerErrorwas retried three times by default; in 3.x nothing is retried until you say so. - Add serializer plugin packages if you used
WithMessagePack()orWithProtoBuf(); JSON and MemoryPack users change nothing. - Rebuild REST integrations against the new plain-JSON contract. The payoff is that non-.NET callers no longer need anything WitRPC-specific.
Getting started
# Server
dotnet add package OutWit.Communication.Server.WebSocket
# Client
dotnet add package OutWit.Communication.Client.WebSocketThe Quick Start builds a working client-server pair in a few minutes, and the guides cover setup, transports & serialization, security, resilience, and Blazor in depth, all updated for 3.x. The full release record is in the changelog, and the source is on GitHub under Apache 2.0.
WitRPC 3 has been running in production since the day of its release, carrying a public gateway, a render-node fleet, several Blazor UIs, and a NativeAOT SDK. The break was priced deliberately: one coordinated cutover in exchange for a wire that can now evolve without another one.