Skip to content
Back to all concepts
APINetworkingMicroservices

GraphQL vs REST vs gRPC

Comprehensive comparison of three major API paradigms: REST (resource-based), GraphQL (query-based), and gRPC (RPC-based), covering performance, use cases, and implementation trade-offs for modern distributed systems.

By ScaleWikiUpdated 5 min readIntermediate

What are API Paradigms?

APIs (Application Programming Interfaces) enable communication between services. Three dominant paradigms have emerged: REST, GraphQL, and gRPC. Each solves different problems.

Key question: How should services communicate?

Quick Comparison

FeatureRESTGraphQLgRPC
ProtocolCommonly HTTP/1.1, HTTP/2 or HTTP/3Commonly HTTP; transport is not the schema modelNative gRPC commonly HTTP/2
FormatAny chosen representation, often JSONUsually JSON responsesUsually Protobuf (binary)
PhilosophyResourcesQueriesRPC
PerformanceDepends on endpoint and transportDepends on selection and resolver costDepends on serialization and service work
CachingStandard HTTP mechanisms if configuredPossible, with query and response designApplication/intermediary design
Browser Support✅✅❌ (needs proxy)
Learning CurveLowMediumHigh
Best ForPublic APIsMobile/Complex UIsMicroservices

Scope of the examples

The following snippets demonstrate request shapes, schemas and handler structure. They are not complete deployable services: database methods, authentication and authorization, pagination limits, cancellation, deadlines and error handling must be supplied. The local gRPC sample is intentionally loopback-only and uses plaintext; a deployed service needs an appropriate transport-security configuration. Proto fragments in separate sections are independent alternatives, not one combined file.

REST (Representational State Transfer)

Philosophy

Everything is a resource with a unique URL. Use standard HTTP methods to manipulate resources.

GET    /users/123      → Read user
POST   /users          → Create user
PUT    /users/123      → Update user
DELETE /users/123      → Delete user

Example API

javascript
// REST endpoint
GET /api/users/123
Response:
{
  "id": 123,
  "name": "Alice",
  "email": "alice@example.com",
  "posts": [
    {
      "id": 1,
      "title": "Hello World",
      "comments": [...]  // All comments included!
    },
    ...
  ]
}

Problem: Over-fetching - you get ALL data even if you only need the name.

REST Implementation

javascript
// Express.js REST API
const express = require('express');
const app = express();

app.use(express.json());

// GET /users/:id
app.get('/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }
  res.json(user);
});

// POST /users
app.post('/users', async (req, res) => {
  const { name, email } = req.body;
  const user = await db.users.create({ name, email });
  res.status(201).json(user);
});

// PUT /users/:id
app.put('/users/:id', async (req, res) => {
  const user = await db.users.update(req.params.id, req.body);
  res.json(user);
});

// DELETE /users/:id
app.delete('/users/:id', async (req, res) => {
  await db.users.delete(req.params.id);
  res.status(204).send();
});

app.listen(3000);

REST Best Practices

javascript
// Versioning
GET /api/v1/users/123
GET /api/v2/users/123

// Filtering & Pagination
GET /api/users?role=admin&page=2&limit=20

// Nested resources
GET /api/users/123/posts
GET /api/users/123/posts/456/comments

// HTTP Status Codes
200 OK              // Success
201 Created         // Resource created
204 No Content      // Deleted successfully
400 Bad Request     // Invalid input
401 Unauthorized    // Not authenticated
403 Forbidden       // Not authorized
404 Not Found       // Resource doesn't exist
500 Internal Error  // Server error

Pros & Cons

✅ Pros:

  • Simple, well-understood
  • Standard HTTP caching when cache keys, headers and authorization rules permit it
  • Stateless, scalable
  • Great for public APIs
  • Tooling everywhere

❌ Cons:

  • Over-fetching (getting too much data)
  • Under-fetching (need multiple requests)
  • Versioning challenges
  • Schema and generated client types need an additional contract such as OpenAPI

GraphQL

Philosophy

Client dictates exactly what data it needs. Single endpoint, flexible queries.

graphql
# Client query
{
  user(id: 123) {
    name
    email
    # Only get first 5 posts with titles
    posts(limit: 5) {
      title
    }
  }
}

# Server response (exactly what was asked)
{
  "data": {
    "user": {
      "name": "Alice",
      "email": "alice@example.com",
      "posts": [
        { "title": "Hello World" },
        { "title": "GraphQL Guide" }
      ]
    }
  }
}

Schema Definition

graphql
# schema.graphql
type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  comments: [Comment!]!
}

type Comment {
  id: ID!
  text: String!
  author: User!
}

type Query {
  user(id: ID!): User
  users(limit: Int, offset: Int): [User!]!
  post(id: ID!): Post
}

type Mutation {
  createUser(name: String!, email: String!): User!
  updateUser(id: ID!, name: String, email: String): User!
  deleteUser(id: ID!): Boolean!
}

GraphQL Implementation

javascript
// Apollo Server
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';

