Codetail

Article 8 of 15

APIs: REST, GraphQL, gRPC

Three paradigms, three different trade-offs.

22 min read

REST: Constraints, Not a Standard

REST is not a protocol or a specification. It is an architectural style described by Roy Fielding in his 2000 dissertation. A REST API is one that satisfies six constraints: client-server separation, statelessness, cacheability, uniform interface, layered system, and optional code on demand. Most APIs called "RESTful" satisfy only the first three and ignore the rest, particularly HATEOAS (Hypermedia As The Engine Of Application State).

In practice, REST means resources identified by URLs, manipulated via HTTP verbs, with representations in JSON (or XML). The protocol does the heavy lifting: caching via ETags and Cache-Control, content negotiation via Accept headers, authentication via Authorization headers. The tradeoff is that the client drives everything. The server cannot push updates; the client must poll.

HTTP verbs and their contracts

MethodSafeIdempotentBodyUse
GETyesyesnoFetch a resource or collection
POSTnonoyesCreate a new resource
PUTnoyesyesReplace a resource entirely
PATCHnonoyesPartially update a resource
DELETEnoyesnoRemove a resource

Safe means the operation has no side effects.Idempotent means calling it N times produces the same result as calling it once. Safe implies idempotent. POST is neither. PUT and DELETE are idempotent but not safe.

Status codes worth knowing

2xxSuccess
200OKGET/PUT/PATCH succeeded
201CreatedPOST created a resource; include Location header
204No ContentDELETE succeeded; no body to return
4xxClient error
400Bad RequestMalformed JSON, missing required field
401UnauthorizedNo or invalid credentials
403ForbiddenAuthenticated but not allowed
404Not FoundResource does not exist
409ConflictState conflict (duplicate, optimistic lock)
422UnprocessableSyntactically valid but semantically wrong
429Too Many RequestsRate limit hit; include Retry-After header
5xxServer error
500Internal Server ErrorUnhandled exception; do not leak stack traces
502Bad GatewayUpstream service returned an invalid response
503Service UnavailableOverloaded or in maintenance; include Retry-After

URL design principles

Good vs bad URL patterns

Avoid

/getUser?id=42
/api/createPost
/users/getFollowers
/deleteComment/5
/api/v1/user_list

Prefer

GET /users/42
POST /posts
GET /users/42/followers
DELETE /comments/5
GET /users

Nouns, not verbs. Plural collection names. Nesting max two levels deep. Actions via HTTP verbs.

API versioning strategies

StrategyExampleProCon
URL segment/api/v2/usersExplicit, cacheable, easy to routeDuplicates URLs across versions
Query param/api/users?version=2Backward-compatible defaultOften forgotten, less cache-friendly
HeaderAccept: application/vnd.api+json;version=2Clean URL, spec-compliantInvisible in browser, harder to test
Content negotiationAccept: application/vnd.company.user.v2+jsonFully REST-compliant (HATEOAS-friendly)Complex to implement and document

URL segment versioning (/v2/) is the industry default and for good reason. It is explicit, trivially cacheable by CDNs and proxies, easy to route at the load balancer, and visible in logs. Start here unless you have a specific reason not to.

The over-fetching problem

REST couples resource shape to endpoint. A GET /users/42 returns the entire user object whether the client needs one field or all of them. A mobile app showing a user avatar and name burns bandwidth downloading the full profile. Worse, a page that needs user, post, and comment data must make three sequential requests before it can render.

Partial solutions exist: sparse fieldsets (?fields=id,name), compound documents (embed related resources in the response), and purpose-built endpoints (/api/post-card-data). All of these are workarounds. They either push the problem to the client (sparse fieldsets), over-engineer the API (compound documents), or erode the uniform interface (bespoke endpoints). GraphQL emerged specifically to solve this.

GraphQL: Query What You Need

GraphQL was developed at Facebook in 2012 and open-sourced in 2015. The motivation was direct: the News Feed required data from dozens of different backend services, and REST endpoints could not express the variable, nested, client-driven shape of that data efficiently. GraphQL moves the query language to the client side.

At the center is the schema. The server defines a type system: every object type, every field, every relationship between types. Clients write queries in the GraphQL query language that traverse this type graph. The server validates queries against the schema at parse time and returns exactly the fields requested, nothing more, nothing less.

