REST vs GraphQL vs gRPC: Picking an API Style by Client, Payload and Latency

Key takeaways

REST is the simplest to cache and debug, GraphQL lets clients shape responses at the cost of server complexity and harder caching, and gRPC gives compact binary calls between services but needs a proxy for browsers. The post walks through examples of each and a selection flow by project type.

Introduction: Importance of API Design

The API style a backend exposes is hard to change once clients depend on it, and REST, GraphQL, and gRPC each push developer experience, payload size, and latency in different directions. This article walks through each style with a small Node.js example, lists where each one is strong and where it hurts, compares them side by side, and ends with selection criteria by client type and project.


REST API

What is REST?

REST (Representational State Transfer) is an architectural style based on HTTP. Core Principles of REST:

graph TB
    A[REST Principles] --> B[Resource]
    A --> C[HTTP Methods]
    A --> D[Stateless]
    A --> E[Cacheable]
    
    B --> B1[Identified by URI]
    C --> C1[GET POST PUT DELETE]
    D --> D1[Server does not store state]
    E --> E1[Utilize HTTP cache]

REST API Example

Endpoint Design:

GET    /api/users          # List users
GET    /api/users/123      # Get specific user
POST   /api/users          # Create user
PUT    /api/users/123      # Update user
DELETE /api/users/123      # Delete user
GET    /api/users/123/posts  # User's post list

Node.js Express Implementation:

const express = require('express');
const app = express();
app.use(express.json());
// List users
app.get('/api/users', (req, res) => {
  const users = [
    { id: 1, name: 'Alice', email: '[email protected]' },
    { id: 2, name: 'Bob', email: '[email protected]' }
  ];
  res.json(users);
});
// Get specific user
app.get('/api/users/:id', (req, res) => {
  const user = { id: req.params.id, name: 'Alice' };
  res.json(user);
});
// Create user
app.post('/api/users', (req, res) => {
  const newUser = req.body;
  res.status(201).json(newUser);
});
app.listen(3000);

The URL design above is the easy part of REST; the parts that decide whether clients can rely on it are the HTTP semantics. GET, PUT and DELETE are defined as idempotent, so clients, proxies and retry libraries may repeat them after a timeout; POST is not, which is why a retried “create order” request can create two orders unless the API accepts an idempotency key (as payment APIs typically do). PUT replaces the whole resource, and a client that sends only { "name": "Bob" } with PUT may wipe the email field; partial updates belong in PATCH. Status codes carry meaning too: returning 200 with { "error": "not found" } breaks every cache, monitor and client library that relies on 404. The example also returns req.params.id as a string ("123"), while the list returns numbers, a small inconsistency that becomes a real bug in typed clients.

REST Pros and Cons

Pros:

  • Simplicity: Uses HTTP standard
  • Caching: Can utilize HTTP cache
  • Tool support: Postman, curl, etc.
  • Low learning curve: Intuitive Cons:
  • Over-fetching: Receives unnecessary data too
  • Under-fetching: Multiple requests needed (a list, then one request per item)
  • Version management: /api/v1/, /api/v2/
  • Lack of flexibility: Add endpoints when client requirements change

Over- and under-fetching are real, but their cost depends on the client. For a server-to-server call in the same data center, a few extra fields or one extra round trip rarely matter. For a mobile app on a slow network, five sequential requests to build one screen can take seconds. Many REST APIs address this without switching technology, through sparse fieldsets (?fields=name,email), embedding (?include=posts), or a backend-for-frontend endpoint that serves exactly one screen.


GraphQL

What is GraphQL?

GraphQL is a query language developed by Facebook, allowing clients to request exactly the data they need. GraphQL Structure:

graph LR
    A[Client] -->|Query| B[GraphQL Server]
    B -->|Only exact data| A
    
    C[REST] -->|Fixed response| D[Client]
    D -->|Receives unnecessary data too| C

GraphQL Example

Schema Definition:

# schema.graphql
type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
}
type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
}
type Query {
  user(id: ID!): User
  users: [User!]!
  post(id: ID!): Post
}
type Mutation {
  createUser(name: String!, email: String!): User!
  updateUser(id: ID!, name: String): User!
  deleteUser(id: ID!): Boolean!
}