const typeDefs = `
  type User {
    id: ID!
    name: String!
    email: String!
    posts: [Post!]!
  }
  
  type Post {
    id: ID!
    title: String!
    content: String!
  }
  
  type Query {
    user(id: ID!): User
    users: [User!]!
  }
  
  type Mutation {
    createUser(name: String!, email: String!): User!
  }
`;

const resolvers = {
  Query: {
    user: async (parent, { id }, context) => {
      return await context.db.users.findById(id);
    },
    users: async (parent, args, context) => {
      return await context.db.users.findAll();
    }
  },
  
  Mutation: {
    createUser: async (parent, { name, email }, context) => {
      return await context.db.users.create({ name, email });
    }
  },
  
  User: {
    // Resolver for User.posts field
    posts: async (parent, args, context) => {
      return await context.db.posts.findByUserId(parent.id);
    }
  }
};

const server = new ApolloServer({ typeDefs, resolvers });
// database and authenticateRequest are application-specific dependencies.
const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
  context: async ({ req }) => ({
    db: database,
    user: await authenticateRequest(req)
  })
});
console.log(`Server ready at ${url}`);

Advanced Features

Fragments (Reusable queries):

graphql
fragment UserInfo on User {
  id
  name
  email
}

query GetUsers {
  users {
    ...UserInfo
    posts {
      title
    }
  }
}

Variables:

graphql
query GetUser($userId: ID!) {
  user(id: $userId) {
    name
    email
  }
}

# Variables
{
  "userId": "123"
}

Subscriptions (Real-time):

graphql
subscription OnNewPost {
  postAdded {
    id
    title
    author {
      name
    }
  }
}
javascript
// Server-side subscription
const resolvers = {
  Subscription: {
    postAdded: {
      subscribe: () => pubsub.asyncIterator(['POST_ADDED'])
    }
  }
};

// Publish event
pubsub.publish('POST_ADDED', {
  postAdded: newPost
});

Pros & Cons

✅ Pros:

  • Clients select response fields; resolvers can still overfetch internally
  • Single request for related data
  • Strong typing (schema)
  • Perfect for mobile (minimize bandwidth)
  • Introspection (self-documenting)

