Skip to content
A Bekkour
Writing

gRPC vs REST: when I actually reach for each (and when gRPC is a mistake)

5 min read

Every “gRPC vs REST” argument I’ve watched happens in the abstract, between people quoting benchmarks at each other. I want to argue it from the other direction — from two things I actually shipped. One uses gRPC and is better for it. One deliberately doesn’t, and would’ve been worse if I’d forced it. So here’s my opinion, and it’s a strong one: gRPC is the right call in a narrow set of cases, and a mistake — or at least an unnecessary tax — nearly everywhere else.

The one question that decides it

Forget throughput numbers for a second. The question that actually predicts whether gRPC pays off is: is this service-to-service, and does it stream? If yes to both, gRPC earns its complexity. If no to either, you’re usually buying a codegen step and a debugging headache you didn’t need.

Here’s the case where the answer was yes to both. I build a deploy engine — a Rails control plane that tells a Go agent on a build box to clone a repo, build a Docker image, and run it, showing every log line live. That’s two services, in two languages, with a long-lived stream of typed events. It’s the textbook gRPC use case, and the proto says it in one line:

service Agent {
  rpc Deploy(DeployRequest) returns (stream DeployEvent);   // one call, many typed events
}

returns (stream DeployEvent) is the whole argument for gRPC right there. A deploy is “one request, many responses over time” — exactly what server-streaming is built for. No long-polling, no inventing my own framing over a raw socket, no while (true) fetch(). Rails opens the call and consumes events as they arrive:

stub.deploy(request, metadata: auth).each do |event|
  case event.kind
  when :KIND_STDOUT, :KIND_STDERR then @deploy.deploy_logs.create!(...)
  when :KIND_COMPLETED           then @deploy.update!(status: event.exit_code.zero? ? :succeeded : :failed)
  end
end

And the second reason gRPC won here: a single .proto file generates the types for both Ruby and Go. The two services physically cannot disagree about the wire format, because they’re generated from the same source. When you own both ends and they’re in different languages, that guarantee is worth a lot — it kills a whole category of “the client and server drifted” bugs.

Where gRPC is a mistake

Now the other side, because the same features that make gRPC great in that case make it wrong in others:

Browsers can’t speak it. This is the big one. A browser cannot make a native gRPC call — you need gRPC-web and a proxy, which is friction for a worse result. So in that same deploy engine, the log stream that goes to the browser does not use gRPC. Rails is the bridge: it consumes the gRPC stream from the Go agent, writes the lines to a database, and the browser tails those over plain HTTP. gRPC service-to-service, HTTP to the browser. If your client is a browser, gRPC is usually the wrong tool for that hop, full stop.

Streaming to a browser is a WebSocket’s job, not gRPC’s. In a different product — a tool that records a customer’s screen and uploads it live — I stream media chunks from the browser to a Go service. That’s streaming, and it’s Go on the receiving end, so by the naive rule you might reach for gRPC. I used a plain WebSocket instead, because the client is a browser and a WebSocket is the native, debuggable, zero-proxy way to do bidirectional streaming from one. Right tool, not the fashionable one.

Public APIs. If third parties consume your API, REST’s boringness is the feature. People can curl it, read it in the network tab, hit it from any language without a codegen step, and cache it with standard HTTP infrastructure. gRPC’s binary frames throw all of that away. A public gRPC API is a tax you pass on to every one of your integrators.

A single service, or a team that’ll resent the toolchain. gRPC comes with a protoc build step, binary payloads you can’t eyeball, and worse ergonomics when something’s wrong at 2am. If you don’t have the “two services, in two languages, streaming” situation to justify that, REST over JSON is simpler, more debuggable, and almost certainly fast enough. “Fast enough” wins more arguments than people admit.

My actual rule

Reach for gRPC when you have all three: service-to-service (not a browser), streaming or genuinely high-throughput, and a contract worth enforcing across languages or teams. That’s when the codegen step and the binary frames buy you something real — as they did for a Rails-orchestrates-Go deploy engine.

Otherwise: REST over JSON for request/response, and a WebSocket when you need to stream to a browser. Not because they’re better in the abstract — gRPC genuinely is, on the axes gRPC optimizes — but because most of the time you’re not on those axes, and the boring choice you can curl will serve you for years. The engineers who get this wrong aren’t the ones who don’t know gRPC. They’re the ones who learned it, got excited, and reached for it on a single JSON API with a browser client.

Cargo-culting the impressive tool is how you end up maintaining someone’s excitement instead of their product. Pick the one the problem actually asks for.


I build systems that span Rails and Go — and I have opinions about where each belongs, formed by shipping both. If you’re making one of these calls and want a second read, get in touch. The mechanics behind the deploy engine above are their own post.

Al Mokhtar Bekkour

Senior Rails & Go engineer in Quebec. I build multi-tenant SaaS and financial integrations, and ship my own products — Constat, Railyard, and Recall. Open to work.

← All writing