Skip to content

gRPC vs REST in 2026: I Decide by Who Calls the Service

gRPC vs REST in 2026: I Decide by Who Calls the Service

I rewrote the same internal service three times last year. REST first, because that’s the default and nobody side-eyes you for picking it. Then gRPC, because a teammate swore our latency would drop. Then halfway back to REST, because the front end couldn’t talk to it without a proxy and I’d run out of patience. Somewhere in that mess I figured out the rule I should have started with, and it isn’t “gRPC is faster.” It’s about who’s calling your service and how chatty they are. So this is the post I wish I’d read before I burned a sprint on it. No benchmarks dressed up as marketing, no “REST is dead.” Just where each one earns its place, with the code that made it click for me.

What gRPC actually changes

gRPC isn’t “REST but binary.” The thing that matters is the contract. With REST you usually describe your API in prose, or in OpenAPI if you’re disciplined, and then the client and server quietly agree to be careful. With gRPC you write a .proto file, and that file generates both sides. The schema stops being documentation you hope stays current. It becomes the source your code is built from.

syntax = "proto3";
package billing.v1;

service InvoiceService {
  rpc GetInvoice(GetInvoiceRequest) returns (Invoice);
}

message GetInvoiceRequest {
  string invoice_id = 1;
}

message Invoice {
  string id = 1;
  string customer_id = 2;
  int64 amount_cents = 3;
  bool paid = 4;
}

Run the generator and you get a typed client and a server stub. Add a field, regenerate, and the compiler tells every caller what changed. That’s the real selling point. Yes, it runs over HTTP/2 and serializes with Protocol Buffers instead of JSON, but the contract-first workflow is what changes how it feels day to day. The official gRPC intro is worth ten minutes if you’ve never seen the flow.

The same endpoint, written both ways

Here’s a plain REST handler in Go for fetching an invoice:

func getInvoice(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    inv, err := store.Find(id)
    if err != nil {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }
    json.NewEncoder(w).Encode(inv)
}

You write the route, parse the path, marshal JSON by hand, and pick the status code yourself. The client, living in some other repo, keeps its own struct that it hopes still matches yours.

Same thing in gRPC, after the proto generates the types:

func (s *server) GetInvoice(ctx context.Context, req *billingv1.GetInvoiceRequest) (*billingv1.Invoice, error) {
    inv, err := s.store.Find(req.InvoiceId)
    if err != nil {
        return nil, status.Error(codes.NotFound, "invoice not found")
    }
    return inv, nil
}

No JSON encoding, no manual status writing, no hand-rolled client struct on the other end. The request and response types are generated once and shared. It’s less code, and the parts that usually drift, like field names and types, can’t drift quietly anymore. I get into this contract-first habit more in my API design post, because it pays off even when you stay on REST.

Where gRPC genuinely wins

Two situations where I stop debating and just reach for it.

The first is service-to-service traffic inside one system. When your own services call each other thousands of times a second, Protocol Buffers over HTTP/2 is meaningfully lighter than JSON over HTTP/1.1. Smaller payloads, multiplexed connections, no re-parsing strings on every hop. The Protocol Buffers docs lay out why the binary format stays compact. You also get generated clients in every language, so your Go service and your Rust service agree on the wire format without anyone writing a client by hand. If you’re choosing the framework for those services in the first place, I wrote up how I pick in my Go web framework post.

The second is streaming. REST can fake it with SSE or long-polling, but gRPC treats bidirectional streaming as a normal thing you declare:

service PriceFeed {
  rpc Subscribe(stream PriceRequest) returns (stream PriceUpdate);
}

Both sides send messages over one open connection for as long as they want. For a live price feed or a chat backend, that’s the right shape rather than a workaround you babysit. The last time I built a dashboard that needed live order updates, the SSE version worked but kept reconnecting and dropping events under load. The streaming RPC just held the connection and pushed updates, and the reconnect logic I’d been maintaining went away.

Where REST is still the right call

gRPC’s weak spot is the place most of my traffic actually comes from: the browser. Browsers can’t speak raw gRPC. You need gRPC-Web plus a proxy like Envoy, or a tool like Connect, which is honestly the nicest version of this I’ve used. Either way it’s real setup, and for a public API it’s setup your consumers have to care about too.

So REST still wins for me when the caller is a browser or a third party, since everyone can curl JSON and nobody wants to install a protoc toolchain just to try your API. It wins when the API is public and you want it browsable and cacheable, because HTTP caching, CDNs, and basically every debugging tool assume REST. And it wins when the thing is just small; a few CRUD endpoints don’t need a build step and a schema compiler bolted on.

I run a couple of public JSON APIs for the projects in my portfolio, and I haven’t moved one of them to gRPC. There’s no payoff that beats “anyone can hit it from a browser tab.”

The parts nobody warns you about

A few things bit me that the tutorials skip.

Debugging feels different at first. You can’t eyeball a gRPC payload in your network tab, since it’s binary. You end up keeping grpcurl around the way you keep curl, and you lean on server logs more than you used to. The first week this feels like a downgrade. After that it’s fine, but budget for the adjustment.

Field numbers are forever. In a proto, the = 1 and = 2 tags are the wire identity, not the field names. You can rename a field freely, but you must never reuse a number for a different field, or old clients will misread new data. It’s a small rule with sharp edges, so I reserve removed numbers explicitly:

message Invoice {
  reserved 4;
  string id = 1;
  string customer_id = 2;
  int64 amount_cents = 3;
}

Errors are their own model. REST has status codes everyone already knows. gRPC ships its own set of status codes, and mapping them cleanly to HTTP at a gateway takes a little thought. Decide your error contract early instead of rediscovering it endpoint by endpoint.

None of these are dealbreakers. They’re the tax you pay for the generated-contract upside, and it’s better to know the bill before you sign up for it.

How I actually decide now

The question I ask isn’t “which is faster.” It’s “who calls this, and how often?”

Internal, high-volume, several languages in the mix, maybe streaming? gRPC. The contract-first workflow and the wire efficiency pay back the extra tooling fast.

Public, browser-facing, or just small? REST. The friction of gRPC at the edge isn’t worth it, and JSON being universal is a feature, not a consolation prize. Team size nudges this too. On a small team where the same people write both ends, the generated contract saves you from a whole class of mismatch bugs. When the callers are strangers on the internet, that benefit evaporates and the setup cost lands on people you’ll never meet.

And you can run both. A setup I keep coming back to is gRPC between the internal services and a thin REST or Connect gateway at the edge for browsers. That’s usually where I land on anything that has private guts and a public face at the same time.

If you want to feel the difference this week, take one internal endpoint, write the .proto, generate the client, and delete the hand-written client struct it replaces. That deletion is the moment it clicks. You’re not babysitting the contract by hand anymore, and you won’t want to go back.