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 + n requests, each a full network round trip.
  • Loose contracts. A JSON response has no built-in types; whether lastReading is 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.

REST compared with GraphQL for the same screen. With REST the client makes three round trips to the REST API: GET /customers/7 returns the whole customer record, GET /customers/7/meters returns the list of meters, and GET /meters/101/readings returns readings, repeated per meter. With GraphQL the client sends one POST to /graphql naming the customer's name and each meter's kind and last reading, and gets back only those fields in one response. REST: one request per resource Client REST API GET /customers/7 whole customer record, all fields GET /customers/7/meters list of meters GET /meters/101/readings (per meter) GraphQL: one request, exactly the fields asked for Client GraphQL API POST /graphql name, meters { kind lastReading } only the requested fields, in one response
The same portal screen fetched two ways. REST needs a round trip per resource and returns full records; GraphQL resolves the whole tree on the server and returns one tailored response.

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 POST bodies 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 with GET, 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 200 in 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 protoc compiler 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 grpcurl read the .proto file (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.