Abstractions and RPCREST, gRPC and GraphQL

100%

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.

Intermediate18 minUpdated 30 Sept 2026

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

Three styles in front of one Label service. The numbered component cards that follow describe each part.
Three styles in front of one Label serviceComponents: 1. Partner shops (Third-party stores calling over the public internet with curl or any HTTP library.), 2. Warehouse app (The packers' handheld app. Many small screens, on busy warehouse Wi-Fi.), 3. Fulfilment service (Internal caller that buys a label for every packed parcel. High volume, same datacenter.), 4. API gateway (Terminates TLS, authenticates, routes REST and GraphQL traffic, and transcodes REST to gRPC.), 5. GraphQL server (Validates each query against one schema its resolvers call the services below.), 6. Label service (Owns labels and buys them from the carrier. Speaks gRPC.), 7. Order service (Owns orders, line items and parcel barcodes. Speaks gRPC.).

Inside the datacenter

Public edge

REST + JSON

GraphQL query

query

REST → gRPC transcoding

gRPC

gRPC

gRPC

4API gateway
TLS
auth
routing

5GraphQL server
one schema
POST /graphql

3Fulfilment service

6Label service
CreateLabel
GetLabel

7Order service
GetOrder
ListItems

1Partner shops

2Warehouse app

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.

Was this section helpful?

How it works.

The same label, followed down each stack to what is actually sent.

1

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

MethodLabel-service useSafeIdempotentCacheable
GET/v1/labels/{id}YesYesYes
PUT/v1/labels/{id}/hold {"on":true}NoYesNo
DELETE/v1/labels/{id} (void the label)NoYesNo
POST/v1/orders/{id}/labels (buy one)NoNoRarely: needs explicit freshness and a matching Content-Location
PATCH/v1/labels/{id} (change the note)NoNo, not by definitionRarely: 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).

2

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

One CreateLabel call on the wire, as an ordered list of steps:
One CreateLabel call on the wire7 steps between Fulfilment service, Label service. The steps are listed as text after the diagram.Label serviceFulfilment serviceLabel service: deadline 740 ms from now; decode, run the handlerHTTP :status is 200 even when the call fails; the outcome is grpc-status (5 = NOT_FOUND, 14 = UNAVAILABLE)HEADERS POST /labels.v1.Labels/CreateLabel · grpc-timeout 740m1DATA 00 | 00 00 00 0f | 15-byte CreateLabelRequest2HEADERS :status 200 · content-type application/grpc3DATA 00 | 00 00 00 19 | 25-byte Label4HEADERS (trailers) grpc-status 05
  1. Fulfilment service → Label service: HEADERS POST /labels.v1.Labels/CreateLabel · grpc-timeout 740m
  2. Fulfilment service → Label service: DATA 00 | 00 00 00 0f | 15-byte CreateLabelRequest
  3. Note over Fulfilment service and Label service: Label service: deadline 740 ms from now; decode, run the handler
  4. Label service → Fulfilment service (reply): HEADERS :status 200 · content-type application/grpc
  5. Label service → Fulfilment service (reply): DATA 00 | 00 00 00 19 | 25-byte Label
  6. Label service → Fulfilment service (reply): HEADERS (trailers) grpc-status 0
  7. 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

KindShapeLabel-service example
Unary1 request → 1 responseCreateLabel for one parcel.
Server streaming1 request → stream of responsesWatchLabel(L-5521) streams carrier scan events until delivery.
Client streamingstream of requests → 1 responseUploadManifest streams 400 parcels at the end of a shift, then gets one summary back.
Bidirectionaltwo independent streamsA packing-station session: scans go up, label PDFs come down as each is ready.
3

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

