Backend

REST vs GraphQL vs gRPC: selecting the right protocol

REST Vs GraphQL Vs GRPC Selecting The Right Protocol

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.