Query Example:

# Query user and posts at once
query {
  user(id: "123") {
    name
    email
    posts {
      title
      content
    }
  }
}
# Response (only needed fields)
{
  "data": {
    "user": {
      "name": "Alice",
      "email": "[email protected]",
      "posts": [
        {
          "title": "First Post",
          "content": "Content..."
        }
      ]
    }
  }
}

Node.js Apollo Server Implementation:

const { ApolloServer, gql } = require('apollo-server');
// Type definitions
const typeDefs = gql`
  type User {
    id: ID!
    name: String!
    email: String!
  }
  
  type Query {
    users: [User!]!
    user(id: ID!): User
  }
`;
// Resolvers
const resolvers = {
  Query: {
    users: () => [
      { id: '1', name: 'Alice', email: '[email protected]' },
      { id: '2', name: 'Bob', email: '[email protected]' }
    ],
    user: (_, { id }) => {
      return { id, name: 'Alice', email: '[email protected]' };
    }
  }
};
const server = new ApolloServer({ typeDefs, resolvers });
server.listen().then(({ url }) => {
  console.log(`🚀 Server ready at ${url}`);
});

This uses the apollo-server v3 package, which has reached end of life; new projects install @apollo/server and start it with startStandaloneServer(server, { listen: { port: 4000 } }). The schema and resolver structure stay the same.

The resolver map is where GraphQL’s main performance trap lives. Each field can have its own resolver, and the server calls it once per object. If User.posts is resolved with a database query, a request for 50 users with their posts runs 1 query for the users and 50 for the posts: the N+1 problem, now on the server instead of the client. DataLoader fixes it by collecting all the posts lookups made during one tick of the event loop and turning them into a single WHERE user_id IN (...) query. Without it, a GraphQL API can be much slower than the REST endpoints it replaced, even though each client sends only one request.

GraphQL Pros and Cons

Pros:

  • Exact data: Request only needed fields
  • Single endpoint: All requests through one /graphql
  • Type system: Type guarantee with schema
  • Developer experience: GraphQL Playground, automatic documentation Cons:
  • Complexity: High learning curve
  • Caching difficulty: Limited HTTP cache utilization
  • N+1 problem: Need additional tools like DataLoader
  • Overhead: Excessive for simple CRUD

Two operational costs are easy to underestimate. Because clients write the queries, one client can send a query nested ten levels deep that joins half the database; a public GraphQL API needs query depth or cost limits, and often persisted queries (only pre-registered queries are allowed). And errors are usually reported as HTTP 200 with an errors array alongside partial data, so dashboards that count 5xx responses show a healthy service while users see failures. I have found this to be the first thing to fix after adopting GraphQL: monitoring has to be per operation and per error, not per status code. Caching is limited because everything is a POST to one URL; GET-based persisted queries and client-side normalized caches (Apollo Client, Relay) are the usual answers.


gRPC

What is gRPC?

gRPC is a high-performance RPC (Remote Procedure Call) framework developed by Google. Uses Protocol Buffers to serialize data. gRPC Structure:

graph LR
    A[Client] -->|Binary Protobuf| B[gRPC Server]
    B -->|Binary Protobuf| A
    
    C[REST] -->|JSON Text| D[Server]
    D -->|JSON Text| C

gRPC Example

Protobuf Definition:

// user.proto
syntax = "proto3";
package user;
service UserService {
  rpc GetUser (GetUserRequest) returns (User);
  rpc ListUsers (ListUsersRequest) returns (ListUsersResponse);
  rpc CreateUser (CreateUserRequest) returns (User);
}
message User {
  int32 id = 1;
  string name = 2;
  string email = 3;
}
message GetUserRequest {
  int32 id = 1;
}
message ListUsersRequest {
  int32 page = 1;
  int32 page_size = 2;
}
message ListUsersResponse {
  repeated User users = 1;
}
message CreateUserRequest {
  string name = 1;
  string email = 2;
}

