DEV Community

Cover image for REST, GraphQL, or gRPC: Picking the Right One Per Boundary, Not Per Company
Andrii B.
Andrii B.

Posted on

REST, GraphQL, or gRPC: Picking the Right One Per Boundary, Not Per Company

Somewhere in most engineering orgs there's a sentence that quietly did a lot of damage: "we're a GraphQL shop." Or a gRPC shop, or a REST shop. It sounds like discipline, like a team that made a decision and stuck to it. It's actually a category error, because the protocol was never a property of your company. It's a property of each boundary, and a single healthy system will rightly speak all three, one at each edge, for reasons that have nothing to do with taste.

REST, GraphQL, and gRPC are not three answers to one question. They're good answers to three different questions, and the questions are set by who's standing on the other side of the wire. Pick per boundary and each edge gets the thing that fits. Pick per company and you spend the next three years paying your chosen protocol's weakness at every edge where it was the wrong call.

The protocol is a property of the boundary

Here's the reframe that makes the whole decision easy. Before you argue about protocols, look at the boundary and answer four questions about it.

Who's on the other side? The public internet? A browser or mobile app you also build? Another team's service inside your own network? A third party you'll never meet? Each of these wants something different, and "other team's internal service" and "some developer integrating your public API" are about as different as two consumers get.

Do you own both ends? This is the big one. If you control the client and the server, you can use a strict binary contract and change both sides together. If you don't, you need something forgiving, debuggable, and standard, because the other end is out of your reach and will break in ways you can't fix.

What shape is the traffic? One request, one response? A client that needs many different slices of related data? A long-lived stream of updates? The shape of the conversation rules some protocols in and others out before you've written a line.

What do you need from the transport itself? Cacheability at the HTTP layer? The ability to curl it at 3am and read the response with your eyes? Raw throughput? A schema the compiler enforces? These aren't nice-to-haves; each one points at a different protocol.

Answer those four about a specific edge and the choice mostly makes itself. Answer them once for the whole company and you've guaranteed a bad fit somewhere.

A decision matrix keyed on who's on the other side, do you own both ends, traffic shape, and what you need from the transport, mapping each answer to REST (public/third party, forgiving, request-response, HTTP caching and curl-debuggable), GraphQL (your own varied web/mobile clients, exact fields in one round trip), or gRPC (internal service you own on both ends, high-volume plus streaming, raw speed and a strict contract)

The three, honestly

Skip the feature matrices. Here's what each one is actually best at, and the bill that comes with it, because the bill is where teams get surprised.

REST is the language of the open web, and its superpower is that everyone already speaks it. It rides plain HTTP, so you get caching, proxies, CDNs, browser support, and curl for free, and any developer on earth can integrate it without a toolchain. That's exactly why it's the right default for public and third-party APIs: the other side is a stranger, and REST asks nothing of them. The bill is that REST has no opinion about your data shape, so clients over-fetch (you return the whole user when they wanted a name) or under-fetch (they call three endpoints to assemble one screen), and without something like OpenAPI bolted on, there's no contract the compiler checks. For a public API those costs are worth paying. Between two of your own services, they're just friction.

GraphQL's superpower is letting many different clients ask for exactly the data they need from one schema. When you own a web app, an iOS app, and an Android app, and all three want overlapping-but-different slices of the same graph, GraphQL kills the over/under-fetching problem dead: each client sends one query for precisely its fields.

# One round trip, exactly the fields this screen needs. No more, no less.
query {
  user(id: "u_42") {
    name
    orders(last: 3) { id total status }
  }
}
Enter fullscreen mode Exit fullscreen mode

That's a genuinely great fit for the client-to-backend edge, the classic backend-for-frontend. The bill is real, though: HTTP caching mostly goes out the window (one endpoint, POST bodies), you invite the N+1 problem and have to solve it with dataloaders, and you take on a schema-and-resolver layer plus query-cost and abuse concerns. Between two internal services that always want the same fixed payload, that whole apparatus is machinery you're maintaining for flexibility nobody's using.

gRPC's superpower is speed and a contract the compiler enforces, over a connection you control on both ends. It serializes with Protocol Buffers (compact binary, not text) over HTTP/2, and in common benchmarks it runs several times faster than JSON-over-HTTP while giving you real streaming, server, client, and bidirectional, plus a .proto file that generates typed clients and servers in every language.

// The contract IS the source of truth. Both sides generate from it.
service Orders {
  rpc GetOrder(GetOrderRequest) returns (Order);
  rpc WatchOrders(WatchRequest) returns (stream Order); // server streaming
}
Enter fullscreen mode Exit fullscreen mode

This is the right call for internal service-to-service traffic where you own both ends, care about latency and throughput, and want a strict schema. The bill: it isn't native to browsers (they can't speak raw gRPC, so you need gRPC-Web or Connect, and true bidirectional streaming still doesn't work from a browser as of 2026), the binary payloads are harder to eyeball, and it adds proxy and infra friction. Point it at the public internet and you've handed every integrator a toolchain problem to solve your performance problem.