API Paradigm Comparator, fetch a blog post card
Round trips3
Payload~8 KB

3 HTTP requests required

GET /api/v1/posts/42

  -> also needed:

GET /api/v1/users/1
GET /api/v1/posts/42/comments?count=true

Three round trips before the
page can render.

Response, 5 fields needed, 10+ returned

{
  "id": 42,
  "title": "Building at Scale",  ✓ needed
  "excerpt": "A deep dive...",   ✓ needed
  "content": "Lorem ipsum...",   ✗ 5 KB, not needed
  "slug": "building-at-scale",   ✗ not needed
  "author_id": 1,                ✗ need name, not id
  "created_at": "2025-01-01T...",✗ not needed
  "updated_at": "2025-01-15T...",✗ not needed
  "view_count": 14200,           ✗ not needed
  "status": "published",         ✗ not needed
  "category_id": 3               ✗ not needed
}
  + separate responses for
    /users/1 and /comments count

Three operation types

QueryReadREST analogy: GET
query { user(id: 42) { name email } }

Fetches data. Safe and idempotent. May be batched and cached.

MutationWriteREST analogy: POST / PUT / DELETE
mutation { createPost(title: "Hello") { id } }

Modifies data. Executed serially (top-to-bottom) when multiple mutations are sent.

SubscriptionStreamingREST analogy: WebSocket
subscription { newComment(postId: 42) { text author { name } } }

Long-lived connection. Server pushes events to the client when data changes.

Schema design: a minimal example

Schema definition language (SDL)

type Query {
  post(id: ID!): Post
  posts(first: Int = 20, after: String): PostConnection
  user(id: ID!): User
}

type Mutation {
  createPost(input: CreatePostInput!): Post!
  deletePost(id: ID!): Boolean!
}

type Post {
  id: ID!
  title: String!
  excerpt: String
  commentCount: Int!
  author: User!          # resolved separately, DataLoader target
  tags: [Tag!]!
  createdAt: DateTime!
}

type User {
  id: ID!
  name: String!
  avatarUrl: String
}

type Tag { name: String! }

input CreatePostInput {
  title: String!
  excerpt: String
}

# Relay-style cursor pagination
type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
}
type PostEdge { node: Post!; cursor: String! }
type PageInfo { hasNextPage: Boolean!; endCursor: String }

The DataLoader pattern

The most common GraphQL performance bug is the N+1 problem. When you resolve a list of 100 posts and each post resolver independently fetches its author, you execute 101 queries. DataLoader solves this by collecting all keys within a single event loop tick and issuing a single batched query.

Without DataLoader

// posts resolver
posts.forEach(post => {
  // N separate queries
  db.query(
    "SELECT * FROM users WHERE id = ?",
    [post.authorId]
  );
});
// 100 posts = 101 queries

With DataLoader

const userLoader = new DataLoader(
  async (ids) => {
    const users = await db.query(
      "SELECT * FROM users WHERE id = ANY(?)",
      [ids]
    );
    return ids.map(id =>
      users.find(u => u.id === id)
    );
  }
);
// 100 posts = 2 queries total

Benefits and costs

-

Caching is harder

REST GET requests are cacheable by URL. GraphQL typically uses POST for all queries, which HTTP caches ignore. You must implement application-level caching (persisted queries, CDN rules keyed on query hash).

-

N+1 on the server

Resolving author for 100 posts fires 100 SELECT queries unless you use DataLoader. DataLoader batches and deduplicates within a single tick: 100 author lookups become 1 SELECT WHERE id IN (...).

+

Schema is a contract

The GraphQL schema is explicit, introspectable, and versioned by default. Clients cannot request fields that do not exist. Tooling (GraphiQL, Apollo Studio) generates documentation automatically.

~

One endpoint, all operations

A single /graphql endpoint replaces dozens of REST endpoints. Routing is by operation name, not URL. This simplifies load balancing but makes per-endpoint rate limiting and monitoring harder.

+

Over-fetching eliminated

Clients specify exactly the fields they need. A mobile app requesting a user card gets name and avatarUrl. A desktop dashboard requesting a full profile gets all 30 fields. One schema, multiple shapes.

-

Arbitrary query depth

Without query depth limits or complexity scoring, a malicious client can craft deeply nested queries (user -> posts -> comments -> author -> posts ...) that exhaust the server. Protect with depth limits and query cost analysis.

