APIs & developer platforms / Comparison
gRPC vs REST vs GraphQL for Internal APIs: A Decision Guide with Working Examples
The same internal service built three ways: compare gRPC, REST, and GraphQL on contracts, payloads, streaming, and operability, then decide with a framework you can reuse.

You will build the same internal operation three ways — fetching a user and their recent orders for an internal dashboard — once as a gRPC service, once as REST, and once as GraphQL. Then you will compare the three on the criteria that actually decide internal API architecture: contract strength, payload efficiency, streaming, error handling, caching, and day-two operability. The guide ends with a decision framework and a default recommendation you can defend in a design review.
Internal APIs are a different game from public ones. Your consumers are colleagues, you control both ends (usually), you can coordinate deploys, and nobody’s integration breaks because they pinned a client version three years ago. That freedom changes which tradeoffs matter — and it is why copying the public-API consensus (“REST for everything”) or the hype consensus (“GraphQL for everything”) both lead teams astray.
The operation, three ways
The dashboard needs: given a user ID, return the user and their five most recent orders.
gRPC
The contract is a .proto file. It is the API, and both server and clients are generated from it.
syntax = "proto3";
package users.v1;
service UserService {
rpc GetUserWithOrders(GetUserWithOrdersRequest)
returns (GetUserWithOrdersResponse);
}
message GetUserWithOrdersRequest {
string user_id = 1;
}
message GetUserWithOrdersResponse {
User user = 1;
repeated Order recent_orders = 2;
}
message User {
string id = 1;
string email = 2;
string display_name = 3;
}
message Order {
string id = 1;
string status = 2;
int64 total_minor_units = 3;
string currency = 4;
}import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
const packageDef = protoLoader.loadSync("users/v1/users.proto");
const proto = grpc.loadPackageDefinition(packageDef) as any;
const server = new grpc.Server();
server.addService(proto.users.v1.UserService.service, {
getUserWithOrders: async (call: any, callback: any) => {
const { user, recentOrders } = await loadUserWithOrders(call.request.userId);
callback(null, { user, recentOrders });
},
});
server.bindAsync("0.0.0.0:50051", grpc.ServerCredentials.createInsecure(),
() => server.start());Field numbers, not names, are the wire contract — which is why renaming a field is safe and reusing a retired field number is a breaking disaster. Code generation is not optional tooling here; it is the workflow.
REST
import { Router } from "express";
export const usersRouter = Router();
usersRouter.get("/v1/users/:userId/with-orders", async (req, res) => {
const { user, recentOrders } = await loadUserWithOrders(req.params.userId);
res.json({ user, recentOrders });
});const res = await fetch(`${baseUrl}/v1/users/${userId}/with-orders`);
if (!res.ok) throw new Error(`request failed: ${res.status}`);
const { user, recentOrders } = await res.json();Notice the purpose-built endpoint. REST internal APIs under pressure tend to grow these aggregate endpoints (/with-orders, /summary, ?include=) because consumers need shaped data, not resources. That is not a failure of REST; it is a signal about what your consumers actually want, worth remembering when we get to GraphQL.
GraphQL
type Query {
user(id: ID!): User
}
type User {
id: ID!
email: String!
displayName: String!
recentOrders(limit: Int = 5): [Order!]!
}
type Order {
id: ID!
status: String!
totalMinorUnits: Int!
currency: String!
}query DashboardUser($id: ID!) {
user(id: $id) {
id
email
displayName
recentOrders(limit: 5) {
id
status
totalMinorUnits
}
}
}One endpoint, and the query decides the shape. The dashboard gets exactly its fields; the mobile-internal-tool consumer asks for a different subset of the same schema without anyone adding an endpoint.
The comparison
| Criterion | gRPC | REST | GraphQL |
|---|---|---|---|
| Contract | .proto, enforced by generation on both sides | OpenAPI if you maintain it; discipline-dependent | Schema, enforced at the gateway |
| Payload format | Binary protobuf; smallest on the wire | JSON; human-readable | JSON; shaped to the query, no unused fields |
| Client effort | Generate stubs; strongly typed calls | A fetch call; typing is your problem unless generated | A query string plus a client library for caching/normalisation |
| Streaming | First-class: client, server, and bidirectional streams in the protocol | Bolted on via SSE or chunked responses | Subscriptions exist, but transport and tooling maturity vary |
| Browser reachability | Not directly; needs gRPC-Web and a proxy | Native | Native (plain HTTP POST) |
| Error model | Status codes plus structured details; unfamiliar to HTTP tooling | HTTP status plus body conventions (use problem details) | HTTP 200 with an errors array alongside partial data |
| HTTP caching | Effectively none; roll your own | Full: ETags, cache headers, CDNs where applicable | Poor by default; persisted queries plus POST defeat URL caching |
| Debuggability | Needs grpcurl and reflection; payloads are binary | curl and browser devtools show everything | Query is inspectable; resolver-level failures can hide behind partial data |
| Schema evolution | Excellent if field-number discipline holds | Versioning or additive-only discipline, enforced socially | Additive with deprecation markers; the schema tracks it natively |
| Chatty consumers | One RPC per call; design coarse operations | N+1 requests unless you build aggregates | One request, resolver fan-out handled server-side |
Three rows decide most real architectures.
Contract enforcement. gRPC wins outright because the contract is executable on both ends: a breaking change fails to compile somewhere, loudly, before deploy. GraphQL’s schema is enforced at runtime at the gateway. REST’s contract is enforced by your team’s habits plus whatever contract testing you bolt on. For internal fleets where services deploy independently, “fails to compile” beats “fails in staging, hopefully.”
The N+1 shape problem. If consumers repeatedly need aggregates across resources, REST makes you choose between chatty clients and an ever-growing zoo of bespoke endpoints. GraphQL was designed for exactly this. gRPC sidesteps it by expecting you to design coarse-grained operations up front — fine when consumers are few and known, painful when a new consumer needs a shape nobody designed for.
Operability. REST is the most observable stack you own: every load balancer, log pipeline, cache, and on-call engineer already understands it. gRPC runs over HTTP/2, which breaks assumptions in older tooling — some load balancers, proxies, and WAFs handle it unevenly, and per-request logging is less universal. GraphQL concentrates traffic on one endpoint, so per-operation monitoring requires parsing operation names out of requests, and a single expensive query is a denial-of-service vector you must actively govern with depth and complexity limits.
Failure modes each camp understates
- gRPC: deadlines and cancellation are first-class, which means you must actually set them; a fleet of gRPC services with no deadlines propagates a stuck dependency into a fleet-wide stall. Load balancing is client-side by default, so client configuration drift produces baffling traffic skew. And the binary payload that makes it efficient makes a 2 a.m. incident harder for everyone who is not set up with reflection and
grpcurl. - REST: the contract rots socially. An endpoint documented as returning five fields returns nine after a year of “harmless” additions, one consumer depends on the undocumented field, and your “additive-only” API has an invisible breaking-change surface. Without contract tests or generation from a maintained spec, REST’s flexibility is deferred breakage.
- GraphQL: partial failure is a feature — data plus errors — that most client code handles badly, silently rendering incomplete screens. Persisted queries, needed for caching and security, reintroduce a deployment coupling that erases the “clients ask for anything” freedom. Authorization is per-field, and teams that check permissions at the endpoint discover the gap late.
A decision framework
Ask four questions, in this order:
- Is either endpoint a browser? If a browser must call it directly, gRPC is out (the proxy tax is rarely worth it internally). Choose between REST and GraphQL by question 3.
- Is the traffic service-to-service with high call volume or streaming? Server-side streaming, bidirectional streams, or very chatty fine-grained calls point to gRPC. Its binary framing and HTTP/2 multiplexing are built for this.
- Do consumers need divergent shapes of the same data? Several frontends or internal tools each wanting different subsets and aggregates point to GraphQL — one schema instead of an endpoint per consumer shape.
- Is it simple CRUD between a handful of services? REST. The operability dividend compounds, and the other two are complexity without a problem to justify it.
Mixed answers produce mixed architectures, and that is healthy: gRPC for the service mesh, GraphQL at the aggregation edge for internal tools, REST for the plain integrations. The failure mode to avoid is not picking two; it is picking one as an identity. A useful test in the design review: for each boundary, make the proposer name the specific property — streaming, shape divergence, compile-time contracts, cacheability — that the protocol provides and the alternative lacks. If the answer is a preference rather than a property, the simpler option should win by default.
Day-two operability, honestly assessed
Architecture decisions are made on day one and paid for on day two, so weigh the operational tail of each option before you commit. With REST, your existing dashboards, alerting, and runbooks work unchanged, and a new hire can debug a failing call with tools already on their laptop. With gRPC, budget for the tooling delta up front: server reflection enabled (and access-controlled) in every environment, grpcurl in the incident toolkit, load balancing decisions made deliberately rather than inherited, and health checking implemented per the gRPC health protocol so your orchestrator’s probes mean something. With GraphQL, the gateway becomes a production system in its own right: it needs query cost limits, per-operation metrics extracted from operation names, persisted-query management for any caching story, and an owner — a gateway nobody owns degrades into an unmonitored single point of failure with a schema attached. None of these tails is a reason to reject the option. All of them are reasons to count the cost in the design review rather than discover it in the first incident.
Verdict
For internal APIs, default to REST for straightforward service communication, because your team’s ability to debug, cache, and monitor it at 2 a.m. is worth more than elegance. Choose gRPC where the boundary is service-to-service, high-volume, streaming, or where contract drift has already burned you — its generated contracts are the strongest guarantee on the table. Choose GraphQL where multiple consumers genuinely need different shapes of shared data, and staff the governance that flexibility demands. The teams that regret their choice are never the ones that matched the protocol to the boundary; they are the ones that standardised on a logo.


