REST vs GraphQL vs gRPC: How to Actually Pick an API Style
Most REST vs GraphQL vs gRPC posts list features in a table and stop. The real decision isn't about features. It's about who consumes your API and what hurts at scale.
I've watched the same meeting happen at three different companies. A team is about to build a new service, and someone asks the question that eats the next ninety minutes: REST, GraphQL, or gRPC? The whiteboard fills up with a feature table. One column says “over-fetching,” another says “strongly typed,” a third says “streaming.” Everyone nods. Nobody decides anything. The table told them what each option is, which is the least useful thing to know.
Here's the part the table never captures. The right API style isn't a property of your data. It's a property of who's on the other end of the wire, and what's going to hurt when you have a hundred times more traffic than you do today. Those two questions decide almost everything. The rest is detail.
Summary
Quick framing before we go deep. These three aren't really competing for the same job, even though every blog post lines them up like they are. REST and GraphQL both live on the public edge, where browsers and mobile apps and third parties talk to you. gRPC mostly lives in the basement, where your own services talk to each other. Once you see that split, half the “versus” framing dissolves.
The same request, three ways
Let's make it concrete. Say you want a user and the titles of their posts. Nothing exotic. This one request shows you the whole personality of each style.
With REST, you talk in resources. A user is a resource, posts are a resource, and you fetch them by hitting URLs that name them.[1] The catch is that the classic version takes more than one round trip:
# Round trip 1: get the user
GET /users/42
200 OK
{ "id": 42, "name": "Ada", "email": "ada@example.com",
"createdAt": "2021-03-01", "plan": "pro" }
# Round trip 2: get that user's posts
GET /users/42/posts
200 OK
[ { "id": 7, "title": "On compilers", "body": "...", "views": 9100 },
{ "id": 9, "title": "Why I like Rust", "body": "...", "views": 220 } ]That extra baggage is over-fetching: the endpoint returns more than the screen needs. The flip side, where one endpoint doesn't give you enough and you have to call a second, is under-fetching. Both are baked into REST's model, because the server decides the shape of each resource ahead of time and every client gets the same shape.
GraphQL flips that. There's one endpoint, and the client sends a query describing exactly the fields it wants.[2] The server hands back that shape and nothing else:
# Single POST to /graphql
query {
user(id: 42) {
name
posts {
title
}
}
}
# Response: precisely the shape you asked for
{
"data": {
"user": {
"name": "Ada",
"posts": [ { "title": "On compilers" }, { "title": "Why I like Rust" } ]
}
}
}gRPC doesn't play on this field at all. You don't hand-write URLs or queries. You define a contract in a .proto file, the schema language for Protocol Buffers, and a code generator turns it into typed client and server stubs in whatever language you use.[3] Calling a method feels like calling a local function:
syntax = "proto3";
service UserService {
rpc GetUserWithPosts(UserRequest) returns (UserReply);
}
message UserRequest {
int32 id = 1;
}
message UserReply {
int32 id = 1;
string name = 2;
repeated Post posts = 3;
}
message Post {
int32 id = 1;
string title = 2;
}Three answers to one question, and they're shaped by three different worldviews. REST thinks in nouns you fetch. GraphQL thinks in a graph you query. gRPC thinks in functions you call. Hold onto that, because it's the thread through everything below.
REST: the boring default, and why boring wins
REST is resource-oriented design over plain HTTP. You model your domain as resources, give each a URL, and use the HTTP verbs (GET, POST, PUT, DELETE) to act on them.[1]It's been the default since the 2010s, and that's not an accident of fashion. It's the right call for a real reason: it rides on the web's own machinery instead of fighting it.
The biggest thing REST gets for free is caching. Because a GET /users/42 is just an HTTP GET with a URL, every layer of the web already knows how to cache it: the browser, the CDN, a reverse proxy, all of it, using standard HTTP cache headers.[4] You don't build anything. The infrastructure that already exists does the work. That single property quietly carries an enormous amount of real-world traffic, and it's the thing people forget to value until they give it up.
REST also has the lowest floor of the three. Anyone can hit an endpoint with curlor a browser address bar and read JSON. New engineers understand it on day one. Third parties integrate without a special client. When you're publishing an API for the outside world, that legibility is most of the value.
Why this matters
So what does REST actually cost? Two things, and they're the same two we saw above. Over-fetching and under-fetching mean clients either haul down fields they don't use or fire several requests to stitch one screen together. And as the product grows, you fight that by adding endpoints: /users/42/posts/summary, /dashboard/feed, a custom endpoint for the mobile home screen. That's endpoint sprawl. It works. It just slowly turns into a pile of bespoke routes nobody wants to own, each tuned to one screen that shipped two years ago.
Here's my actual opinion: that sprawl is annoying, not fatal. A well-kept REST API with a handful of purpose-built endpoints for your heaviest screens handles the vast majority of products just fine. People treat endpoint sprawl like a crisis that demands GraphQL. Most of the time it's just a tidy-up job you keep putting off.
GraphQL: a query language, and a server you now have to defend
GraphQL gives the client the steering wheel. One endpoint, a typed schema, and queries that ask for exactly the fields a screen needs.[2] Where this genuinely shines is when you have many different clients hitting one backendand each wants a different slice of the data. A web dashboard wants a dense table. The iOS app wants three fields and a thumbnail. The Apple Watch wants almost nothing. With REST you'd build three endpoints or ship over-fetched payloads to the small screens. With GraphQL, each client writes the query it needs and the server doesn't change.
That's also why product teams that iterate fast like it. The front-end can add a field to a screen without waiting on a back-end deploy, as long as the field already exists in the schema. For a company shipping UI changes daily across web and mobile, that decoupling is real velocity, and it's the honest case for GraphQL.
Heads up
Three costs come with the territory, and you should price them in before you commit.
Caching gets hard. Every GraphQL request is typically a POST to a single /graphqlURL, so the HTTP-level caching that REST gets for free just doesn't apply. The URL is always the same and the body is always different.[5] You can claw caching back with persisted queries and client-side normalized caches, but now caching is a project you staff, not a header you set.
The N+1 resolver trap.GraphQL resolves a query field by field. Ask for 50 users and each user's posts, and a naive server does one query for the users and then one more per user for posts. That's the N+1 problem, and it'll quietly melt your database. The standard fix is a batching layer like DataLoader, which coalesces those calls.[6]It works well. It's also one more thing you have to know about and wire up correctly.
Expensive queries are an attack surface.Because clients control the shape, a client can request a deeply nested, wildly expensive query and hand your database a bill you didn't agree to. Defending a public GraphQL endpoint means query depth limits, complexity scoring, and timeouts, none of which you needed when the server decided the shape.[7]This is exactly why I'm wary of GraphQL on a fully public, third-party-facing API. You're handing strangers a query planner.
gRPC: fast, typed, and not for browsers
gRPC is a different animal. It's binary Protocol Buffers sent over HTTP/2, with strongly-typed contracts defined in .proto files and code generated for both ends.[3]It also supports streaming in both directions natively, so a server can push a flow of messages without the client re-polling. When you need raw throughput and low latency between services you own, this is the right tool, and it isn't close.
The binary format is the whole point. JSON is text your CPU has to parse and your network has to carry, field names and all, on every message. Protobuf packs the same data into a compact binary frame, which is both smaller on the wire and faster to encode and decode.[8] At the scale of a microservice mesh doing millions of internal calls a minute, that difference compounds into real money and real latency budget.
The catch is that gRPC pays for that speed with friction everywhere a human or a browser is involved.
Browsers can't speak it directly.A web page can't call a gRPC service straight from JavaScript, because browsers don't expose the low-level HTTP/2 control gRPC needs. You have to run a proxy layer, gRPC-Web, that translates between the browser and the real gRPC backend.[9]That's extra infrastructure for the privilege of talking to your own front-end, which is exactly why gRPC rarely belongs on the public edge.
It's hard for humans to read.A Protobuf message on the wire is binary. You can't curl it and eyeball the response. Debugging means special tooling like grpcurlor a reflection-aware client. That's a fair trade inside a platform team that lives in this stack. It's a tax on everyone else.
The tooling has weight.You need the Protobuf compiler, a build step that regenerates stubs when the contract changes, and that pipeline wired into every language your services use. None of it is hard. All of it is overhead you don't carry with REST, where the “tooling” is an HTTP client you already have.
Context
.protofile is a shared, version-controlled agreement between teams. Change a field type and the other team's build breaks immediately, not in production. Inside a company, that strictness is a feature. Facing the public, it's a wall.Takeaway
REST trades efficiency for legibility and free caching. GraphQL trades server simplicity for client flexibility. gRPC trades human friendliness for raw speed. You're not picking the best one. You're picking which trade you can live with.
The decision rule
Strip away the table and it comes down to three questions about your consumer. Here's the rule I actually use.
- Public API, third-party integrators, or plain CRUD? Use REST. If outsiders consume it, or it's straightforward create-read-update-delete on resources, REST's legibility and free caching win. Don't overthink it. This is most APIs.
- One backend feeding many demanding clients with varied data needs? Consider GraphQL. Web plus iOS plus Android, each wanting a different slice, with a product team iterating fast. The flexibility pays for the caching and resolver complexity you take on.
- High-throughput internal service-to-service traffic? Use gRPC.A microservice mesh where you own both ends, latency matters, and nobody's pointing a browser at it directly. The typed contracts and binary speed are exactly what you want down there.
Now the opinion I'll actually defend. Most teams reaching for GraphQL would be perfectly fine with REST plus a couple of tailored endpoints for their heaviest screens. They're buying a query engine, a caching project, and an N+1 footgun to solve a problem that a /dashboard/feedendpoint solves in an afternoon. GraphQL is a great answer to a question a lot of teams don't actually have yet. Be honest about whether you have it.
Side note
What I'd do on Monday
If you're starting a new service and you're not sure, start with REST. It's the lowest-regret choice. It caches for free, anyone can debug it, and if you outgrow it you'll know exactly which screens hurt and why. That knowledge is what tells you whether the next move is GraphQL or just three more endpoints.
Reach for gRPC the moment you're writing service-to-service calls that need to be fast and you control both ends. That decision is usually easy once you see the traffic. And reach for GraphQL only when you can name the clients, count them, and show that their data needs genuinely diverge. If you can't name them, you don't have the problem GraphQL solves. You have a REST API that needs a little tidying.
The feature table was never the decision. Who's on the other end of the wire was the decision the whole time.
Sources and further reading
- 1.PrimaryMDN: REST, resource-oriented design over HTTP. developer.mozilla.org
- 2.PrimaryGraphQL: queries and the field-selection model. graphql.org
- 3.PrimarygRPC: introduction, Protocol Buffers and generated stubs. grpc.io
- 4.PrimaryMDN: HTTP caching with standard cache headers. developer.mozilla.org
- 5.PrimaryGraphQL: caching, and why HTTP-level caching does not apply. graphql.org
- 6.PrimaryDataLoader: batching and caching to fix the N+1 resolver problem. github.com/graphql/dataloader
- 7.ReportingOWASP GraphQL Cheat Sheet: query depth and complexity limits. cheatsheetseries.owasp.org
- 8.PrimaryProtocol Buffers: compact binary serialization overview. protobuf.dev
- 9.PrimarygRPC-Web: the proxy layer browsers need to reach gRPC services. grpc.io
Written by
Tech Talk News Editorial
Tech Talk News covers engineering, AI, and tech investing for people who build and invest in technology.