gRPC: Protocol Buffers and HTTP/2

gRPC is a remote procedure call framework developed at Google and open-sourced in 2015. It uses Protocol Buffers as its interface definition language and serialization format, and HTTP/2 as its transport. The combination gives it properties that REST and GraphQL cannot match: strongly typed schemas enforced at compile time, binary serialization 3-10x smaller than JSON, multiplexed streams over a single TCP connection, and server push.

The workflow is schema-first and code-generated. You define your service in a .proto file. The gRPC compiler (protoc) generates type-safe client stubs and server interfaces in your language of choice. The generated code handles serialization, deserialization, and transport. You implement the server interface and call the client stub as if it were a local function.

Protocol Buffers: how binary encoding works

Protocol Buffers (protobuf) encode messages as binary sequences of field-number/value pairs. Field names are not transmitted on the wire; only the field number is. This is what makes the format compact and why it is schema-dependent: you cannot decode a protobuf message without the .proto definition.

From .proto definition to binary wire format

Schema (.proto)

message User {
  int64  id         = 1;
  string name       = 2;
  string email      = 3;
  bool   is_active  = 4;
}

// Populated with:
// id: 42, name: "Alice",
// email: "a@ex.com", is_active: true

Wire format (binary, 24 bytes)

08 2A              field 1, varint, 42
12 05 41 6C 69     field 2, "Alice"
   63 65
1A 07 61 40 65     field 3, "a@ex.com"
   78 2E 63 6F
   6D
20 01              field 4, bool true

vs JSON (67 bytes):
{"id":42,"name":"Alice",
"email":"a@ex.com",
"is_active":true}

Field names absent on the wire. Unknown field numbers are silently skipped (forward compatibility). Missing fields use the type default (0, "", false). This is how .proto achieves backward and forward compatibility without versioning.

Scalar types and wire sizes

TypeWire typeBytesNotes
int32 / int64varint1-10Variable-length encoding; small numbers cost fewer bytes
float / doublefixed32 / fixed644 / 8Fixed size regardless of value
stringlength-delimited2 + lenUTF-8 encoded; 2 bytes overhead for length
boolvarint1Encoded as 0 or 1
byteslength-delimited2 + lenArbitrary binary; use for images, encrypted payloads
repeated Tpacked varintvariesArrays. Packed encoding for numeric types
messagelength-delimitednestedEmbedded messages, fully recursive

HTTP/2 and multiplexing

REST over HTTP/1.1 opens one TCP connection per in-flight request (or reuses connections serially). HTTP/2 sends multiple streams over a single connection simultaneously using framing. Each request/response pair is a stream. Streams are independent: a slow response does not block a fast one (head-of-line blocking is eliminated at the HTTP layer).

Multiplexing

Multiple RPC calls share one TCP connection. No per-request connection overhead. No head-of-line blocking between streams.

Header compression

HPACK compresses HTTP headers. Repeated headers (Content-Type, Authorization) are sent once and referenced by index on subsequent frames.

Flow control

Each stream has a flow control window. Fast senders cannot overwhelm slow receivers. Works at both the stream and connection level.

Server push

Server can send frames the client has not requested yet. gRPC uses this for server-streaming and bidirectional RPCs.

Four streaming modes

Unaryclient → server → clientlike: Standard HTTP request/response
rpc GetUser(UserRequest) returns (User);

Most API calls: fetch a resource, run a command

Server streamingclient → server ⟶⟶⟶ clientlike: Server-sent events
rpc WatchLogs(LogQuery) returns (stream LogEntry);

Live feeds, log tailing, export of large datasets

Client streamingclient ⟶⟶⟶ server → clientlike: Chunked upload
rpc UploadChunks(stream Chunk) returns (UploadResult);

File upload, batch ingest, streaming metrics

Bidirectional streamingclient ⟷⟷⟷ serverlike: WebSocket
rpc Chat(stream Message) returns (stream Message);

Real-time chat, multiplayer, interactive sessions

When NOT to use gRPC

!

Public APIs

Browsers cannot call gRPC directly. gRPC-Web requires a proxy (Envoy, grpc-gateway). REST or GraphQL is far more accessible for public developer APIs.

!

Human-readable debugging

