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.

users/v1/users.proto
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;
}
server.ts
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

routes/users.ts
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 });
});
client.ts
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

schema.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.graphql
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

CriteriongRPCRESTGraphQL
Contract.proto, enforced by generation on both sidesOpenAPI if you maintain it; discipline-dependentSchema, enforced at the gateway
Payload formatBinary protobuf; smallest on the wireJSON; human-readableJSON; shaped to the query, no unused fields
Client effortGenerate stubs; strongly typed callsA fetch call; typing is your problem unless generatedA query string plus a client library for caching/normalisation
StreamingFirst-class: client, server, and bidirectional streams in the protocolBolted on via SSE or chunked responsesSubscriptions exist, but transport and tooling maturity vary
Browser reachabilityNot directly; needs gRPC-Web and a proxyNativeNative (plain HTTP POST)
Error modelStatus codes plus structured details; unfamiliar to HTTP toolingHTTP status plus body conventions (use problem details)HTTP 200 with an errors array alongside partial data
HTTP cachingEffectively none; roll your ownFull: ETags, cache headers, CDNs where applicablePoor by default; persisted queries plus POST defeat URL caching
DebuggabilityNeeds grpcurl and reflection; payloads are binarycurl and browser devtools show everythingQuery is inspectable; resolver-level failures can hide behind partial data
Schema evolutionExcellent if field-number discipline holdsVersioning or additive-only discipline, enforced sociallyAdditive with deprecation markers; the schema tracks it natively
Chatty consumersOne RPC per call; design coarse operationsN+1 requests unless you build aggregatesOne 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Written by Joan Ayebola

I’m a software engineer and technical writer with more than four years of experience building software and explaining how it works. I write tested tutorials, migration guides, and documentation that help developers understand a tool quickly and use it with confidence.

Keep reading