REST, gRPC and GraphQL.
The Label service can be POST /v1/orders/918273/labels, Labels.CreateLabel or mutation { createLabel }. All three reach the same handler. The style decides who can call it, what can cache it, how it evolves, and how many round trips a screen needs.
Builds on RPC and what it hides.
The idea.
One service, three shapes of contract. The business logic does not change; who can call it, and how cheaply, does.
What an API style decides
Every service needs a contract: what a caller may ask for, how the request travels, and what comes back. REST, gRPC and GraphQL are three answers. They differ on three questions.
What is addressed. REST addresses resources by URL (/v1/labels/L-5521) and acts on them with a small fixed set of HTTP methods. gRPC addresses a method on a service (Labels.CreateLabel), like calling a function. GraphQL addresses fields in one typed graph (order → label → trackingNo), and the caller lists the fields it wants.
What the wire carries. REST usually carries JSON representations of resources. gRPC carries compact binary protobuf messages whose layout both sides know from a shared .proto file. GraphQL carries a query string in and JSON of exactly the query's shape out.
What the middle of the network understands. Browsers, proxies and CDNs know HTTP methods, so a REST GET on a stable URL can be cached anywhere on the path. A gRPC call is always an HTTP/2 POST with a binary body; intermediaries see an opaque stream. GraphQL usually sends everything as a POST to one /graphql endpoint, so shared caches see one URL and cannot tell queries apart.
The same label, bought three ways
POST /v1/orders/918273/labels HTTP/1.1
Host: api.warehouse.example
Content-Type: application/json
Idempotency-Key: 5f1c9e2a-7b4d-4c1e-9a3f-2d8e6b0c4a17
{"carrier":"ground","weight_g":1450}
HTTP/1.1 201 Created
Location: /v1/labels/L-5521
{"label_id":"L-5521","tracking_no":"GRD000918273","cost_cents":612}// labels/v1/labels.proto
package labels.v1;
service Labels {
rpc CreateLabel(CreateLabelRequest) returns (Label);
}
// Caller (generated Go stub):
// label, err := client.CreateLabel(ctx, &pb.CreateLabelRequest{
// OrderId: 918273, Carrier: "ground", WeightG: 1450})
// → Label{label_id: "L-5521", tracking_no: "GRD000918273", cost_cents: 612}mutation {
createLabel(orderId: 918273, carrier: "ground", weightG: 1450) {
labelId
trackingNo
}
}
# → {"data":{"createLabel":{"labelId":"L-5521","trackingNo":"GRD000918273"}}}Three styles in front of one Label service
The styles are not rivals inside one company; they are picked per audience. Partners get REST because every language and every tool speaks it. Internal services get gRPC because it is typed, fast and has deadlines built in. The handheld app gets GraphQL because each screen can fetch what it needs in one round trip. All three end at the same CreateLabel handler.
How it works.
The same label, followed down each stack to what is actually sent.
REST: resources and a uniform interface
REST is a set of constraints from Roy Fielding's 2000 dissertation, not a protocol: client-server, stateless requests (each carries everything needed to serve it), cacheable responses, a layered system (proxies and gateways may sit in the path), optional code on demand, and a uniform interface. The uniform interface has four parts: resources are identified by URIs, they are changed by sending representations, each message describes itself (method, media type, cache headers), and hypermedia links tell the client what it can do next.
Fielding names the price himself: because every resource is handled through the same generic interface, data travels in a standard form rather than one shaped to the application's needs, which costs efficiency. In exchange, every HTTP client, proxy and cache already understands the API.
HTTP methods on the Label service
| Method | Label-service use | Safe | Idempotent | Cacheable |
|---|---|---|---|---|
| GET | /v1/labels/{id} | Yes | Yes | Yes |
| PUT | /v1/labels/{id}/hold {"on":true} | No | Yes | No |
| DELETE | /v1/labels/{id} (void the label) | No | Yes | No |
| POST | /v1/orders/{id}/labels (buy one) | No | No | Rarely: needs explicit freshness and a matching Content-Location |
| PATCH | /v1/labels/{id} (change the note) | No | No, not by definition | Rarely: same rule as POST |
Safe means the client asks for no change (RFC 9110 lists GET, HEAD, OPTIONS and TRACE). Idempotent means sending it twice has the same intended effect as once: every safe method, plus PUT and DELETE. A proxy may retry an idempotent request after a dropped connection; it must not retry a POST. That is why buying a label, a POST, needs an Idempotency-Key (see the idempotency topic).
gRPC: typed procedures on HTTP/2
A gRPC service starts as a .proto file. The protobuf compiler generates a client stub and a server base class in each language, so a Go caller and a Java server agree on every field at build time. Each call is an HTTP/2 stream: many calls share one TCP connection at once without waiting on each other, which is why a single channel between Fulfilment and Labels can carry thousands of calls a second. The call carries a deadline, metadata (key-value headers such as auth tokens) and, at the end, one of 17 status codes.
One CreateLabel call on the wire
- Fulfilment service → Label service: HEADERS POST /labels.v1.Labels/CreateLabel · grpc-timeout 740m
- Fulfilment service → Label service: DATA 00 | 00 00 00 0f | 15-byte CreateLabelRequest
- Note over Fulfilment service and Label service: Label service: deadline 740 ms from now; decode, run the handler
- Label service → Fulfilment service (reply): HEADERS :status 200 · content-type application/grpc
- Label service → Fulfilment service (reply): DATA 00 | 00 00 00 19 | 25-byte Label
- Label service → Fulfilment service (reply): HEADERS (trailers) grpc-status 0
- Note over Fulfilment service and Label service: HTTP :status is 200 even when the call fails; the outcome is grpc-status (5 = NOT_FOUND, 14 = UNAVAILABLE)
Four kinds of call, on the Label service
| Kind | Shape | Label-service example |
|---|---|---|
| Unary | 1 request → 1 response | CreateLabel for one parcel. |
| Server streaming | 1 request → stream of responses | WatchLabel(L-5521) streams carrier scan events until delivery. |
| Client streaming | stream of requests → 1 response | UploadManifest streams 400 parcels at the end of a shift, then gets one summary back. |
| Bidirectional | two independent streams | A packing-station session: scans go up, label PDFs come down as each is ready. |
GraphQL: one schema, caller-chosen shape
Schema, query and response
type Parcel { barcode: ID! order: Order! }
type Order {
id: ID!
status: OrderStatus!
items: [LineItem!]!
label: Label # nullable: may not exist yet
}
type LineItem { sku: String! qty: Int! }
type Label { labelId: ID! trackingNo: String! costCents: Int! }
type Query { parcel(barcode: ID!): Parcel }
type Mutation {
createLabel(orderId: ID!, carrier: String!, weightG: Int!): Label!
}query PackScreen {
parcel(barcode: "P-77120") {
order {
status
items { sku qty }
label { trackingNo }
}
}
}
# {"data":{"parcel":{"order":{
# "status":"PICKED",
# "items":[{"sku":"MUG-12","qty":2},{"sku":"TEA-50","qty":1}],
# "label":{"trackingNo":"GRD000918273"}}}}}How the server resolves the pack-screen query
- Warehouse app → GraphQL server: POST /graphql PackScreen
- Note over Warehouse app and GraphQL server: parcel → order → status, items, label: parse, validate, resolve field by field
- GraphQL server → Order service: GetParcel(P-77120)
- Order service → GraphQL server (reply): order 918273
- GraphQL server → Order service: ListItems(918273)
- GraphQL server → Label service: GetLabelByOrder(918273)
- Note over GraphQL server and Label service: items and label are siblings: they may resolve in parallel
- GraphQL server → Warehouse app (reply): 200 {data: {parcel: {order: {…}}}}
The response has two top-level keys, data and errors. If the Label service is down, the label resolver fails, order.label comes back null, and an entry in errors names the path ["parcel", "order", "label"]. The status and items still arrive: partial success, usually with HTTP 200. Had label been declared non-null (Label!), the null would travel up to the nearest nullable ancestor. Order is non-null too (Parcel.order is Order!), so that is parcel: data.parcel becomes null and the whole pack screen is blank. Nullability is a decision about blast radius. Where it is enabled, introspection lets a client ask the server for its schema, which is what powers GraphiQL and code generators; many public servers turn it off in production.
| Code | Outcome | Kind | What happens | Reacts |
|---|---|---|---|---|
| 503 | REST partner | error | The gateway returns 503 with a Retry-After header. The partner retries the POST with the same Idempotency-Key. | 4API gateway |
| 14 | gRPC caller (grpc-status) | error | UNAVAILABLE, on an HTTP 200 response. The Fulfilment service's retry policy may retry it; the deadline caps the total wait. | 3Fulfilment service |
| 200 | GraphQL app (errors[]) | challenge | The screen gets status and items, label is null, and one error names the label path. The app draws the screen with a retry button on the label panel. | 2Warehouse app |
In practice.
Where each style's cost or saving actually shows up, in numbers.
Opening the pack screen on warehouse Wi-Fi
Scenario 1 of 3: As described.
Timeline as a list
Opening the pack screen on warehouse Wi-Fi: 4 lanes, from 0 ms to 320 ms.
- 0–120 ms · Round trip 1 · GET /v1/parcels/P-77120
- 120–240 ms · Round trip 2a · GET /v1/orders/918273/items
- 120–240 ms · Round trip 2b · GET /v1/orders/918273/label
- 200 ms · all lanes · target 200 ms (ours) (deadline)
- 240 ms · Pack screen · drawn at 240 ms (error, violation)
The pack screen, in round trips
- Handheld ↔ gateway round trip
- 120 msOur assumption for busy warehouse Wi-Fi.
- Dependent REST waves
- 2Parcel lookup first; items and label in parallel after it.
- Service call inside the datacenter
- 0.5 msOrder of magnitude (Dean, LADIS 2009).
- Parcels a packer opens per shift
- 600Our assumption.
- REST, resource by resourcewaves × rtt240 msfrom Dependent REST waves and Handheld ↔ gateway round trip
- GraphQL, one queryrtt + 2 dependent server calls × dc≈ 121 msfrom Handheld ↔ gateway round trip and Service call inside the datacenter
- Waiting saved per packer per shiftscans × (240 − 121) ms≈ 71 sfrom Parcels a packer opens per shift, REST, resource by resource and GraphQL, one query
- The win is fewer round trips on a slow link, not fewer bytes.
- Inside the datacenter the same saving is about 0.5 ms per wave, which rarely matters.
- A REST screen endpoint gets the same 121 ms; GraphQL avoids writing one per screen.
Same request, two encodings
- CreateLabelRequest as JSON
- 54 bytes
- As a gRPC message
- 20 bytes15-byte protobuf + 5-byte frame header.
- Internal CreateLabel calls
- 5,000/sOur assumption for peak packing.
- Request bodies as JSON5,000 × 54 B270 KB/sfrom Internal CreateLabel calls and CreateLabelRequest as JSON
- Request bodies as gRPC5,000 × 20 B100 KB/sfrom Internal CreateLabel calls and As a gRPC message
- Both are trivial next to a 10 Gb/s network link; bytes alone do not justify gRPC here.
- The real savings are CPU for parsing text at high call rates and on large messages, plus a contract the compiler checks.
- Byte-by-byte encoding of the 54 and 20 bytes: see rpc-basics.
Load on the Label service as the pick list grows
- Naive
- Batched
Data
| Orders on the page | Naive (calls/s) | Batched (calls/s) |
|---|---|---|
| 1 | 60 | 60 |
| 10 | 330 | 60 |
| 25 | 780 | 60 |
| 32 | 990 | no value |
| 50 | 1,530 | 60 |
| 100 | 3,030 | 60 |
- Assumed capacity: Backend calls per second (calls/s) = 1,000
- At 50: 1,530 calls/s for 50-order pages
What N+1 costs the Label service
- Orders per pick-list page
- 50
- Pick-list queries across the warehouse
- 30/sOur assumption.
- Naive resolversrate × (1 + page)1,530 calls/sfrom Pick-list queries across the warehouse and Orders per pick-list page
- Batched per requestrate × 260 calls/sfrom Pick-list queries across the warehouse
- GraphQL moves the N+1 problem from the client to the server. Batch at the resolver, per request, so one user's cache never leaks into another's.
Real APIs that mix styles
| Who | What they do |
|---|---|
| GitHub | Public REST API and a public GraphQL API side by side; the GraphQL one is metered in points computed from each query and caps the nodes a query may touch. |
| Google Cloud APIs | Defined once in protobuf and served both as gRPC and as REST/JSON, with google.api.http annotations mapping methods to URLs (AIP-127). |
| Kubernetes API server | A REST API over JSON that also speaks protobuf (application/vnd.kubernetes.protobuf) for built-in types, to cut encoding cost for its own components. |
| gRPC-Web | Browsers cannot drive HTTP/2 frames and trailers directly, so they call gRPC services through a proxy, Envoy by default. |
Trade-offs.
No style wins everywhere. Choose per audience, then pay for the weakness you picked.
The three styles side by side
| Concern | REST | gRPC | GraphQL |
|---|---|---|---|
| Contract | Optional (OpenAPI) | Required (.proto), code generated | Required (SDL schema), introspectable |
| Payload | JSON text | Protobuf binary | JSON text |
| HTTP caching | Native for GET on stable URLs | None: every call is a POST | Hard: one POST endpoint; persisted queries over GET help |
| Browsers | Native | Needs gRPC-Web and a proxy | Native |
| Streaming | Add-ons (server-sent events, WebSocket) | Built in: four call kinds | Subscriptions, transport left to the server |
| Fetch shape | Fixed per resource: over- or under-fetching | Fixed per message | Caller picks the fields |
| Errors | HTTP status codes | grpc-status in trailers, 17 codes | errors array, partial data, usually HTTP 200 |
| Evolution | New version in URL or header | Add fields with new numbers; reserve removed ones | Add fields; mark old ones @deprecated |
| Debugging | curl and browser dev tools | grpcurl, needs the schema or reflection | GraphiQL, from introspection |
- Pro:Every language and tool already speaks it
- Pro:GETs cache at the CDN and in partner proxies
- Pro:Method semantics tell proxies what is safe to retry
- Con:Chatty for screens that join several resources
- Con:Needs versioning discipline (/v1, /v2)
Rate limits must count query cost, not requests; Shared caching needs persisted queries
Partners must run codegen; Browser-based partners need a gRPC-Web proxy
- Pro:Compiler-checked contract in every language
- Pro:Deadlines, cancellation and status codes built in
- Pro:Many concurrent calls on one HTTP/2 connection
- Pro:Streaming when a call needs it
- Con:Binary on the wire, so inspecting it needs tools and the schema
- Con:Long-lived connections need per-request (L7) or client-side balancing
No enforced schema between teams; More CPU per message to parse text
- Pro:One round trip per screen
- Pro:App teams change which existing fields a screen fetches without a new endpoint (with persisted queries, only a query registration)
- Pro:Typed schema for client codegen
- Con:N+1 unless resolvers batch
- Con:Needs query-cost limits and persisted queries for safety and caching
One endpoint per screen, changed with every screen
Where each style bites
| Failure | Impact | Detection | Mitigation | Meanwhile |
|---|---|---|---|---|
| A deeply nested or very wide query5GraphQL server | One request fans out into thousands of backend calls | Resolver call count and latency per operation | Depth limits, per-query cost analysis, persisted queries only for first-party apps | Rejected query gets an error; other clients unaffected |
| A proto field renumbered or its number reused6Label service | Old clients silently decode bytes into the wrong field | Breaking-change check on the .proto in CI | Never reuse numbers; mark removed ones reserved | None if caught in CI; corrupted data if not |
| gRPC traffic behind a connection-level (L4) balancer4API gateway | Each client's one HTTP/2 connection pins all its calls to one backend | Uneven CPU across Label service replicas | Per-request L7 balancing, or client-side balancing over a resolved address list | Hot replicas slow; others idle |
| A proxy or client retries a timed-out POST /labels4API gateway | Two labels bought, two carrier fees | Duplicate labels per order in reconciliation | Idempotency-Key on every POST (see the idempotency topic) | Retries are safe once keys are in place |