Binary frames are not readable with curl or browser DevTools without tooling. REST JSON is immediately inspectable. Protobuf requires protoc or a dedicated tool.

!

Teams new to the pattern

Schema-first development, code generation, and proto file management add upfront complexity. The ROI materializes at scale; it is overhead in a small service.

!

Firewall-restricted environments

Some corporate proxies and API gateways do not support HTTP/2 or pass binary frames correctly. REST over HTTP/1.1 has zero environment friction.

Choosing the Right Paradigm

REST, GraphQL, and gRPC are not competing alternatives where one wins. They are tools with different strengths that often coexist in the same system. The right choice depends on your client type, latency requirements, team experience, and schema complexity. No architecture uses a single paradigm end-to-end at scale.

Decision matrix

SignalRESTGraphQLgRPCNotes
Public API for third-party developersbest fitworkableavoidREST is universally accessible. No toolchain required. GraphQL works but requires clients to learn SDL.
Mobile app with varying data needsavoidbest fitworkableGraphQL eliminates over-fetching. Mobile on slow networks benefits most from exact-fit responses.
Internal microservice-to-microserviceworkableavoidbest fitgRPC binary serialization and HTTP/2 multiplexing reduce latency and CPU at high RPS between services.
Real-time streaming (server push)avoidworkablebest fitgRPC server streaming is built in. GraphQL subscriptions work via WebSocket. REST requires SSE or polling.
Browser clients (no proxy)best fitbest fitavoidBrowsers cannot call gRPC directly. gRPC-Web needs an Envoy proxy. REST and GraphQL work natively.
Team needs discoverable, self-documenting APIworkablebest fitworkableGraphQL is introspectable. GraphiQL and Apollo Studio generate docs from the schema automatically.
Strict latency budget, high throughputworkableworkablebest fitBinary frames, header compression, and multiplexed streams give gRPC a measurable edge at scale.
Strongly typed cross-language clientsworkableworkablebest fitprotoc generates type-safe stubs in 10+ languages from a single .proto. No manual type sync.
Simple CRUD with standard HTTP cachingbest fitavoidworkableREST GET responses are cached by CDNs and browsers out of the box. GraphQL POST requests are not.
Aggregating data from many servicesavoidbest fitworkableGraphQL's federation model (Apollo Federation, GraphQL Mesh) was built for this pattern.

Hybrid architectures

Production systems rarely use one paradigm throughout. The most common hybrid patterns combine paradigms to get the best properties of each at the right layer of the stack.

REST for public + gRPC internally

External clients get REST endpoints. Internal services communicate via gRPC. A thin gateway translates. This is the approach used by Google, Netflix, and Uber.

Public: POST /orders  |  Internal: OrderService.CreateOrder (gRPC)

GraphQL BFF + gRPC backends

A Backend for Frontend layer (GraphQL) aggregates data from multiple gRPC microservices. Clients get the ergonomics of GraphQL. Services get the performance of gRPC.

Browser -> GraphQL BFF -> [UserService, PostService, CommentService] (gRPC)

REST for writes + GraphQL for reads

Mutations go through REST endpoints where HTTP semantics (201 Created, 409 Conflict, idempotency keys) are well understood. Queries go through GraphQL for flexible read patterns.

POST /payments (REST) + query { paymentHistory { ... } } (GraphQL)

Rules of thumb

Start with REST

Unless you have a specific reason not to. REST is the path of least resistance: universal tooling, no build step, every developer knows it. Reach for GraphQL or gRPC when REST's limitations are actively hurting you.

Switch to GraphQL when over-fetching or waterfall fetching is measurable

If mobile clients are downloading 10x more data than they display, or if a page requires three sequential requests before it can render, GraphQL solves those problems directly.

Switch to gRPC for service-to-service at volume

When internal services are making thousands of calls per second to each other, gRPC's binary serialization and multiplexing pay for the operational cost of managing .proto files.

Never expose gRPC directly to browsers without a proxy

gRPC-Web via Envoy works, but it is an operational dependency. For browser-facing APIs, REST or GraphQL is always simpler.

Match the paradigm to the boundary, not the team preference

The public API boundary calls for REST. The inter-service boundary calls for gRPC. The frontend-to-aggregation boundary calls for GraphQL. These are engineering decisions, not personal style choices.