Start Debugging

gRPC vs REST vs SignalR for service-to-service calls in .NET 11

For internal service-to-service calls in .NET 11, default to gRPC when you own both ends of the contract and the call is point-to-point. Use REST with JSON when anything you do not control has to call the service. SignalR is not an RPC transport between services: reach for it only when one producer has to fan a message out to many long-lived consumers.

If service A calls service B and nothing else calls B, use gRPC. You own both ends, so a generated client and a binary contract cost you nothing and buy you a payload roughly half the size of the JSON equivalent plus real deadline propagation. Use REST with JSON the moment something you do not control has to call the service: a browser, a partner, a curl command in a runbook. SignalR is the odd one out, and the single most common mistake in this comparison is treating it as a third RPC option. It is not. SignalR is a connection-management and fan-out layer, and it earns its place only when one producer has to push to many long-lived consumers. Everything below targets .NET 11 (Preview 6, SDK 11.0.100-preview.6.26359.118, GA expected November 2026) and C# 14, with Grpc.AspNetCore 2.83.0.

The decision in one table

FeaturegRPCREST with JSONSignalR
Shape of the callPoint-to-point RPCPoint-to-point request/responseOne producer, many consumers
ContractRequired, .protoOptional, OpenAPINone, method names by string
ProtocolHTTP/2 (required)HTTP/1.1, HTTP/2, HTTP/3WebSockets, SSE, long polling
PayloadProtobuf, binaryJSON, textJSON or MessagePack
ClientGenerated from .protoHand-written or OpenAPI-generatedHand-written, strings for method names
StreamingClient, server, bidirectionalServer (chunked / SSE)Server, client, bidirectional
Caller cancellation reaches calleeYes, plus a native deadlineOnly as a connection abortYes as of .NET 11, non-streaming invocations
Callable from a browserNo, needs gRPC-Web or transcodingYesYes, that is the point
Works behind an L4 load balancerBadlyYesNeeds sticky sessions or a backplane
Human-readable on the wireNoYesYes with JSON, no with MessagePack
Ships with ASP.NET CoreNo, out-of-band NuGetYesYes

Two rows decide almost every real case. “Shape of the call” separates SignalR from the other two, and “contract” separates gRPC from REST. If you find yourself weighing rows further down the table, you have probably already made the decision and are looking for permission.

Why SignalR keeps ending up in this comparison, and why it usually loses

SignalR shows up in service-to-service searches because a hub method looks exactly like an RPC:

// .NET 11, C# 14 -- looks like RPC, is not built for it
public sealed class PricingHub : Hub
{
    public Task<decimal> GetPrice(string sku) => _pricing.LookupAsync(sku);
}

A caller can absolutely InvokeAsync<decimal>("GetPrice", sku) from another service and get an answer. It works. What you have built, though, is an RPC channel on top of a technology whose entire design centre is connection lifetime management for clients that come and go. You inherit the costs of that design without needing any of the benefits.

The concrete costs: method names are strings resolved by reflection at dispatch time, so a rename is a runtime failure rather than a build failure. There is no schema, so nothing generates a client and nothing validates a payload shape. Scaling out means every server in the pool needs to reach every connection, which means a Redis backplane or the Azure SignalR Service, plus sticky sessions if you are not on WebSockets. And a hub connection is stateful: your caller now has a reconnect state machine to reason about for what used to be a stateless request.

SignalR is the right answer when the traffic really is fan-out. A pricing service that must push tick updates to forty worker processes is a SignalR problem, because SignalR has groups, broadcast, and a backplane, and gRPC has none of those. Microsoft’s own gRPC and HTTP API comparison says this directly: gRPC supports streaming but has no concept of broadcasting to registered connections, so each gRPC call has to stream to its client individually.

The distinction is fan-out, not “real-time”. gRPC bidirectional streaming is real-time. It is just point-to-point.

What each one actually puts on the wire

The performance argument for gRPC is usually stated as “Protobuf is smaller than JSON” with no number attached. Here is the number, for a message shaped like a typical internal reply:

// proto3
message OrderStatus {
  string order_id   = 1;  // "8f14e45f-ceea-467a-9c1d-2b7f2f0c3a11"
  int32  status     = 2;  // 3
  int64  updated_at = 3;  // 1786060800
  double total      = 4;  // 129.95
  string currency   = 5;  // "EUR"
}
EncodingMessage bytesFramed bytesRatio vs JSON
JSON (System.Text.Json, default options)116116100%
MessagePack (SignalR binary hub protocol)66n/a56.9%
Protobuf (Google.Protobuf 3.35.1)606551.7%
SignalR JSON hub protocol invocationn/a165142%

Methodology: serialized each encoding of the same five fields and counted bytes, measured on Windows 11 with the .NET 10.0.5 runtime (SDK 10.0.201), Google.Protobuf 3.35.1 and MessagePack 3.1.8. The wire formats are specified independently of the runtime version, so the byte counts are identical on .NET 11; only the runtime doing the encoding differs. “Framed bytes” adds gRPC’s five-byte length prefix (one compressed flag byte plus a four-byte big-endian length) and, for SignalR, the JSON invocation envelope plus the 0x1E record separator.