❌ Cons:

  • Complex caching (can't use HTTP caching easily)
  • N+1 query risk; batching, joins or request-scoped loaders can address it
  • Security risks (deeply nested queries can DoS server)
  • Harder to version
  • Files/uploads awkward

gRPC (Google Remote Procedure Call)

Philosophy

High-performance RPC using binary protocol. Call remote functions like local functions.

Client calls: getUser(123)
↓
gRPC converts to binary → Sent over HTTP/2
↓
Server executes: getUser(123)
↓
Returns binary response → Converted to object

Protocol Buffers (Protobuf)

protobuf
// user.proto
syntax = "proto3";

package users;

service UserService {
  rpc GetUser(GetUserRequest) returns (User);
  rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
  rpc CreateUser(CreateUserRequest) returns (User);
  rpc StreamUsers(StreamUsersRequest) returns (stream User);
}

message User {
  int32 id = 1;
  string name = 2;
  string email = 3;
  repeated Post posts = 4;
}

message Post {
  int32 id = 1;
  string title = 2;
  string content = 3;
}

message GetUserRequest {
  int32 id = 1;
}

message ListUsersRequest {
  int32 page = 1;
  int32 page_size = 2;
}

message ListUsersResponse {
  repeated User users = 1;
  int32 total = 2;
}

message CreateUserRequest {
  string name = 1;
  string email = 2;
}

message StreamUsersRequest {
  int32 min_id = 1;
}

gRPC Implementation

Server (Node.js):

javascript
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');

// Load proto file
const packageDefinition = protoLoader.loadSync('user.proto', { keepCase: true });
const userProto = grpc.loadPackageDefinition(packageDefinition).users;

// Implement service methods
const server = new grpc.Server();

server.addService(userProto.UserService.service, {
  getUser: async (call, callback) => {
    const userId = call.request.id;
    const user = await db.users.findById(userId);
    
    if (!user) {
      return callback({
        code: grpc.status.NOT_FOUND,
        message: 'User not found'
      });
    }
    
    callback(null, user);
  },
  
  listUsers: async (call, callback) => {
    const { page, page_size } = call.request;
    const users = await db.users.findAll({ page, page_size });
    callback(null, { users, total: users.length });
  },
  
  createUser: async (call, callback) => {
    const { name, email } = call.request;
    const user = await db.users.create({ name, email });
    callback(null, user);
  },
  
  // Server streaming
  streamUsers: (call) => {
    const stream = db.users.streamFrom(call.request.min_id);
    
    stream.on('data', (user) => {
      call.write(user);
    });
    
    stream.on('end', () => {
      call.end();
    });
  }
});

server.bindAsync(
  '127.0.0.1:50051',
  grpc.ServerCredentials.createInsecure(),
  () => server.start()
);

Client:

javascript
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');

const packageDefinition = protoLoader.loadSync('user.proto', { keepCase: true });
const userProto = grpc.loadPackageDefinition(packageDefinition).users;

const client = new userProto.UserService(
  'localhost:50051',
  grpc.credentials.createInsecure()
);

// Unary call
client.getUser({ id: 123 }, (error, user) => {
  if (error) {
    console.error(error);
  } else {
    console.log('User:', user);
  }
});

// Server streaming
const call = client.streamUsers({ min_id: 100 });

call.on('data', (user) => {
  console.log('Received user:', user);
});

call.on('end', () => {
  console.log('Stream ended');
});

Four Communication Patterns

protobuf
service UserService {
  // 1. Unary: Single request → Single response
  rpc GetUser(GetUserRequest) returns (User);
  
  // 2. Server streaming: Single request → Stream of responses
  rpc ListUsers(ListUsersRequest) returns (stream User);
  
  // 3. Client streaming: Stream of requests → Single response
  rpc UploadUsers(stream User) returns (UploadResponse);
  
  // 4. Bidirectional streaming: Stream ←→ Stream
  rpc Chat(stream Message) returns (stream Message);
}

Pros & Cons

✅ Pros:

  • Extremely fast (binary, HTTP/2, multiplexing)
  • Type-safe (generated code)
  • Streaming support (built-in)
  • Multiple languages (code generation)
  • Compact binary representation for many schemas; measure against compressed JSON for the actual payload

❌ Cons:

  • No browser support (needs envoy/grpc-web proxy)
  • Harder to debug (binary, not human-readable)
  • Learning curve (Protobuf, code generation)
  • Limited middleware ecosystem

Compare the same work, not protocol labels

The previous numerical latency and payload examples had no workload or measurement method and are not benchmarks. A fair comparison returns equivalent data, uses the same backend access pattern and tests both cold and reused connections. Record serialized bytes, compressed wire bytes, CPU, p50/p95 latency and error rate under the same concurrency.

For a profile screen needing a user and twenty posts, count database queries as well as network requests. A single GraphQL operation that triggers twenty-one database queries can be slower than a REST endpoint that performs one join. Conversely, a narrowly selected GraphQL response can avoid transferring unused fields. gRPC can provide generated contracts and streaming, but serialization cannot fix a slow database.

REST and GraphQL can both use HTTP/2. Multiplexing is not exclusive to gRPC. Read GraphQL’s performance guidance and gRPC’s RPC lifecycle documentation for the distinct concerns.

Real-World Usage

REST: Stripe API

bash
curl https://api.stripe.com/v1/charges \
  -u sk_test_xxx: \
  -d amount=2000 \
  -d currency=usd \
  -d source=tok_visa

GraphQL: GitHub API

graphql
query {
  viewer {
    repositories(first: 10) {
      nodes {
        name
        stargazers {
          totalCount
        }
      }
    }
  }
}

gRPC: Google Cloud APIs

protobuf
// Google Cloud Pub/Sub
service Publisher {
  rpc Publish(PublishRequest) returns (PublishResponse);
}

Product names do not define a universal internal architecture. Treat the examples above as publicly documented API styles, not a reconstruction of a company’s private service topology.


When to Use What?

Use REST when:

  • Building public APIs
  • Need simple HTTP caching
  • Wide audience (mobile, web, third-party)
  • Team unfamiliar with GraphQL/gRPC
  • Examples: Twitter API, Stripe API, AWS API

Use GraphQL when:

  • Mobile app (minimize round trips)
  • Complex, nested data requirements
  • Rapid frontend iteration
  • Multiple client types with different needs
  • Example: GitHub’s documented GraphQL API

Use gRPC when:

  • Internal microservices
  • Performance critical (low latency, high throughput)
  • Polyglot services (multiple languages)
  • Streaming data required
  • Example: documented gRPC APIs in Google Cloud

Migration Strategies

REST → GraphQL

javascript
// Wrap existing REST endpoints
const resolvers = {
  Query: {
    user: async (parent, { id }) => {
      // Call existing REST API
      const response = await fetch(`/api/users/${id}`);
      return response.json();
    }
  }
};

REST → gRPC (Dual Support)

javascript
// Expose both REST and gRPC
app.get('/users/:id', restHandler);      // REST
server.addService(userService, grpcHandler);  // gRPC

// Use envoy proxy for gRPC-web (browser support)

Interview Tips 💡

When discussing API design in system design interviews:

  1. Identify audience: "For public API, use REST for simplicity and caching..."
  2. Performance needs: "Internal services need low latency, so gRPC..."
  3. Client requirements: "Mobile app with complex screens, GraphQL prevents over-fetching..."
  4. Trade-offs: "gRPC can reduce serialization overhead; browser integration uses a compatible transport such as gRPC-Web..."
  5. Real examples: "GitHub documents a GraphQL API; use public provider documentation for concrete integration details..."
  6. Hybrid approach: "Could use REST for public API, gRPC for internal services..."

Worked decision: a product screen and a batch worker

A product team can expose REST for a stable partner integration, GraphQL for a changing aggregate screen and gRPC for a streaming internal worker without treating one as the winner. The cost is maintaining more contracts. Choose the smallest set that resolves an actual client constraint, then test an added field, a deprecated field and an interrupted request across supported client versions.