The numbers after each field (= 1, = 2) are what goes on the wire, not the names, and they are the contract. You can rename a field freely, but you must never change a field’s number or reuse the number of a deleted field; mark it reserved 3; instead. Old clients would otherwise decode new data into the wrong field without any error. In proto3 every scalar field also has a default (0, empty string), and an unset field is indistinguishable from one set to its default, so “was page_size omitted or set to 0?” cannot be answered unless the field is declared optional. C++ Server Implementation:

#include <grpcpp/grpcpp.h>
#include "user.grpc.pb.h"
class UserServiceImpl final : public user::UserService::Service {
  grpc::Status GetUser(
      grpc::ServerContext* context,
      const user::GetUserRequest* request,
      user::User* response) override {
    
    response->set_id(request->id());
    response->set_name("Alice");
    response->set_email("[email protected]");
    
    return grpc::Status::OK;
  }
};
int main() {
  std::string server_address("0.0.0.0:50051");
  UserServiceImpl service;
  
  grpc::ServerBuilder builder;
  builder.AddListeningPort(server_address, grpc::InsecureServerCredentials());
  builder.RegisterService(&service);
  
  std::unique_ptr<grpc::Server> server(builder.BuildAndStart());
  std::cout << "Server listening on " << server_address << std::endl;
  server->Wait();
  
  return 0;
}

gRPC Pros and Cons

Pros:

  • High performance: Binary protocol, HTTP/2
  • Type safety: Protobuf schema
  • Streaming: Bidirectional streaming support
  • Multi-language support: Generate clients in multiple languages Cons:
  • Limited browser support: Needs gRPC-Web
  • Debugging difficulty: Binary protocol
  • Learning curve: Protobuf syntax and code generation in the build
  • Tooling: curl does not work; use grpcurl, Postman’s gRPC mode, or server reflection

The failure modes of gRPC in production are mostly about connections and time. Every call should carry a deadline; without one, a slow downstream service holds requests open indefinitely, and failures surface as DEADLINE_EXCEEDED only if someone set a deadline in the first place. Errors come back as status codes such as UNAVAILABLE (connection or server problem, usually safe to retry) and INVALID_ARGUMENT (not safe to retry), and mapping them to HTTP statuses at a gateway takes deliberate work. And because a client keeps one long-lived HTTP/2 connection and multiplexes all calls over it, a standard Kubernetes Service, which balances connections, sends every request from a given client to the same pod. Scaling out then does nothing for that client until connections are re-established. Client-side load balancing over a headless Service, or a proxy such as Envoy or a service mesh that balances individual requests, is the standard fix.


Comparative Analysis

Comprehensive Comparison Table

FeatureRESTGraphQLgRPC
ProtocolAny HTTP versionAny HTTP version (WebSocket for subscriptions)HTTP/2
Data FormatUsually JSONJSONProtobuf (Binary)
Type SystemOptional (OpenAPI)Yes (Schema)Yes (Protobuf)
CachingExcellent (HTTP)LimitedLimited
PerformanceMediumMediumFast
Learning CurveLowMediumHigh
Browser SupportExcellentExcellentLimited
StreamingNoneSubscriptionBidirectional

Where the performance difference comes from

Benchmarks comparing the three produce very different numbers depending on payload shape, language, serialization library and whether compression is on, so be wary of any single ranking. The mechanisms behind the differences are more useful than the numbers:

  • Serialization: Protobuf encodes field numbers and compact varints instead of repeating field names as text, so messages are smaller and faster to parse than the equivalent JSON. The gap shrinks a lot once responses are gzip-compressed, and it matters most for many small messages and CPU-bound services.
  • Connections: gRPC multiplexes calls over one HTTP/2 connection. REST over HTTP/2 gets the same benefit; REST over HTTP/1.1 with keep-alive is less efficient but rarely the bottleneck.
  • Server work per request: GraphQL parses and validates a query document and runs a resolver per field, which costs CPU that REST and gRPC do not spend; with poorly batched resolvers, database round trips dominate everything else.

For most business APIs, latency is dominated by database queries and network distance, not by the API style. gRPC’s speed matters in high-volume service-to-service traffic; GraphQL’s benefit is fewer client round trips, which shows up as faster screens on mobile networks rather than as higher server throughput.

