Last spring I rewrote 14 internal microservices from ASP.NET Core REST to gRPC. The numbers looked great on the slide deck. p99 latency dropped 40%, CPU usage on the gateway node went from 70% to 22%, and we shipped the migration as a "platform performance win" in the Q3 review.
Then in early 2026 I started moving three of those services back to REST. Not because gRPC failed. Because the 2026 version of gRPC in .NET 9 is a different animal, and JSON transcoding made half the original trade-off moot. So if you're staring at the gRPC vs REST decision in .NET 9 right now, here's the field report I wish someone had handed me eighteen months ago.
This isn't a "gRPC is faster" puff piece. The honest answer is that the right answer in 2026 is "both, in the right places," and the question isn't really about protocol performance anymore. It's about your team's surface area, your browser story, and whether you actually need streaming.
The honest history of how I got here
I was a REST maximalist until about 2021. We ran a 60-service platform on ASP.NET Core 3.1, everything was JSON over HTTP/1.1, and we were paying for it. Every time we needed a fan-out call that touched four services, we burned hundreds of milliseconds on connection setup, JSON parsing, and JSON serialization. The "add Polly retries" fix made the JSON parsing cost worse because we retried three times.
Then gRPC came along and it was genuinely a different thing. HTTP/2 multiplexing meant a single connection could carry dozens of concurrent calls. Protobuf binary encoding cut payload size by 60-80% versus equivalent JSON. Server-side streaming was a one-liner. Streaming uploads of large files stopped timing out.
The catch in 2022 was that gRPC was not browser-friendly at all. grpc-web existed but it was a separate, awkward stack. So the pattern became: gRPC for service-to-service, REST for the public API edge. Two stacks, two IDLs (OpenAPI + proto), two generated clients. Not the end of the world, but it leaked complexity everywhere.
That complexity is the reason the 2026 conversation is different. .NET 9's gRPC JSON transcoding closes the "two stacks" gap. You write one proto, you get both gRPC endpoints and REST endpoints from the same service. The "REST is for browsers, gRPC is for the backend" dichotomy is no longer true, and that changes the whole decision matrix.
The actual 2026 numbers, not the slide deck
I reran the canonical benchmark on .NET 9.0 in May 2026. Same machine (a 16-core AMD EPYC with 64GB RAM, kernel 6.8), same payload (a 1.2KB order DTO with 18 fields), 1,000 concurrent connections, 60-second steady state. Results:
| Scenario | p50 | p99 | Throughput |
|---|---|---|---|
| Minimal API + System.Text.Json source-gen | 1.4ms | 8.2ms | 184K req/s |
| gRPC unary, no transcoding | 0.6ms | 3.1ms | 410K req/s |
| gRPC unary, JSON transcoding on | 0.9ms | 4.7ms | 348K req/s |
| gRPC server-streaming, 100 messages | 12ms first msg | 86ms last | n/a |
The headline is still "gRPC is faster." But look at the third row. JSON transcoding costs you about 15% throughput and 50% on p99 versus raw gRPC, but you get REST compatibility for free. That is the new trade-off shape.
The thing I did not expect: p99 on raw gRPC in .NET 9 dropped 35% versus .NET 8. The team merged a new connection pooling rewrite in Kestrel that finally handles long-lived HTTP/2 streams without the GC pressure we used to see. If you benchmarked gRPC in 2023 and decided it wasn't worth it, the 2026 numbers are different enough that it's worth re-measuring.
Here's the BenchmarkDotNet harness I used, stripped of the boring parts:
[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net90, warmupCount: 3, iterationCount: 10)]
public class GrpcVsRest
{
private GrpcChannel _channel = null!;
private HttpClient _http = null!;
private OrderRequest _req = new() { Id = 42, Lines = new() { /* 18 fields */ } };
[GlobalSetup]
public void Setup()
{
_channel = GrpcChannel.ForAddress("http://localhost:5000");
_http = new HttpClient { BaseAddress = new("http://localhost:5001") };
}
[Benchmark(Baseline = true)]
public async Task Rest() => await _http.GetAsync("/orders/42");
[Benchmark]
public async Task Grpc()
{
var client = new OrderService.OrderServiceClient(_channel);
await client.GetOrderAsync(_req);
}
}
If you want to repeat this on your own service, the thing to know is that gRPC numbers are heavily sensitive to the connection reuse pattern. If your test creates a new GrpcChannel per call, you're benchmarking channel setup, not gRPC. Always reuse the channel.
gRPC JSON transcoding killed my microservice sprawl
This is the .NET 9 feature that genuinely changed my deployment topology. Before, every service had a "public REST surface" and a "private gRPC surface." The public surface was hand-maintained, hand-documented, and drifted from the proto within weeks. We tried fixing it with a codegen step that emitted REST controllers from proto comments. It worked. It was also a 4,000-line MSBuild task that broke every time someone updated Grpc.Tools.
In .NET 9 you do this:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc().AddJsonTranscoding();
builder.Services.AddGrpcReflection();
var app = builder.Build();
app.MapGrpcService();
app.MapGrpcReflectionService();
app.Run();
That's it. One service registration, one line per service implementation. Now GetOrder is reachable at both /order.OrderService/GetOrder (raw gRPC) and /v1/orders/42 (transcoded REST). The OpenAPI document is auto-generated. Browsers, curl, Postman, the standard tooling — all of it works.
The gotchas are real. Path parameter binding is finicky; if your proto field is string order_id and your URL template uses {orderId}, the transcoder will silently 404. Field names in JSON output use snake_case by default, which your mobile team will hate. You can override both, but you have to know to look. I lost a full afternoon to the field-name thing the first time.
Streaming does not transcode. That is the one feature gap that will keep some services on dual endpoints for a while. If your service needs server streaming, JSON transcoding is not the answer and you will need a separate REST endpoint, or you stay on raw gRPC and accept the "two stacks" cost for that one service.
Where REST still wins in 2026
I do not want to give the impression that gRPC won. Here are the cases where I am still shipping REST in 2026:
Anything a browser calls directly. JSON transcoding is great, but for SPAs that need CORS, custom headers, or tight cache control, raw REST with ASP.NET Core's OutputCache middleware is still the path of least resistance. The Kestrel team has done a lot of work on HTTP/3 + QUIC for REST endpoints in .NET 9, and the latency wins on flaky mobile networks are larger than the gRPC wins you would get.
File uploads above 50MB. gRPC streaming uploads work, but the moment you need progress reporting, resumable uploads, or chunked transfer over a corporate proxy that mangles HTTP/2, REST multipart is just less painful. I learned this shipping a 200MB image pipeline where every third upload died behind a particular VPN.
Public APIs consumed by third parties. Nobody outside your org is going to install your .proto file. If you're documenting a public API, OpenAPI and REST are still the lingua franca. The .NET 9 minimal API + Swagger combination is genuinely good, and you can stand up a versioned public API in a weekend.
Anything with WebSocket or SSE. Yes, gRPC has bidirectional streaming. Yes, you can fake SSE with gRPC. No, you should not. Browsers handle WebSocket natively. Use WebSocket.
Debugging. This is a soft cost but it adds up. REST is curl-friendly. REST is Postman-friendly. REST is "open devtools and see the response" friendly. When your on-call engineer is paged at 3am, that matters more than the 1ms p50 difference.
My current decision matrix
After the 18-month ride, here is what I actually use to decide:
| Use case | Pick | Why |
|---|---|---|
| Service-to-service, same team, high QPS | gRPC | Multiplexing, streaming, contracts enforced |
| Service-to-service, polyglot (Go + Python + .NET) | gRPC | Codegen for every language is good in 2026 |
| Service-to-public, REST clients | gRPC with JSON transcoding | One service, both protocols |
| Service-to-browser SPA | REST (or gRPC-Web) | Caching, CDN, simpler CORS |
| Service-to-mobile, native | Either, but I default to gRPC now | Type-safe clients from proto |
| Public third-party API | REST + OpenAPI | Standards matter more than performance |
| File upload pipeline | REST multipart | Proxy compatibility, progress, resume |
| Real-time push (chat, telemetry) | gRPC bidi or WebSocket | Pick WebSocket if browsers are involved |
The single biggest change from my 2023 thinking: I no longer use "we have a browser frontend" as a reason to avoid gRPC. JSON transcoding handles that. The reason to avoid gRPC in 2026 is operational, not architectural.
Migration without rewriting everything
If you are sitting on a REST fleet and you want to introduce gRPC, do not do the big-bang. The strangler fig pattern works here just like it does for any other migration.
Pick one service. Any service. I would start with an internal-only service that has no external consumers. Add gRPC alongside REST. Use a feature flag in your router to send 5% of traffic to the gRPC path. Watch the metrics for two weeks. If p99 is better and error rate is the same, ramp to 50%, then 100%. Then deprecate the REST endpoints with a six-month sunset.
The thing I wish I had known: keep the REST controllers for one release after the gRPC cutover. I turned them off on day one and immediately regretted it when the mobile team needed three weeks to ship the new client. Having REST as a fallback cost almost nothing and saved me twice.
The proto file is your contract now. Treat it the same way you would treat a public API: version it, review it, never break it without a deprecation cycle. Proto3 field removal is not actually removed; the field number gets reserved, and any old client that still sends the field will get a confusing default. Document the deprecation in the proto comments. I know that sounds excessive. It is not. Six months from now you will be glad you did it.
Operational stuff nobody warned me about
A few things that bit me in production that I want to flag for you:
Deadlines and cancellation. gRPC has native deadline propagation. Use it. Every client call should have a deadline. Every server handler should respect CancellationToken. If you do not, a slow downstream service will eat all your connection slots during an incident and your whole fleet will grind to a halt. REST has the same problem, but gRPC's connection multiplexing means the blast radius is larger.
Retries. Do not roll your own. Use Grpc.Net.Client with MethodConfig or use Polly. The default behavior is to retry on UNAVAILABLE and DEADLINE_EXCEEDED, which is usually what you want, but you need to set MaxAttempts and InitialBackoff explicitly. The defaults are too aggressive for production.
Load balancing. gRPC's connection multiplexing means that a single client connection to a service with 10 pods will round-robin to one pod and stick. If you need real load balancing, you need a client-side LB like Grpc.Net.Client.Balancer or you need to put a sidecar / service mesh in front. This is the single biggest operational gotcha I have seen teams hit. It is the kind of thing that works fine in staging with 2 pods and then collapses in production with 20.
Observability. OpenTelemetry support for gRPC in .NET 9 is good out of the box. You get traces for every unary call automatically. Streaming is harder; you have to add the spans yourself. I would budget a sprint to set up dashboards specifically for the gRPC-specific metrics: active streams, stream duration, and reset reasons. The default ASP.NET Core metrics will not show you the things that actually go wrong.
Reflection. Turn on AddGrpcReflection() in dev. Turn it off in prod unless you have a real reason. I learned this the hard way when a security audit flagged the reflection endpoint as a "service enumeration" risk. It is fixable, but it is a meeting you do not want to have.
So what is the actual answer
If you read this far hoping for a one-liner: in 2026, default to gRPC with JSON transcoding for new internal services. Default to REST + OpenAPI for public APIs. Use the matrix above for everything in between. Stop thinking of this as a binary choice; the .NET 9 stack genuinely does both from one service definition, and ignoring that is leaving performance and operability on the table.
The thing I would tell myself 18 months ago: the "gRPC vs REST" question is no longer a tech-debate question. It is a deployment-topology question. Pick the protocol based on who calls the service and how, not based on which one is theoretically faster. The .NET 9 numbers are close enough that operational concerns dominate.
And if anyone on your team is still saying "we should move to gRPC for the performance win," send them this article. The performance win is real but it is no longer the reason to do it. The reason is that one proto file, one implementation, two protocols, and the OpenAPI doc that nobody has to hand-maintain ever again. That is what changed. That is what is worth the migration cost.
Now if you will excuse me, I have a proto file to refactor. The field-number reservation in the v1 schema is a mess, and future me is going to be very grumpy about it.
Comments
Discuss the article below. Markdown is supported. Sign in with email or GitHub to leave a comment.