Over the last few years I've ended up running all three of the big API styles in production at the same time. Not because I wanted to — because different parts of the system pulled me in different directions. A public REST API for third-party developers, gRPC for the internal service-to-service mesh, and a GraphQL layer sitting on top of both for our own frontend.
This is what I learned actually running them side by side, including the parts the docs don't tell you.
The setup, in one sentence each
- REST — resources over HTTP, JSON bodies, status codes carry meaning. Boring, predictable, and every tool on earth understands it.
-
gRPC — binary protocol over HTTP/2, contracts defined in
.protofiles, code generated for you. Fast and strict. - GraphQL — a single endpoint where the client asks for exactly the fields it wants. Flexible, but that flexibility cuts both ways.
Where each one genuinely shines
REST: when other people call your API
If third-party developers are your audience, REST is almost always the right answer. The moment you expose an API to people you don't control, the priority shifts from raw performance to "can a stranger figure this out with curl and a README?"
Every HTTP client, every monitoring tool, every load balancer, every browser devtools speaks REST natively. That compatibility is worth more than any latency win.
curl -X POST https://api.example.com/v1/orders \
-H "Content-Type: application/json" \
-d '{"items": [{"sku": "a-100", "qty": 2}]}'
Status codes do the error handling for you. A 429 tells the client to slow down. A 409 says "conflict, retry with new state". Half your documentation writes itself the day you start, because the conventions are already understood.
gRPC: when your own services talk to each other
Inside your own infrastructure, you control both sides of the wire. That's where gRPC wins. Protobuf serialization is dramatically smaller than JSON, HTTP/2 multiplexing removes head-of-line blocking, and the generated client/server code means you never hand-write a request parser again.
service InventoryService {
rpc ReserveStock(ReserveRequest) returns (ReserveResponse);
}
message ReserveRequest {
string sku = 1;
int32 quantity = 2;
}
The thing people miss about gRPC is that the code generation is the feature. You write the .proto, run one command, and both the server stub and the client are generated in every language you use. Renaming a field becomes a compile error instead of a runtime surprise.
GraphQL: when the frontend and backend drift apart
GraphQL shines when the team building the UI is separate from the team building the backend, and you want to ship frontend features without a backend deploy for every one.
query {
order(id: "42") {
status
total
items {
name
quantity
}
}
}
The frontend gets exactly the shape it needs — no more eight REST calls to render one screen, no more over-fetching thirty fields when you need three.
What the docs don't tell you
gRPC is annoying to debug
With REST, when something breaks you can usually reproduce it in a browser or a single curl. With gRPC you need a dedicated tool like grpcurl, and the binary payload means you can't just eyeball a response. Your on-call flow gets more friction. That's a real, recurring cost — not a one-time setup.
GraphQL caching is genuinely hard
REST gives you nice cache keys for free, because the key is just the URL. GraphQL gives you one URL for everything and a POST body that differs on every request. HTTP caching basically stops working, and you end up building application-level caching — or reaching for persisted queries — that you never needed with REST.
REST's "flexibility" becomes a tax
REST is the least opinionated of the three, and that shows up as inconsistency. Field naming, pagination style, error shapes — every new endpoint risks inventing its own convention unless you enforce one. gRPC and GraphQL both hand you a schema that does this enforcement for you.
Versioning and breaking changes
REST handles evolution with URL versioning — /v1, /v2 — simple, but it means you keep maintaining old versions long after you'd like to retire them. gRPC bakes field numbers and deprecation rules into protobuf itself, so additive changes are safe and breaking changes are loud at compile time. GraphQL sidesteps the problem almost entirely: fields are additive by default, and you can mark a field @deprecated while old clients keep working.
The practical result: I spend the least time worrying about versioning with GraphQL, and the most with REST, because old /v1 endpoints have a way of accumulating forever. Every one of them is a small maintenance debt you'll be paying off for years.
A rough decision guide
| Your situation | Pick |
|---|---|
| Third-party / public API | REST |
| Internal service-to-service, high traffic | gRPC |
| One team, own frontend and backend | GraphQL |
| Mobile app on slow networks | GraphQL |
| Simple internal tool, low traffic | REST — don't over-engineer |
| Streaming / real-time | gRPC |
What I'd pick again
If I had to restart today with the same system: REST for anything public, gRPC for the internal mesh, and skip GraphQL until the frontend team actually feels the pain.
That last part is the real lesson. I adopted GraphQL partly because it was popular, not because I had a concrete problem it solved. The caching complexity and the extra tooling were real costs, and for a while they weren't paying for themselves. Now that the frontend team is genuinely large and moves fast, GraphQL earns its keep — but I should have waited until that was actually true.
The boring truth: most of the time REST is fine, gRPC is a great upgrade for the internal path once you measure the traffic, and GraphQL is a power tool for a specific problem, not a default.
Pick the tool for the job you actually have, not the one you read about on Hacker News.
Top comments (0)