
Introduction
Choosing the right communication protocol is one of the most important architectural decisions in modern software development. REST has been the traditional default, GraphQL has risen as a flexible alternative, and gRPC is gaining momentum for high-performance distributed systems. Each protocol solves different problems, and understanding their trade-offs is crucial for building scalable applications.
In this guide, we’ll compare REST, GraphQL, and gRPC across key dimensions—performance, flexibility, tooling, and developer experience—with practical code examples to help you decide which protocol fits your project in 2025.
REST: The Classic Standard
REST (Representational State Transfer) has powered the web for decades. It uses HTTP verbs (GET, POST, PUT, DELETE) to expose resources in a predictable way. REST’s simplicity and ubiquity make it the default choice for most web APIs.
REST API Example
// Express.js REST API
import express from 'express';
const app = express();
app.use(express.json());
interface User {
id: string;
name: string;
email: string;
orders: Order[];
}
interface Order {
id: string;
total: number;
status: string;
}
// GET /users - List users
app.get('/api/users', async (req, res) => {
const { page = 1, limit = 20 } = req.query;
const users = await userRepository.findAll({
skip: (Number(page) - 1) * Number(limit),
take: Number(limit),
});
res.json({
data: users,
pagination: {
page: Number(page),
limit: Number(limit),
total: await userRepository.count(),
},
});
});
// GET /users/:id - Get single user
app.get('/api/users/:id', async (req, res) => {
const user = await userRepository.findById(req.params.id);
if (!user) {
return res.status(404).json({
error: 'User not found',
code: 'USER_NOT_FOUND',
});
}
res.json({ data: user });
});
// POST /users - Create user
app.post('/api/users', async (req, res) => {
const { name, email } = req.body;
// Validation
if (!name || !email) {
return res.status(400).json({
error: 'Name and email are required',
code: 'VALIDATION_ERROR',
});
}
const user = await userRepository.create({ name, email });
res.status(201).json({ data: user });
});
// GET /users/:id/orders - Get user orders (separate endpoint)
// This requires an additional request - REST's under-fetching problem
app.get('/api/users/:id/orders', async (req, res) => {
const orders = await orderRepository.findByUserId(req.params.id);
res.json({ data: orders });
});
Benefits:
- Universally supported and understood
- Easy to implement with standard web tools
- Works well with HTTP caching (CDN, browser cache, ETags)
- Strong ecosystem of client libraries and frameworks
- Stateless design enables horizontal scaling
Pitfalls:
- Over-fetching: endpoints return all fields even when client needs few
- Under-fetching: complex views require multiple round-trips
- Lacks strong typing (unless paired with OpenAPI/Swagger)
- Versioning can become complex as API evolves
REST is a safe choice for public APIs, CRUD services, and applications where simplicity matters.
GraphQL: Flexibility First
GraphQL was introduced by Facebook to solve REST’s over-fetching problem. Instead of multiple endpoints, it exposes a single endpoint where clients can query exactly the data they need, including nested relationships in a single request.
GraphQL API Example
// GraphQL Schema Definition
const typeDefs = `#graphql
type User {
id: ID!
name: String!
email: String!
orders(status: OrderStatus): [Order!]!
totalSpent: Float!
createdAt: DateTime!
}
type Order {
id: ID!
total: Float!
status: OrderStatus!
items: [OrderItem!]!
user: User!
createdAt: DateTime!
}
type OrderItem {
id: ID!
product: Product!
quantity: Int!
price: Float!
}
type Product {
id: ID!
name: String!
price: Float!
inventory: Int!
}
enum OrderStatus {
PENDING
PROCESSING
SHIPPED
DELIVERED
CANCELLED
}
type Query {
user(id: ID!): User
users(page: Int, limit: Int): UserConnection!
order(id: ID!): Order
products(category: String): [Product!]!
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
createOrder(input: CreateOrderInput!): Order!
cancelOrder(id: ID!): Order!
}
type Subscription {
orderStatusChanged(userId: ID!): Order!
}
input CreateUserInput {
name: String!
email: String!
}
input UpdateUserInput {
name: String
email: String
}
input CreateOrderInput {
userId: ID!
items: [OrderItemInput!]!
}
input OrderItemInput {
productId: ID!
quantity: Int!
}
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
`;
// Resolvers with DataLoader for N+1 prevention
import DataLoader from 'dataloader';
const resolvers = {
Query: {
user: async (_, { id }, context) => {
return context.userLoader.load(id);
},
users: async (_, { page = 1, limit = 20 }) => {
const offset = (page - 1) * limit;
const users = await userRepository.findAll({ offset, limit: limit + 1 });
const hasNextPage = users.length > limit;
const edges = users.slice(0, limit).map(user => ({
node: user,
cursor: Buffer.from(user.id).toString('base64'),
}));
return {
edges,
pageInfo: {
hasNextPage,
endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null,
},
};
},
},
User: {
orders: async (user, { status }, context) => {
const orders = await context.ordersByUserLoader.load(user.id);
if (status) {
return orders.filter(order => order.status === status);
}
return orders;
},
totalSpent: async (user, _, context) => {
const orders = await context.ordersByUserLoader.load(user.id);
return orders
.filter(order => order.status !== 'CANCELLED')
.reduce((sum, order) => sum + order.total, 0);
},
},
Mutation: {
createUser: async (_, { input }) => {
return userRepository.create(input);
},
createOrder: async (_, { input }, context) => {
const { userId, items } = input;
// Validate user exists
const user = await context.userLoader.load(userId);
if (!user) {
throw new Error('User not found');
}
// Calculate totals and create order
const order = await orderRepository.create({
userId,
items,
status: 'PENDING',
});
// Publish subscription event
context.pubsub.publish('ORDER_STATUS_CHANGED', {
orderStatusChanged: order,
});
return order;
},
},
Subscription: {
orderStatusChanged: {
subscribe: (_, { userId }, context) => {
return context.pubsub.asyncIterator(['ORDER_STATUS_CHANGED']);
},
},
},
};
// Context factory with DataLoaders
function createContext({ req }) {
return {
userLoader: new DataLoader(async (ids) => {
const users = await userRepository.findByIds(ids);
return ids.map(id => users.find(u => u.id === id));
}),
ordersByUserLoader: new DataLoader(async (userIds) => {
const orders = await orderRepository.findByUserIds(userIds);
return userIds.map(userId =>
orders.filter(order => order.userId === userId)
);
}),
pubsub: pubsubInstance,
};
}
GraphQL Query Example
# Single request fetches user + orders + items - no under-fetching!
query GetUserWithOrders($userId: ID!) {
user(id: $userId) {
id
name
email
totalSpent
orders(status: DELIVERED) {
id
total
status
items {
product {
name
price
}
quantity
}
}
}
}
Benefits:
- Precise data fetching reduces bandwidth waste
- Strongly typed schema improves developer experience
- Single endpoint simplifies client code
- Real-time subscriptions built into the spec
- Great for front-end teams working with diverse data needs
Pitfalls:
- N+1 query problems require DataLoader or similar patterns
- Complex queries can cause performance issues (need query complexity limiting)
- HTTP caching is harder compared to REST
- Requires more upfront schema design
GraphQL shines in client-heavy apps like SPAs, mobile apps, or multi-platform projects where UI teams need autonomy.
gRPC: Performance at Scale
gRPC, created by Google, is built on HTTP/2 and uses Protocol Buffers (Protobuf) for serialization. It is designed for low-latency, high-performance communication between microservices, offering up to 10x faster serialization than JSON.
gRPC Service Definition
// user.proto
syntax = "proto3";
package user;
option go_package = "./pb";
// Service definition
service UserService {
// Unary RPC - single request, single response
rpc GetUser(GetUserRequest) returns (User);
rpc CreateUser(CreateUserRequest) returns (User);
rpc UpdateUser(UpdateUserRequest) returns (User);
rpc DeleteUser(DeleteUserRequest) returns (Empty);
// Server streaming - single request, stream of responses
rpc ListUsers(ListUsersRequest) returns (stream User);
// Client streaming - stream of requests, single response
rpc BatchCreateUsers(stream CreateUserRequest) returns (BatchCreateResponse);
// Bidirectional streaming - stream both ways
rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}
service OrderService {
rpc CreateOrder(CreateOrderRequest) returns (Order);
rpc GetOrder(GetOrderRequest) returns (Order);
// Real-time order updates via server streaming
rpc WatchOrder(WatchOrderRequest) returns (stream OrderUpdate);
}
// Messages
message User {
string id = 1;
string name = 2;
string email = 3;
int64 created_at = 4;
repeated Order orders = 5;
}
message Order {
string id = 1;
string user_id = 2;
double total = 3;
OrderStatus status = 4;
repeated OrderItem items = 5;
int64 created_at = 6;
}
message OrderItem {
string product_id = 1;
string product_name = 2;
int32 quantity = 3;
double price = 4;
}
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0;
ORDER_STATUS_PENDING = 1;
ORDER_STATUS_PROCESSING = 2;
ORDER_STATUS_SHIPPED = 3;
ORDER_STATUS_DELIVERED = 4;
ORDER_STATUS_CANCELLED = 5;
}
message GetUserRequest {
string id = 1;
}
message CreateUserRequest {
string name = 1;
string email = 2;
}
message UpdateUserRequest {
string id = 1;
optional string name = 2;
optional string email = 3;
}
message DeleteUserRequest {
string id = 1;
}
message ListUsersRequest {
int32 page_size = 1;
string page_token = 2;
}
message BatchCreateResponse {
int32 created_count = 1;
repeated string user_ids = 2;
}
message CreateOrderRequest {
string user_id = 1;
repeated OrderItemInput items = 2;
}
message OrderItemInput {
string product_id = 1;
int32 quantity = 2;
}
message GetOrderRequest {
string id = 1;
}
message WatchOrderRequest {
string order_id = 1;
}
message OrderUpdate {
string order_id = 1;
OrderStatus status = 2;
string message = 3;
int64 timestamp = 4;
}
message ChatMessage {
string user_id = 1;
string content = 2;
int64 timestamp = 3;
}
message Empty {}
gRPC Server Implementation (Go)
package main
import (
"context"
"io"
"log"
"net"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "myapp/pb"
)
type userServer struct {
pb.UnimplementedUserServiceServer
users map[string]*pb.User
}
func (s *userServer) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
user, exists := s.users[req.Id]
if !exists {
return nil, status.Errorf(codes.NotFound, "user %s not found", req.Id)
}
return user, nil
}
func (s *userServer) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.User, error) {
user := &pb.User{
Id: generateID(),
Name: req.Name,
Email: req.Email,
CreatedAt: time.Now().Unix(),
}
s.users[user.Id] = user
return user, nil
}
// Server streaming - returns multiple users
func (s *userServer) ListUsers(req *pb.ListUsersRequest, stream pb.UserService_ListUsersServer) error {
pageSize := int(req.PageSize)
if pageSize == 0 {
pageSize = 10
}
count := 0
for _, user := range s.users {
if count >= pageSize {
break
}
if err := stream.Send(user); err != nil {
return err
}
count++
}
return nil
}
// Client streaming - receives batch of users
func (s *userServer) BatchCreateUsers(stream pb.UserService_BatchCreateUsersServer) error {
var createdIds []string
for {
req, err := stream.Recv()
if err == io.EOF {
// Client finished sending
return stream.SendAndClose(&pb.BatchCreateResponse{
CreatedCount: int32(len(createdIds)),
UserIds: createdIds,
})
}
if err != nil {
return err
}
user := &pb.User{
Id: generateID(),
Name: req.Name,
Email: req.Email,
CreatedAt: time.Now().Unix(),
}
s.users[user.Id] = user
createdIds = append(createdIds, user.Id)
}
}
// Bidirectional streaming - real-time chat
func (s *userServer) Chat(stream pb.UserService_ChatServer) error {
for {
msg, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
// Echo back with timestamp
response := &pb.ChatMessage{
UserId: "system",
Content: "Received: " + msg.Content,
Timestamp: time.Now().Unix(),
}
if err := stream.Send(response); err != nil {
return err
}
}
}
func main() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
grpcServer := grpc.NewServer(
grpc.UnaryInterceptor(loggingInterceptor),
)
pb.RegisterUserServiceServer(grpcServer, &userServer{
users: make(map[string]*pb.User),
})
log.Println("gRPC server listening on :50051")
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
// Interceptor for logging/monitoring
func loggingInterceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
start := time.Now()
resp, err := handler(ctx, req)
log.Printf("Method: %s, Duration: %v, Error: %v",
info.FullMethod, time.Since(start), err)
return resp, err
}
gRPC Client (Node.js)
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
// Load proto definition
const packageDefinition = protoLoader.loadSync('user.proto', {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
const proto = grpc.loadPackageDefinition(packageDefinition) as any;
// Create client
const client = new proto.user.UserService(
'localhost:50051',
grpc.credentials.createInsecure()
);
// Unary call
async function getUser(id: string): Promise<User> {
return new Promise((resolve, reject) => {
client.GetUser({ id }, (err: Error, response: User) => {
if (err) reject(err);
else resolve(response);
});
});
}
// Server streaming
async function* listUsers(pageSize: number): AsyncGenerator<User> {
const call = client.ListUsers({ page_size: pageSize });
for await (const user of call) {
yield user;
}
}
// Client streaming
async function batchCreateUsers(users: CreateUserRequest[]): Promise<BatchCreateResponse> {
return new Promise((resolve, reject) => {
const call = client.BatchCreateUsers((err: Error, response: BatchCreateResponse) => {
if (err) reject(err);
else resolve(response);
});
for (const user of users) {
call.write(user);
}
call.end();
});
}
// Usage
async function main() {
// Create a user
const user = await new Promise<User>((resolve, reject) => {
client.CreateUser(
{ name: 'John Doe', email: 'john@example.com' },
(err: Error, response: User) => {
if (err) reject(err);
else resolve(response);
}
);
});
console.log('Created user:', user);
// Stream users
for await (const user of listUsers(10)) {
console.log('User:', user.name);
}
}
Benefits:
- Binary serialization is faster and more compact than JSON
- Native support for streaming (bidirectional, client, server)
- Strong contracts via .proto definitions with code generation
- HTTP/2 multiplexing reduces connection overhead
- Excellent for polyglot microservice environments
Pitfalls:
- Steeper learning curve for teams new to Protobuf
- Debugging is harder compared to human-readable JSON
- Limited browser support (gRPC-Web adds complexity)
- Tooling is not as universal as REST
gRPC is ideal for microservices, IoT, and real-time systems where performance and scalability are critical.
Side-by-Side Comparison
| Feature | REST | GraphQL | gRPC |
|---|---|---|---|
| Data Fetching | Fixed endpoints | Flexible queries | Strongly typed contracts |
| Performance | Good (JSON over HTTP/1.1) | Depends on query complexity | Excellent (binary, HTTP/2) |
| Typing | Optional (OpenAPI) | Strong typing via schema | Strong typing via Protobuf |
| Caching | Straightforward (HTTP cache) | More complex (needs library) | Built-in streaming, less caching |
| Real-time | WebSockets (separate) | Subscriptions built-in | Bidirectional streaming |
| Code Generation | Optional (OpenAPI tools) | Many tools available | Built-in, required |
| Browser Support | Native | Native | Requires gRPC-Web |
| Best Use Case | CRUD APIs, public endpoints | Client-driven applications | High-scale microservices |
Hybrid Architecture: Using All Three
Many production systems combine protocols strategically:
// API Gateway that bridges protocols
import express from 'express';
import { ApolloServer } from '@apollo/server';
import * as grpc from '@grpc/grpc-js';
const app = express();
// REST for public API and webhooks
app.post('/api/v1/webhooks/stripe', stripeWebhookHandler);
app.get('/api/v1/health', healthCheckHandler);
// GraphQL for frontend applications
const apolloServer = new ApolloServer({
typeDefs,
resolvers: {
Query: {
user: async (_, { id }) => {
// Call internal gRPC service
return grpcUserClient.getUser({ id });
},
},
},
});
// gRPC for internal microservice communication
const grpcUserClient = new UserServiceClient(
'user-service:50051',
grpc.credentials.createInsecure()
);
const grpcOrderClient = new OrderServiceClient(
'order-service:50051',
grpc.credentials.createInsecure()
);
Common Mistakes to Avoid
Using gRPC for Public APIs
gRPC’s limited browser support makes it a poor choice for APIs consumed directly by web browsers. Use REST or GraphQL for public-facing APIs.
Ignoring GraphQL Query Complexity
Without query depth and complexity limits, malicious clients can craft expensive queries that crash your servers. Always implement query cost analysis.
Over-Engineering with GraphQL
If your API is simple CRUD operations consumed by a single client, GraphQL’s overhead may not be worth it. REST is simpler to maintain.
Not Versioning REST APIs
Plan for API evolution from the start. Use URL versioning (/api/v1/) or header-based versioning to support backwards compatibility.
Choosing the Right Protocol in 2025
- Pick REST if you want simplicity, interoperability, and wide adoption.
- Pick GraphQL if your app has multiple clients (mobile, web, IoT) needing flexible queries.
- Pick gRPC if performance and scalability are your top priorities in distributed systems.
Conclusion
REST, GraphQL, and gRPC each bring unique strengths to the table. In 2025, no single protocol is a “one size fits all” solution—the best choice depends on your project’s requirements for performance, flexibility, and ecosystem support.
For a deeper dive into GraphQL implementation, read Implementing GraphQL APIs in 2025: benefits and pitfalls. For rate limiting across any API type, check out our guide on API Rate Limiting 101. And for official documentation, explore the gRPC documentation.
2 Comments