What each protocol is best at and the bill it comes with: REST is best at public APIs, HTTP caching, universality and curl-debuggability, and the bill is over/under-fetching and no built-in contract; GraphQL is best at many varied clients getting exact fields in one round trip, and the bill is that caching is hard, N+1, and schema-plus-resolver overhead; gRPC is best at internal service-to-service, fast binary, streaming and a strict .proto contract, and the bill is that it is not browser-native (needs gRPC-Web or Connect), binary is opaque, and it adds infra friction

The map, drawn once

Put the four questions and the three bills together and the boundary map is almost boring in how clearly it falls out:

  • Public or third-party API (strangers, you don't own their end): REST. Reach for GraphQL here only if your public consumers genuinely need to shape their own queries over a rich graph, which is rarer than it sounds.
  • Your own browser and mobile clients talking to your edge (you own both, many different slices): GraphQL, as a backend-for-frontend. Plain REST is a perfectly good answer too when the clients are simple; don't add a graph you don't need.
  • Internal service-to-service, you own both ends, latency and contracts matter: gRPC. This is its home turf and nothing else comes close.
  • Streaming updates: gRPC streaming if it's inside your walls; for browser-facing streams, server-sent events or websockets, or a messaging system if it's really events rather than calls.

And one modern wrinkle worth knowing: the Connect protocol (from Buf) was built specifically to stop forcing this into either-or at the client edge. It's a POST-based protocol that works over HTTP/1.1 or HTTP/2, speaks JSON or binary, runs in browsers and between services, and stays compatible with gRPC. If your pain is "I want one Protobuf schema from the browser all the way down to the services," Connect is the thing that lets a single contract span that whole path without a gRPC-Web translation layer bolted on the side.

One request, three protocols

Here's what "per boundary" looks like in a single trace, and why it isn't chaos. A user opens your web app. The browser sends one GraphQL query to your backend-for-frontend, asking for exactly the fields that screen needs. The BFF fans that out to three internal services over gRPC, because those are your services, on your network, and you want them fast and strictly typed. One of those services needs a shipping quote, so it calls a logistics partner's public REST API, because that partner is a stranger and REST is the only thing you can assume they expose.

Three protocols, one user action, and every hop used the right one for its edge. Nobody had to "win." A gRPC absolutist would be fighting the browser at the top of that trace; a GraphQL absolutist would be running a schema-and-resolver layer between services that only ever exchange fixed payloads; a REST absolutist would be making chatty, uncached, contract-less internal calls where gRPC would have been faster and safer. The polyglot version isn't a compromise. It's each boundary getting what it actually needs.

A single user action crossing three protocols: the browser sends a GraphQL query for exact fields to a backend-for-frontend, which fans out over gRPC to inventory, order and payment services, and the order service calls an external logistics partner over REST. GraphQL is the client edge, gRPC the internal edge, REST the external edge

Why "per company" is the actual anti-pattern

It's worth being precise about why standardizing one protocol everywhere goes wrong, because it always sounds so responsible in the meeting.

When you mandate gRPC everywhere, you spend real effort dragging it somewhere it doesn't belong: the browser. Now every frontend needs gRPC-Web or a proxy, your public integrators need a toolchain to call you, and you're maintaining translation layers to paper over the fact that browsers don't speak your chosen protocol.

When you mandate GraphQL everywhere, you install a schema-and-resolver layer between internal services that exchange fixed, well-known payloads and never needed query flexibility. You gave up HTTP caching, invited N+1 into paths that were simple request-response, and added query-cost policing to service-to-service calls that could have been a typed gRPC method.

When you mandate REST everywhere, your internal calls are chatty and uncached, you have no compiler-checked contract between services that deploy independently, and you're hand-rolling streaming over a protocol that would rather you didn't.

Every one of those is the same mistake: taking a tool that's excellent at one kind of boundary and forcing it onto boundaries it was never good at, in the name of a consistency that only helps the org chart, not the system.

The senior move

The instinct to standardize comes from a good place, fewer things to learn, one way to do it, less sprawl. And inside a single boundary, that instinct is right: pick one protocol for your public API and hold the line; pick one for your service mesh and don't have three. Consistency within a boundary is a real virtue.

The mistake is stretching that consistency across boundaries that have nothing in common except that they're yours. The public internet and your internal service mesh are not the same problem, and the same protocol cannot be the best answer to both. The senior move isn't picking a winner. It's refusing to, and instead asking, at each edge, the four questions that actually decide it: who's on the other side, do I own both ends, what shape is the traffic, what do I need from the transport. Answer those honestly per boundary, let your system be polyglot on purpose, and the protocol war just stops being a war you have to fight.


Originally published at andriiboyko.com.

Top comments (0)