How the server resolves the pack-screen query, as an ordered list of steps:
How the server resolves the pack-screen query8 steps between Warehouse app, GraphQL server, Order service, Label service. The steps are listed as text after the diagram.Label serviceOrder serviceGraphQL serverWarehouse appparcel → order → status, items, label: parse, validate, resolve field by fielditems and label are siblings: they may resolve in parallelPOST /graphql PackScreen1GetParcel(P-77120)2order 9182733ListItems(918273)4GetLabelByOrder(918273)5200 {data: {parcel: {order: {…}}}}6
  1. Warehouse app → GraphQL server: POST /graphql PackScreen
  2. Note over Warehouse app and GraphQL server: parcel → order → status, items, label: parse, validate, resolve field by field
  3. GraphQL server → Order service: GetParcel(P-77120)
  4. Order service → GraphQL server (reply): order 918273
  5. GraphQL server → Order service: ListItems(918273)
  6. GraphQL server → Label service: GetLabelByOrder(918273)
  7. Note over GraphQL server and Label service: items and label are siblings: they may resolve in parallel
  8. 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.

The Label service is down. What each caller sees.One failure, three error models. Clients must be written for the one their style uses.
CodeOutcomeKindWhat happensReacts
503REST partnererrorThe gateway returns 503 with a Retry-After header. The partner retries the POST with the same Idempotency-Key.4API gateway
14gRPC caller (grpc-status)errorUNAVAILABLE, on an HTTP 200 response. The Fulfilment service's retry policy may retry it; the deadline caps the total wait.3Fulfilment service
200GraphQL app (errors[])challengeThe 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
Was this section helpful?

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.

  1. 0–120 ms · Round trip 1 · GET /v1/parcels/P-77120
  2. 120–240 ms · Round trip 2a · GET /v1/orders/918273/items
  3. 120–240 ms · Round trip 2b · GET /v1/orders/918273/label
  4. 200 ms · all lanes · target 200 ms (ours) (deadline)
  5. 240 ms · Pack screen · drawn at 240 ms (error, violation)
With REST the app needs the order ID before it can ask for items and label, so it pays two round trips. One GraphQL query, or a REST endpoint built for the screen, pays one. Round trip of 120 ms is our assumption for busy Wi-Fi; handler time is left out because it is the same in all three.

The pack screen, in round trips

Assumptions
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.
Working
  1. REST, resource by resourcewaves × rtt240 msfrom Dependent REST waves and Handheld ↔ gateway round trip
  2. GraphQL, one queryrtt + 2 dependent server calls × dc≈ 121 msfrom Handheld ↔ gateway round trip and Service call inside the datacenter
  3. 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
What it means
  • 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

Assumptions
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.
Working
  1. Request bodies as JSON5,000 × 54 B270 KB/sfrom Internal CreateLabel calls and CreateLabelRequest as JSON
  2. Request bodies as gRPC5,000 × 20 B100 KB/sfrom Internal CreateLabel calls and As a gRPC message
What it means
  • 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
Load on the Label service as the pick list growsAt 30 pick-list queries a second, naive resolvers send 30 × (1 + N) calls a second to the Label service, crossing an assumed 1,000 calls/s capacity at about 33 orders per page; a per-request batch loader stays at 60 calls/s whatever the page size.05001k1.5k2k2.5k3k020406080100Assumed capacity1,530 calls/s for 50-order pagesNaiveBatchedBackend calls per second (calls/s)Orders on the pageLoad on the Label service as the pick list growsAt 30 pick-list queries a second, naive resolvers send 30 × (1 + N) calls a second to the Label service, crossing an assumed 1,000 calls/s capacity at about 33 orders per page; a per-request batch loader stays at 60 calls/s whatever the page size.05001k1.5k2k2.5k3k020406080100Assumed capacity1,530 calls/s for 50-orderpagesNaiveBatchedBackend calls per second (calls/s)Orders on the page
Query: orders(first: N) { id label { trackingNo } } at 30 queries/s (our assumption). Naive: 1 ListOrders + N GetLabel per query. Batched (DataLoader): 1 ListOrders + 1 BatchGetLabels. The 1,000 calls/s capacity is also an assumption: 30 × (1 + 32) = 990 fits, 30 × (1 + 33) = 1,020 does not.
Data
Orders on the pageNaive (calls/s)Batched (calls/s)
16060
1033060
2578060
32990no value
501,53060
1003,03060
  • 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