Use Case Comparison

REST Suitable For:

  • Simple CRUD API
  • Public API (external developer use)
  • When caching is important
  • Legacy system integration GraphQL Suitable For:
  • Complex data requirements
  • Mobile apps (data savings)
  • Fast frontend development
  • Various clients (web, mobile, desktop) gRPC Suitable For:
  • Microservice communication
  • Real-time streaming
  • High performance requirements
  • Internal API (browser not needed)

Selection Guide

Selection Flowchart

flowchart TD
    A[Start API Design] --> B{Browser Client?}
    B -->|No| C{Performance Top Priority?}
    C -->|Yes| D[gRPC]
    C -->|No| E[REST]
    
    B -->|Yes| F{Complex Data Requirements?}
    F -->|Yes| G[GraphQL]
    F -->|No| H{Public API?}
    H -->|Yes| I[REST]
    H -->|No| J[GraphQL or REST]

Recommendations by Project

1. Startup MVP

  • REST: Fast development, simplicity
  • Example: Express + MongoDB 2. Mobile App Backend
  • GraphQL: Data savings, flexibility
  • Example: Apollo Server + PostgreSQL 3. Microservices
  • gRPC: High performance, type safety
  • Example: gRPC + Kubernetes 4. Public API
  • REST: Standard, tool support
  • Example: Stripe API, GitHub API 5. Real-time App
  • GraphQL Subscription or gRPC Streaming
  • Example: Chat, notifications, dashboard

Hybrid Approach

Many projects mix multiple API styles.

Frontend (Browser)
    ↓ GraphQL
API Gateway
    ↓ gRPC
Microservices

Example:

  • External clients: REST or GraphQL
  • Internal services: gRPC
  • Real-time features: WebSocket or GraphQL Subscription

Implementation Patterns

REST + gRPC Hybrid:

// API Gateway (Express + REST)
app.get('/api/users/:id', async (req, res) => {
  // Call internal gRPC service
  const user = await grpcClient.getUser({ id: req.params.id });
  res.json(user);
});

GraphQL + gRPC Hybrid:

// GraphQL resolver calling gRPC
const resolvers = {
  Query: {
    user: async (_, { id }) => {
      // Call gRPC service
      return await grpcClient.getUser({ id });
    }
  }
};

Both snippets hide the work that makes a gateway reliable. The gateway has to translate errors (a gRPC NOT_FOUND should become a REST 404 or a GraphQL error with a clear code, not a generic 500), propagate deadlines so that a client timeout cancels the downstream call, and forward tracing headers so a request can be followed across services. Type conversions also leak: a proto int64 arrives in JavaScript as a string or a Long object depending on the library options, because it does not fit in a JavaScript number. The hybrid pays off when the teams are large enough that each side benefits from its own style; for a small team, one style end to end is usually less work.


Summary

Key Summary

REST:

  • HTTP-based, resource-centric
  • Simple and excellent caching
  • Suitable for public APIs GraphQL:
  • Query language, client-centric
  • Exact data requests
  • Suitable for complex data requirements gRPC:
  • Binary protocol, high performance
  • Type safety, streaming
  • Suitable for microservices

Selection Criteria

PriorityChoice
SimplicityREST
FlexibilityGraphQL
PerformancegRPC
Public APIREST
MobileGraphQL
MicroservicesgRPC

Additional Learning Resources

Official Documentation:

Quick Decision Guide

Simple CRUD? → REST
Complex data fetching? → GraphQL
High performance internal? → gRPC
Public API? → REST
Mobile app? → GraphQL
Microservices? → gRPC
Real-time bidirectional? → gRPC or GraphQL Subscription

Frequently Asked Questions (FAQ)

Q. Can a browser call a gRPC service directly?

A. Not with standard gRPC, because browsers do not give JavaScript the low-level HTTP/2 control and trailers the protocol relies on. The usual options are gRPC-Web behind a proxy such as Envoy, a protocol like Connect that also speaks gRPC-Web, or exposing REST/GraphQL at the edge while services talk gRPC internally. That last split is the hybrid approach described above.