GraphQL and gRPC
Two alternatives to REST: GraphQL's flexible queries with one endpoint and a schema, and gRPC's fast, typed calls with Protocol Buffers — and when each fits.
Why alternatives to REST exist
A REST API exposes resources at URLs and lets HTTP methods act on them. That model is simple, cacheable and understood by every tool, which is why it dominates public APIs. It also has costs that grow with the size of a system:
- Over-fetching: an endpoint returns a fixed representation, so a client that needs only a customer's name still downloads all twenty fields, including some it should perhaps never see. On a phone over a weak connection, that waste adds up.
-
Under-fetching, the opposite: one endpoint does
not give enough, so the client makes more calls. A portal screen
that shows a customer, their meters and the latest reading per
meter may need
1 + 1 + nrequests, each a full network round trip. -
Loose contracts. A JSON response has no built-in
types; whether
lastReadingis a number or a string, or may be missing, depends on documentation (see Designing and Documenting APIs). - Performance between services. Inside a data center, hundreds of services call each other thousands of times per second. Text JSON over separate HTTP/1.1 requests costs CPU time and latency that a binary, multiplexed protocol avoids.
GraphQL attacks the first two problems, gRPC the last two. Neither replaces REST everywhere; each trades simplicity for something specific.
GraphQL
GraphQL is a query language for APIs and a runtime that answers those queries. The server publishes a typed schema of everything that can be asked; the client sends a query describing the exact shape of the data it wants, and the response has that same shape. GraphQL was developed at Facebook and published in 2015. The specification is now maintained by the GraphQL Foundation under the Linux Foundation; its latest edition is the September 2025 edition, the first new one since October 2021 (it adds, among other things, OneOf input objects and schema coordinates).
Everything goes through one endpoint, usually
POST /graphql, with the query in a JSON body:
POST /graphql HTTP/1.1
Host: api.example.com
Content-Type: application/json
Authorization: Bearer eyJhbGciOi…
{"query": "query CustomerMeters($id: ID!) { customer(id: $id) { name meters { kind lastReading } } }",
"variables": {"id": "7"}}
The schema
The GraphQL schema describes
every type, every field and every argument the API offers, written
in the Schema Definition Language (SDL). ! means "never
null", [Meter!]! is a list of meters, and three special
root types define the entry points: Query (read),
Mutation (change) and Subscription (be
told about changes). An illustrative schema for Kestrel Energy's
customer portal:
"""A customer of Kestrel Energy."""
type Customer {
id: ID!
name: String!
email: String
meters: [Meter!]!
}
type Meter {
id: ID!
kind: String! # "electricity" or "gas"
lastReading: Int
}
type Outage {
area: String!
status: String!
}
type Query {
customer(id: ID!): Customer
outages(area: String): [Outage!]!
}
type Mutation {
submitReading(meterId: ID!, value: Int!): Meter
}
type Subscription {
outageUpdated(area: String!): Outage
}
The schema is the contract, and it is machine-readable: a server can be asked for its own schema (introspection), which is how tools offer autocompletion and generate typed client code. Many teams disable introspection on public production APIs so outsiders do not get a free map of every field.
Queries, mutations and subscriptions
A query reads data. Its shape mirrors the response:
query CustomerMeters {
customer(id: 7) {
name
meters {
kind
lastReading
}
}
}
{
"data": {
"customer": {
"name": "R. Visser",
"meters": [
{ "kind": "electricity", "lastReading": 18342 },
{ "kind": "gas", "lastReading": 5127 }
]
}
}
}
A mutation changes data and then returns fields from the result, so the client gets the new state in the same round trip:
mutation {
submitReading(meterId: 101, value: 18410) {
id
lastReading
}
}
A subscription asks the server to push results
whenever something changes, for example
subscription { outageUpdated(area: "Rotterdam-Noord") { status }
}. The GraphQL spec does not fix a transport for this; in practice
subscriptions run over WebSockets or server-sent events (see
Webhooks and Real-Time APIs).
Errors work differently from REST. A GraphQL response can contain
both data and an errors array (for example
when one field failed and the rest succeeded), and many servers
answer 200 OK even when the query had errors. When
troubleshooting, always read the body, not only the status code.
How a GraphQL server works: resolvers
A GraphQL server does not store data itself. For every field in the schema there is a resolver: a function that produces that field's value, given the parent object and the field's arguments. The server walks the query tree, calls the resolver for each requested field, and builds a response with exactly those fields. Fields that were not asked for are never resolved, so their data is never fetched or sent. That is how GraphQL removes over-fetching.
The example below imitates this without a GraphQL library: a tiny parser turns the query into a tree, and resolvers grouped by type fill it in. Like a real server, it refuses fields that are not in the schema — here an IBAN that the database holds but the API does not expose.
import json
import re
# Pretend database: the server knows far more than most clients need
CUSTOMERS = {
7: {"id": 7, "name": "R. Visser", "email": "r.visser@example.com",
"iban": "NL00 BANK 0000 0000 00", "meter_ids": [101, 102]},
}
METERS = {
101: {"id": 101, "kind": "electricity", "last_reading": 18342, "installed": "2019-04-02"},
102: {"id": 102, "kind": "gas", "last_reading": 5127, "installed": "2019-04-02"},
}
# Resolvers: one small function per field, grouped by type
RESOLVERS = {
"Query": {"customer": lambda parent, args: CUSTOMERS.get(args["id"])},
"Customer": {
"id": lambda c, args: c["id"],
"name": lambda c, args: c["name"],
"email": lambda c, args: c["email"],
"meters": lambda c, args: [METERS[m] for m in c["meter_ids"]],
},
"Meter": {
"kind": lambda m, args: m["kind"],
"lastReading": lambda m, args: m["last_reading"],
},
}
CHILD_TYPE = {("Query", "customer"): "Customer", ("Customer", "meters"): "Meter"}
def parse(tokens):
"""Turn '{ a(x: 1) { b } }' into {field: (args, sub_selection)}."""
selection = {}
assert tokens.pop(0) == "{"
while tokens[0] != "}":
name, args = tokens.pop(0), {}
if tokens[0] == "(":
tokens.pop(0)
while tokens[0] != ")":
key, _, value = tokens.pop(0), tokens.pop(0), tokens.pop(0)
args[key] = int(value)
tokens.pop(0)
sub = parse(tokens) if tokens[0] == "{" else None
selection[name] = (args, sub)
tokens.pop(0)
return selection
def execute(type_name, parent, selection):
result = {}
for field, (args, sub) in selection.items():
if field not in RESOLVERS[type_name]:
raise ValueError(f'Cannot query field "{field}" on type "{type_name}"')
value = RESOLVERS[type_name][field](parent, args)
if sub is not None:
child = CHILD_TYPE[(type_name, field)]
if isinstance(value, list):
value = [execute(child, item, sub) for item in value]
elif value is not None:
value = execute(child, value, sub)
result[field] = value
return result
def run(query):
tokens = re.findall(r"[{}():]|\w+", query)
try:
return {"data": execute("Query", None, parse(tokens))}
except ValueError as error:
return {"errors": [{"message": str(error)}]}
print(json.dumps(run("{ customer(id: 7) { name } }")))
print(json.dumps(run("{ customer(id: 7) { name meters { kind lastReading } } }")))
print(json.dumps(run("{ customer(id: 7) { iban } }")))
{"data": {"customer": {"name": "R. Visser"}}}
{"data": {"customer": {"name": "R. Visser", "meters": [{"kind": "electricity", "lastReading": 18342}, {"kind": "gas", "lastReading": 5127}]}}}
{"errors": [{"message": "Cannot query field \"iban\" on type \"Customer\""}]}
The first query asks only for a name and gets only a name; the second follows the relationship to meters in the same request. A real server adds validation of argument types, variables, fragments, aliases and much more, but the core idea is the same walk over the query tree.
The new problems GraphQL brings
GraphQL hands the client a lot of power, and the server has to defend itself against how that power is used:
- Expensive queries. One small request can ask for customers, their meters, every reading of every meter, and the customers of each meter's area again — nesting that multiplies into millions of rows. Servers set a maximum query depth, a cost limit (points per field, refused above a budget), pagination on every list, and timeouts. Rate limiting by request count alone is not enough when one request can be a thousand times heavier than another.
- The N+1 problem. Naive resolvers run one database query for the customer list and then one per customer for their meters. Servers batch these lookups (the common "DataLoader" pattern collects all IDs requested in one step and fetches them in a single query).
-
Caching. REST gets
HTTP caching almost for free: each
resource has its own URL. GraphQL sends different
POSTbodies to one URL, which shared caches and CDNs do not cache. Common answers are persisted queries (the client sends the ID or hash of a query registered in advance, often withGET, so it can be cached and arbitrary queries can be refused) and caching inside the client library. - Authorization per field and per object. With one endpoint there is no URL to protect; every resolver that returns sensitive data must check whether this caller may see it. Forgetting one is the GraphQL form of the problems described on Web and API Security.
-
Monitoring. Every request is
POST /graphql 200in the access log. Log the operation name and errors, or troubleshooting becomes guesswork.
gRPC
gRPC is a remote procedure call framework: the client calls what looks like a local function, and gRPC carries the call to a server and the result back. It was developed at Google, released as open source in 2015 and is now a Cloud Native Computing Foundation (CNCF) project. Three choices define it:
- Protocol Buffers by default (proto3 is the recommended version) to describe services and messages and to encode them in a compact binary format (see JSON and Other Data Formats).
- HTTP/2 as transport: many calls share one connection at the same time (multiplexing), headers are compressed, and long-lived streams are built in.
-
Code generation: the
protoccompiler and its plugins turn the service definition into client stubs and server interfaces for many languages, so a Go service and a Python client agree on types without anyone writing JSON parsing code.
A service definition
An illustrative .proto file for a meter service at
Kestrel Energy. The numbers after each field are field tags: they,
not the names, identify fields on the wire, so they must never be
reused for something else.
syntax = "proto3";
package kestrel.meters.v1;
service MeterService {
// Unary: one request, one response
rpc GetMeter(GetMeterRequest) returns (Meter);
// Server streaming: one request, a stream of responses
rpc WatchReadings(WatchReadingsRequest) returns (stream Reading);
// Client streaming: a stream of requests, one response
rpc UploadReadings(stream Reading) returns (UploadSummary);
// Bidirectional streaming: both sides send whenever they like
rpc Control(stream Command) returns (stream Status);
}
message GetMeterRequest { string meter_id = 1; }
message WatchReadingsRequest { string meter_id = 1; }
message Meter {
string meter_id = 1;
string kind = 2;
int64 last_reading = 3;
}
message Reading {
string meter_id = 1;
int64 value = 2;
string read_at = 3; // ISO 8601 timestamp
}
message UploadSummary { int32 accepted = 1; int32 rejected = 2; }
message Command { string meter_id = 1; string action = 2; }
message Status { string meter_id = 1; string state = 2; }
Unary and streaming calls
A unary call is the familiar pattern: one request message, one response message. A streaming RPC keeps the call open and sends a sequence of messages in one or both directions over a single HTTP/2 stream. gRPC has three kinds: server streaming (subscribe to readings of one meter), client streaming (a field device uploads a day of readings, then gets one summary) and bidirectional streaming (a control session in which commands and status updates flow both ways independently). Streams are what REST over HTTP/1.1 cannot express without WebSockets or server-sent events.
gRPC in practice
On the wire, a gRPC call is an HTTP/2 POST to a path
built from the service and method name, with content type
application/grpc (or
application/grpc+proto). Each message is sent with a
small length prefix. The outcome of the call is not the HTTP status,
which is normally 200, but a
grpc-status value sent in the HTTP/2
trailers after the body:
:method: POST
:path: /kestrel.meters.v1.MeterService/GetMeter
content-type: application/grpc
te: trailers
grpc-timeout: 2S
← request: length-prefixed binary message
:status: 200
content-type: application/grpc
← response: length-prefixed binary message
grpc-status: 0 ← trailer: 0 = OK
Useful status codes to recognize: 0 OK,
4 DEADLINE_EXCEEDED, 5 NOT_FOUND,
7 PERMISSION_DENIED, 14 UNAVAILABLE
(often: the server is down or unreachable, worth retrying) and
16 UNAUTHENTICATED. The
grpc-timeout header carries the client's
deadline; good gRPC clients always set one, so a
slow service fails fast instead of tying up every caller.
Where gRPC fits, and where it does not:
- Inside data centers and between microservices it is a strong default: small binary messages, one long-lived connection, strict types, streaming, and generated code in every language the teams use. Kubernetes, for example, uses it between the kubelet and the container runtime.
- Browsers cannot call gRPC directly. JavaScript in a page has no access to HTTP/2 framing or trailers. The usual workaround is gRPC-Web, a variant that a proxy (such as Envoy) translates into real gRPC, or a gateway that exposes a REST/JSON interface in front of the gRPC services.
-
Debugging takes tools. Binary messages are not
readable in a network capture or with plain
curl. Tools such
as
grpcurlread the.protofile (or the server's reflection service) and translate calls to and from JSON. - Infrastructure must speak HTTP/2 end to end. Load balancers that only balance TCP connections send all calls of a long-lived connection to one backend; gRPC-aware (layer 7) load balancing spreads individual calls.
REST, GraphQL and gRPC compared
| Aspect | REST | GraphQL | gRPC |
|---|---|---|---|
| Model | Resources at URLs, HTTP methods | One endpoint, client-shaped queries | Remote functions on a service |
| Contract | Optional (OpenAPI) | Required schema (SDL) | Required .proto file |
| Data format | Usually JSON | JSON | Protocol Buffers (binary) |
| Transport | Any HTTP version | Usually HTTP POST |
HTTP/2 |
| HTTP caching | Natural | Needs persisted queries or client caching | Not applicable |
| Streaming | Via SSE or WebSockets | Subscriptions | Built in, both directions |
| Browser use | Direct | Direct | Via gRPC-Web and a proxy |
| Debug with curl | Easy | Possible (JSON body) | Needs special tools |
| Fits best | Public APIs, simple CRUD, broad tool support | Front ends with varied data needs, many related objects | Internal service-to-service calls, streaming, low latency |
Large systems often use all three: gRPC between internal services, a GraphQL layer for the web and mobile front ends, and a REST API with an OpenAPI description for partners and the public. Choose per audience, not per fashion. Choosing between synchronous calls and messages between services is covered on Service Communication.