Assumptions
Orders per pick-list page
50
Pick-list queries across the warehouse
30/sOur assumption.
Working
  1. Naive resolversrate × (1 + page)1,530 calls/sfrom Pick-list queries across the warehouse and Orders per pick-list page
  2. Batched per requestrate × 260 calls/sfrom Pick-list queries across the warehouse
What it means
  • 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

WhoWhat they do
GitHubPublic 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 APIsDefined 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 serverA 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-WebBrowsers cannot drive HTTP/2 frames and trailers directly, so they call gRPC services through a proxy, Envoy by default.
Was this section helpful?

Trade-offs.

No style wins everywhere. Choose per audience, then pay for the weakness you picked.

The three styles side by side

ConcernRESTgRPCGraphQL
ContractOptional (OpenAPI)Required (.proto), code generatedRequired (SDL schema), introspectable
PayloadJSON textProtobuf binaryJSON text
HTTP cachingNative for GET on stable URLsNone: every call is a POSTHard: one POST endpoint; persisted queries over GET help
BrowsersNativeNeeds gRPC-Web and a proxyNative
StreamingAdd-ons (server-sent events, WebSocket)Built in: four call kindsSubscriptions, transport left to the server
Fetch shapeFixed per resource: over- or under-fetchingFixed per messageCaller picks the fields
ErrorsHTTP status codesgrpc-status in trailers, 17 codeserrors array, partial data, usually HTTP 200
EvolutionNew version in URL or headerAdd fields with new numbers; reserve removed onesAdd fields; mark old ones @deprecated
Debuggingcurl and browser dev toolsgrpcurl, needs the schema or reflectionGraphiQL, from introspection
01
The public partner API
Chosen:REST + JSON
  • 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
Downside we accept:
  • Con:Chatty for screens that join several resources
  • Con:Needs versioning discipline (/v1, /v2)
Ruled out:GraphQL

Rate limits must count query cost, not requests; Shared caching needs persisted queries

Ruled out:gRPC

Partners must run codegen; Browser-based partners need a gRPC-Web proxy

02
Service to service
Chosen:gRPC
  • 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
Downside we accept:
  • 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
Ruled out:REST + JSON

No enforced schema between teams; More CPU per message to parse text

03
A client app with many screens
Chosen:GraphQL
  • 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
Downside we accept:
  • Con:N+1 unless resolvers batch
  • Con:Needs query-cost limits and persisted queries for safety and caching
Ruled out:REST screen endpoints (BFF)

One endpoint per screen, changed with every screen

Where each style bites

FailureImpactDetectionMitigationMeanwhile
A deeply nested or very wide query5GraphQL serverOne request fans out into thousands of backend callsResolver call count and latency per operationDepth limits, per-query cost analysis, persisted queries only for first-party appsRejected query gets an error; other clients unaffected
A proto field renumbered or its number reused6Label serviceOld clients silently decode bytes into the wrong fieldBreaking-change check on the .proto in CINever reuse numbers; mark removed ones reservedNone if caught in CI; corrupted data if not
gRPC traffic behind a connection-level (L4) balancer4API gatewayEach client's one HTTP/2 connection pins all its calls to one backendUneven CPU across Label service replicasPer-request L7 balancing, or client-side balancing over a resolved address listHot replicas slow; others idle
A proxy or client retries a timed-out POST /labels4API gatewayTwo labels bought, two carrier feesDuplicate labels per order in reconciliationIdempotency-Key on every POST (see the idempotency topic)Retries are safe once keys are in place
Was this section helpful?
Next in Core
Idempotency and retries
Read next