Read that table carefully before you use it to justify anything. Protobuf saves 56 bytes on a 116-byte message. On a service handling ten thousand calls a second that is 560 KB/s of egress, which matters if you are paying for cross-zone traffic and is noise if you are not. The SignalR row is the interesting one: the JSON hub protocol envelope makes a single invocation larger than the plain REST equivalent, because you are paying for type, target, and arguments on top of the payload. Switching a hub to MessagePack claws most of that back, at the cost of the human readability that was the reason to consider a text protocol in the first place.

Serialization size is also the weakest of gRPC’s advantages. The stronger ones are the generated client and the deadline.

When to pick gRPC

// .NET 11, C# 14 -- Grpc.AspNetCore 2.83.0
// Server
builder.Services.AddGrpc();
app.MapGrpcService<OrderService>();

// Client: register through the factory so channels are reused.
builder.Services
    .AddGrpcClient<Orders.OrdersClient>(o => o.Address = new Uri("https://orders"))
    .AddStandardResilienceHandler();

// Call site: the deadline is the point.
var reply = await client.GetStatusAsync(
    new OrderRequest { OrderId = id },
    deadline: DateTime.UtcNow.AddSeconds(2),
    cancellationToken: ct);

Use AddGrpcClient rather than GrpcChannel.ForAddress in application code. Creating a channel per call forces a fresh socket, TCP handshake, TLS negotiation, and HTTP/2 connection preface every time, and the factory reuses the channel for you. If you are layering retries on top, the same resilience handler that wraps HttpClient applies here, because a gRPC channel is a SocketsHttpHandler underneath.

When to pick REST with JSON

// .NET 11, C# 14 -- minimal API + typed client
app.MapGet("/orders/{id}", async (string id, IOrderStore store, CancellationToken ct)
    => await store.FindAsync(id, ct) is { } o
        ? Results.Ok(o)
        : Results.NotFound());

// Caller
builder.Services
    .AddHttpClient<OrdersClient>(c => c.BaseAddress = new Uri("https://orders"))
    .AddStandardResilienceHandler();

For anything more structured than this, returning a typed Results union gets you compile-time checking of the response shapes and a correct OpenAPI document without hand-written attributes, which recovers a slice of the contract discipline that made gRPC attractive.

When SignalR is genuinely the right call

.NET 11 makes SignalR meaningfully better for long-lived connections in two ways. The /refresh endpoint plus EnableAuthenticationRefresh means a hub connection no longer drops when its bearer token expires, which was the single largest source of spurious reconnects in token-authenticated deployments. And SignalR clients can finally cancel a running hub method, so cancelling the CancellationToken you passed to InvokeAsync actually reaches the server. Both features are .NET client only in Preview 6; the JavaScript client and Azure SignalR Service support are still in progress.

The gotchas that pick for you

L4 load balancers break gRPC. A gRPC channel is one HTTP/2 connection, and every call multiplexes onto it. An L4 balancer distributes TCP connections, so every call from that channel lands on the same backend forever. Your fleet gets a hot instance and a lot of idle ones. Fixing it means client-side load balancing or an L7 proxy such as Envoy, Linkerd, or YARP, and that decision usually belongs to a platform team rather than to you. If you cannot make that change, the comparison is over and REST wins. The same class of infrastructure friction shows up when running gRPC in containers, where a proxy that only speaks HTTP/1.1 produces failures that look nothing like a protocol mismatch.

gRPC ships out-of-band and the TFM list proves it. Grpc.AspNetCore 2.83.0, published 3 August 2026, targets net8.0, net9.0, and net10.0. There is no net11.0 target framework, and there is no gRPC section in the What’s new in ASP.NET Core in .NET 11 release notes at all. This is not a support gap: a net10.0 assembly loads and runs on .NET 11. It is a cadence difference. gRPC on .NET is maintained in grpc/grpc-dotnet on its own release schedule, so a .NET 11 feature that would benefit gRPC arrives when grpc-dotnet ships it, not in November. Plan your upgrade notes accordingly.

HTTP/2 is mandatory for gRPC, optional for everything else. That is a real constraint on any hop where you do not control the intermediaries. It also means gRPC does not benefit from HTTP/3 today, while a REST endpoint does: configuring Kestrel to serve HTTP/3 is a one-line endpoint change, and .NET 11’s Kestrel now starts processing HTTP/3 requests without waiting for the control stream and SETTINGS frame, cutting first-request latency on new connections.

SignalR scale-out is a dependency, not a setting. More than one server instance means a Redis backplane or the Azure SignalR Service, and non-WebSocket transports need sticky sessions on top. Compare that with a stateless REST endpoint behind a round-robin balancer before you decide the fan-out is worth it.

Observability is not equal. All three emit ActivitySource traces that flow through OpenTelemetry, so wiring traces to a free backend covers all of them. What differs is what you can see in a network capture: JSON is readable, Protobuf and MessagePack need the schema and tooling.

The recommendation, restated

Draw the boundary at fan-out first. If one service has to notify many long-lived consumers, that is SignalR, and neither of the other two has a substitute for groups and a backplane. Everything else is point-to-point, and there the question is who owns the contract. If you own both ends and can regenerate clients in the same pull request that changes the schema, gRPC pays for itself through the generated client and propagated deadlines, with the smaller payload as a bonus rather than the reason. If anyone outside your build calls the service, ship REST with JSON and stop optimizing bytes you are not paying for.

The failure mode worth avoiding is picking gRPC for a service with three callers a minute because a benchmark showed 51.7% payload size, then discovering that your L4 load balancer pins every call to one pod. Fifty-six bytes per message is not worth a platform migration.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back