When a team starts a new backend project, the first design decision that surfaces is the API style.
Choosing between REST, GraphQL, or gRPC isn’t just a “tool‑of‑the‑trade” choice; it shapes how clients request data, how servers evolve, and how the whole system behaves under load.
TL;DR
- REST: Simple CRUD, clear URLs, built‑in caching, best for public or slowly evolving services.
- GraphQL: One endpoint, fine‑grained field selection, ideal when UI needs mixed or nested data.
- gRPC: Binary protobuf, low latency, native streaming, suited for internal micro‑service mesh or real‑time workloads.
When to Use Each Style
REST
REST is the de‑facto standard for exposing resources over HTTP.
It shines when:
- The domain consists of well‑defined entities (users, posts, orders).
- Clients only need CRUD operations and the overhead of a full HTTP request/response cycle is acceptable.
- You want to leverage HTTP caching, content‑negotiation, and mature tooling (e.g., Postman, Swagger).
GraphQL
GraphQL’s appeal lies in its request flexibility.
Use it when:
- Clients (often front‑end apps) need to fetch a specific subset of fields or combine data from several resources in a single round‑trip.
- You anticipate a rapidly evolving schema; adding new fields is non‑breaking.
- You want a strongly‑typed schema that can be introspected and auto‑generated into client code.
gRPC
gRPC is a binary protocol built on HTTP/2, designed for efficient inter‑service communication.
It excels when:
- Services must process thousands of requests per second with minimal latency.
- You need bi‑directional streaming (e.g., real‑time chat, telemetry).
- Your infrastructure is language‑agnostic; protobuf definitions can be compiled into many languages.
Performance & Bandwidth
REST
REST typically serializes entire objects as JSON.
A GET /users/42 might return a 200 KB payload if the user object contains nested logs, even though the client only needs name and email.
Over‑fetching wastes bandwidth and can degrade perceived performance, especially on mobile networks.
GraphQL
GraphQL lets the client specify exactly which fields to return:
query {
user(id: 42) {
name
email
}
}
The server responds with only those fields, often cutting payload size by 70–80 % in practice.
However, the server must still resolve each field, which can introduce overhead if resolvers are expensive.
gRPC
gRPC uses Protocol Buffers, a compact binary format.
Serialization and deserialization are faster than JSON, and the message size is usually a fraction of the JSON equivalent.
For example, a protobuf payload for the same user data might be ~1 KB.
When combined with HTTP/2 multiplexing, gRPC reduces connection setup overhead and improves throughput.
Data Shape & Client Flexibility
REST
REST relies on URLs to describe the resource hierarchy:GET /users/42/posts/7.
Complex queries (e.g., “give me all posts by users in a certain city, sorted by date”) often require query strings or multiple endpoints, leading to a proliferation of endpoints and confusing semantics.
GraphQL
GraphQL exposes a single endpoint (/graphql) and a type system that maps directly to UI components.
A UI can request:
{
posts(limit: 10, filter: { author: { city: "Berlin" } }) {
id
title
author { name, city }
}
}
The shape of the response mirrors the query, making it trivial for the front‑end to bind data to components without manual mapping.
gRPC
gRPC defines strict service contracts via .proto files:
service UserService {
rpc GetUser(GetUserRequest) returns (UserResponse);
}
Clients must be compiled against the same schema; they cannot request arbitrary fields.
This rigidity enforces consistency but reduces flexibility for ad‑hoc queries.
Tooling & Ecosystem
| Feature | REST | GraphQL | gRPC |
|---|---|---|---|
| Middleware | Express, Koa, Hapi | Apollo Server, GraphQL‑Yoga | gRPC‑node, Envoy |
| Caching | HTTP cache, CDN | Custom caching, persisted queries | gRPC interceptors |
| Debugging | HTTP tools, Wireshark | GraphiQL, introspection | grpcurl, Envoy logs |
| Code Generation | OpenAPI → TS | GraphQL Code Generator → TS | protoc → TS stubs |
- REST’s middleware ecosystem is vast; you can drop in authentication, rate limiting, or logging with minimal friction.
- GraphQL’s introspection and schema stitching allow you to compose services from multiple domains.
- gRPC’s built‑in streaming, deadline propagation, and load‑balancing via Envoy make it a natural fit for service meshes.
Development Flow
REST
Versioning is often handled through URLs (/v1/users) or custom headers.
Breaking changes (e.g., renaming a field) require careful migration and deprecation strategies.
Automated contract tests (e.g., Pact) can catch regressions early.
GraphQL
Adding a new field to a type is a non‑breaking change; old clients continue to work.
Removing a field, however, breaks clients that query it.
Best practices include:
- Mark deprecated fields in the schema and provide a deprecation reason.
- Use a schema registry to track changes across environments.
gRPC
gRPC enforces strict contract evolution.
Adding optional fields (optional string nickname = 4;) is safe; removing fields is not.
When a change is required, you typically create a new service version (UserServiceV2) and use version‑aware routing.
Common Mistakes & Trade‑offs
| Category | Mistake | Consequence | Mitigation |
|---|---|---|---|
| REST | Over‑fetching via large resources | Wasted bandwidth, slower UX | Use pagination, selective fields, or GraphQL for complex queries |
| GraphQL | Over‑engineering the schema | Complex resolvers, slow server | Keep schema flat, use data loaders to batch DB calls |
| gRPC | Relying on raw protobufs without reflection | Harder debugging, opaque logs | Use Envoy, grpc‑web, or gRPC‑UI for inspection |
Trade‑offs to consider
- Simplicity vs. Flexibility: REST is simple but rigid; GraphQL is flexible but can become complex; gRPC is efficient but requires tight coupling.
- Tooling vs. Performance: REST tools are abundant; gRPC offers higher performance at the cost of debugging overhead.
- Client vs. Server Focus: GraphQL shifts complexity to the server (resolvers); REST keeps the server simple but pushes data shaping to the client.
Real‑World Example: CRUD Service in Three Styles
Below is a minimal “user” CRUD example written in TypeScript for Node.js.
It demonstrates the same operations expressed in REST, GraphQL, and gRPC.
// --------------------------------------------------
// REST (Express)
// --------------------------------------------------
import express from 'express';
const app = express();
app.use(express.json());
app.get('/users/:id', async (req, res) => {
const user = await db.findUserById(Number(req.params.id));
res.json(user);
});
app.post('/users', async (req, res) => {
const newUser = await db.createUser(req.body);
res.status(201).json(newUser);
});
app.put('/users/:id', async (req, res) => {
const updated = await db.updateUser(Number(req.params.id), req.body);
res.json(updated);
});
app.delete('/users/:id', async (req, res) => {
await db.deleteUser(Number(req.params.id));
res.status(204).end();
});
# --------------------------------------------------
# GraphQL (Apollo Server)
# --------------------------------------------------
type User {
id: ID!
name: String!
email: String!
}
type Query {
user(id: ID!): User
}
type Mutation {
createUser(name: String!, email: String!): User
updateUser(id: ID!, name: String, email: String): User
deleteUser(id: ID!): Boolean
}
const resolvers = {
Query: {
user: (_, { id }) => db.findUserById(Number(id)),
},
Mutation: {
createUser: (_, { name, email }) => db.createUser({ name, email }),
updateUser: (_, { id, name, email }) =>
db.updateUser(Number(id), { name, email }),
deleteUser: (_, { id }) => db.deleteUser(Number(id)),
},
};
const server = new ApolloServer({ typeDefs, resolvers });
server.listen({ port: 4000 });
// --------------------------------------------------
// gRPC (Protocol Buffers)
// --------------------------------------------------
syntax = "proto3";
package users;
service UserService {
rpc GetUser(GetUserRequest) returns (UserResponse);
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse);
rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse);
}
message GetUserRequest { int64 id = 1; }
message UserResponse {
int64 id = 1;
string name = 2;
string email = 3;
}
message CreateUserRequest {
string name = 1;
string email = 2;
}
message CreateUserResponse { UserResponse user = 1; }
message UpdateUserRequest {
int64 id = 1;
string name = 2;
string email = 3;
}
message UpdateUserResponse { UserResponse user = 1; }
message DeleteUserRequest { int64 id = 1; }
message DeleteUserResponse { bool success = 1; }
// gRPC server implementation (Node.js)
// --------------------------------------------------
import * as grpc from '@grpc/grpc-js';
import { UserServiceHandlers } from './proto/users_grpc_pb';
import { UserService } from './proto/users_pb';
const handlers: UserServiceHandlers = {
getUser: async (call, callback) => {
const user = await db.findUserById(call.request.getId());
const response = new UserResponse();
response.setId(user.id);
response.setName(user.name);
response.setEmail(user.email);
callback(null, response);
},
// ... create, update, delete similar
};
const server = new grpc.Server();
server.addService(UserService, handlers);
server.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), () => {
server.start();
});
Explanation
- REST: Each CRUD operation maps to an HTTP verb and URL. The client can use any HTTP client (cURL, Axios, browser fetch).
- GraphQL: All operations hit
/graphql. The query/mutation shape dictates the response. Clients can request only the fields they need. - gRPC: Each method is a strongly‑typed RPC. The client must compile the
.protofile to generate stubs, but the call is efficient and supports streaming if needed.
Common Mistakes & Trade‑offs (Expanded)
REST Over‑fetching
Mistake: Returning the entire user object when only the name and email are required.
Consequence: Increased payload, slower UI rendering.
Mitigation: Use selective field endpoints (GET /users/:id?fields=name,email) or switch to GraphQL for fine‑grained queries.
GraphQL Over‑engineering
Mistake: Designing a deeply nested schema to model every possible UI view.
Consequence: Resolver functions become complex, caching is harder, and performance degrades.
Mitigation: Keep the schema flat, use data loaders for batching, and document the public API surface.
gRPC Debugging Difficulty
Mistake: Logging raw protobuf messages in production.
Consequence: Logs are unreadable, making troubleshooting hard.
Mitigation: Use Envoy’s access logs, grpc‑web for browser debugging, and enable protocol‑buffer introspection in development.
Key Takeaways
- REST is the safest bet for simple, public APIs where caching and HTTP tooling matter.
- GraphQL offers the most client flexibility, at the cost of resolver complexity and potential over‑fetching if misused.
- gRPC delivers the best performance for internal service meshes and real‑time workloads, but requires a stricter contract and more tooling for debugging.
- Match the API style to your use case: CRUD‑heavy public services → REST; UI‑centric data fetching → GraphQL; high‑throughput micro‑services → gRPC.
- Always consider future evolution: how will breaking changes be handled? Will the schema grow in a way that preserves backward compatibility?
Choosing the right API style isn’t a one‑size‑fits‑all decision; it’s a strategic trade‑off between performance, flexibility, and developer experience. Evaluate your application’s specific needs, and let the data shape, client requirements, and infrastructure guide your choice.