Beyond REST — gRPC & GraphQL
Two alternatives to REST and when each one earns its place. Same problem, different trade-offs.
Why REST Isn't Always the Answer
REST is the default for good reason. It's simple, it works over HTTP, every language has libraries for it, browsers speak it natively, and you can debug it with curl.
But REST has rough edges that show up at scale:
- Over-fetching — your endpoint returns 30 fields, the client only needs 3
- Under-fetching — the client needs data from 5 endpoints, makes 5 round trips
- Versioning is painful — once
/v1/usersis shipped, changing it breaks clients - Schema is documented, not enforced — the API and the client can drift apart silently
- Overhead — JSON over HTTP is verbose; for high-throughput service-to-service calls, it's wasteful
Two alternatives have become serious contenders for specific use cases: gRPC for service-to-service communication, GraphQL for client-shaped data.
gRPC — Fast, Typed, Service-to-Service
gRPC is Google's open-source RPC framework. The pitch: write once in a .proto file, get auto-generated client and server code in any language, communicate via efficient binary over HTTP/2.
How it works:
1. Define your service in a Protocol Buffer (.proto) file:
syntax = "proto3";
service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
}
message GetUserRequest {
string user_id = 1;
}
message User {
string id = 1;
string email = 2;
string name = 3;
int64 created_at = 4;
}
2. Run the protoc compiler. It generates client and server stubs in Go, Java, Python, Node, Rust, etc.
3. Implement the server in your language. Call the client from any other language. The wire format is identical.
// Server (Go)
func (s *Server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
user, err := s.db.FindUser(req.UserId)
if err != nil { return nil, err }
return &pb.User{Id: user.ID, Email: user.Email, Name: user.Name}, nil
}
// Client (Python — same API, different language)
response = stub.GetUser(GetUserRequest(user_id="abc123"))
print(response.email)
What gRPC gives you:
- Binary protocol (Protobuf) — much smaller than JSON, faster to parse
- Strongly typed contracts — your client and server can't disagree on the schema; the proto file IS the contract
- Streaming — bidirectional, server-streaming, or client-streaming RPCs (great for pub/sub-like patterns)
- HTTP/2 multiplexing — many concurrent calls over one connection
- Cross-language code generation — one source of truth, many implementations
Where gRPC wins:
• Service-to-service communication inside your data center — every request is faster
• High-throughput systems where JSON parsing overhead matters
• Polyglot teams — Go service calling Python service calling Java service
• Real-time streaming workflows (live updates, telemetry)
Where gRPC loses:
• Browser clients — gRPC doesn't run natively in browsers (you need gRPC-Web, an adapter)
• Public APIs — partners expect REST; gRPC has a learning curve
• Easy debugging — you can't curl a gRPC endpoint without tools (grpcurl)
Rule of thumb: use REST/JSON at the edge (browsers, public APIs); use gRPC inside your system.
GraphQL — The Client Asks For What It Wants
GraphQL is Facebook's answer to "different clients need different shapes of data." Instead of the server defining endpoints, the client writes a query specifying exactly what it wants.
A REST request:
GET /api/users/123
{
"id": "123",
"name": "Alice",
"email": "alice@example.com",
"createdAt": "...",
"updatedAt": "...",
"lastLogin": "...",
"settings": { ...30 fields... },
"preferences": { ...20 fields... }
}
The same data, GraphQL:
query {
user(id: "123") {
name
email
}
}
Returns:
{ "user": { "name": "Alice", "email": "alice@example.com" } }
Just what the client asked for. Nothing more.
The big mental shift: the server publishes a SCHEMA (types, fields, relationships) and a set of root QUERIES and MUTATIONS. Clients compose any query that's valid against the schema. There are no "endpoints" — there's one URL, and the request body is a query.
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
followers: [User!]!
}
type Post {
id: ID!
title: String!
body: String!
author: User!
}
type Query {
user(id: ID!): User
posts(limit: Int): [Post!]!
}
A client can ask for a user, all their posts, and each post's author's name — in one request:
query {
user(id: "123") {
name
posts {
title
author { name }
}
}
}
What you get:
- No over-fetching — clients ask for exactly what they need
- No under-fetching — one query can pull deeply nested data in one round trip
- Strongly typed schema — tooling auto-generates client types in TypeScript, Swift, Kotlin
- Self-documenting — the schema doubles as documentation; tools like GraphQL Playground let you explore it interactively
- No versioning — add fields freely; deprecate old fields with annotations; clients that don't ask for old fields aren't affected
What it costs:
- Caching is harder — REST cached well at the HTTP level (CDN-friendly); GraphQL queries are POSTs, harder to cache
- N+1 query problem is REAL — naive resolvers fetch user, then run a separate DB query for each post, then for each post's author. You need DataLoader or similar to batch
- File uploads are awkward — GraphQL doesn't natively handle binary; you typically pair it with a separate upload endpoint
- Permissions are per-field complexity — "user can see their own email but not anyone else's" requires field-level authorization
- Performance ceilings — a hostile client can craft a deeply nested query that crashes your server. Need query depth/complexity limits.
When GraphQL wins:
• Multiple clients (web, iOS, Android) with different data needs
• Aggregating data from many backend services into one client query
• Rapidly evolving products where the data model changes frequently
When GraphQL loses:
• Simple CRUD APIs — REST is simpler
• Public APIs — REST is more familiar to integrators
• High-throughput service-to-service — gRPC is faster
Picking Between REST, gRPC, and GraphQL
No protocol is universally best. The right choice depends on who's calling, what they need, and what your team can operate.
┌─────────────────────────────────────┐
│ Public API for partners/external? │
│ → REST. Always. │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Browser/mobile client with │
│ varied data needs? │
│ → GraphQL (or REST + BFF) │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Service-to-service inside the │
│ data center, high throughput? │
│ → gRPC │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Simple CRUD service, small team, │
│ no special needs? │
│ → REST. The defaults are good. │
└─────────────────────────────────────┘
The real-world hybrid: large systems often use ALL THREE. REST at the edge for partners. GraphQL via a BFF layer for first-party clients. gRPC for service-to-service inside the cluster. Each protocol does what it's best at, and the boundaries between them are clear.
Don't try to pick one for everything. Pick the right tool for each boundary.
⁂ Back to all modules