Skip to content
Unverified — AI-generated content. Help verify this page

REST vs GraphQL vs gRPC vs tRPC

API design is the contract between your services and your clients. The paradigm you choose affects how you model data, handle errors, manage versioning, and evolve your system. This page compares the four most relevant API paradigms across every dimension that matters.

Overview

REST

REST (Representational State Transfer) is an architectural style defined by Roy Fielding in 2000. It uses HTTP methods (GET, POST, PUT, PATCH, DELETE) to operate on resources identified by URLs. REST is not a specification — it is a set of constraints (stateless, cacheable, uniform interface) that most implementations follow loosely. REST is the dominant API style for public APIs, web services, and microservice communication. OpenAPI (Swagger) provides standardization for REST API documentation and code generation.

GraphQL

GraphQL is a query language and runtime created by Facebook in 2015. Instead of multiple endpoints returning fixed data shapes (like REST), GraphQL exposes a single endpoint. The client sends a query specifying exactly the fields it needs, and the server returns precisely that data — no more, no less. GraphQL uses a strongly typed schema (SDL) that serves as the API contract. It solves REST's overfetching and underfetching problems but introduces complexity in caching, authorization, and query performance.

gRPC

gRPC (Google Remote Procedure Call) is a high-performance RPC framework created by Google in 2015. It uses Protocol Buffers (protobuf) for serialization — a binary format that is smaller and faster than JSON — and HTTP/2 for transport, enabling multiplexing, streaming, and header compression. gRPC is the dominant choice for service-to-service communication in microservices architectures, particularly where latency and throughput matter.

tRPC

tRPC is a TypeScript-first RPC framework created by Alex Johansson in 2021. It provides end-to-end type safety between your TypeScript backend and frontend without code generation, schema files, or API specifications. You define procedures on the server, and the client gets full autocompletion and type checking through TypeScript inference. tRPC is designed for fullstack TypeScript applications where both client and server share a codebase or monorepo.

Architecture Comparison

Key Architectural Differences

REST is resource-oriented: you design your API around nouns (users, posts, comments) and use HTTP methods as verbs. Each resource has its own URL, and the server determines the response shape. This is simple and cacheable but leads to overfetching (getting fields you do not need) and underfetching (needing multiple requests for related data).

GraphQL is query-oriented: the client specifies exactly what data it wants in a declarative query. The server resolves the query by calling resolver functions for each field. This eliminates overfetching/underfetching but shifts complexity to the server (N+1 queries, query cost analysis, authorization per field).

gRPC is procedure-oriented: you define service methods and message types in .proto files, and the framework generates strongly typed client/server stubs. Communication uses binary protobuf over HTTP/2, making it significantly faster than JSON-over-HTTP. gRPC natively supports four communication patterns: unary, server streaming, client streaming, and bidirectional streaming.

tRPC is also procedure-oriented but operates entirely within the TypeScript type system. There are no schema files, no code generation, and no runtime overhead beyond standard HTTP. Type safety flows from server to client through TypeScript inference — change a server procedure's return type, and the client gets a type error immediately.

Feature Matrix

FeatureRESTGraphQLgRPCtRPC
Data formatJSON (usually)JSONProtobuf (binary)JSON
TransportHTTP/1.1 or HTTP/2HTTP (typically POST)HTTP/2HTTP or WebSocket
Schema/ContractOpenAPI (optional)SDL (required).proto (required)TypeScript types (inferred)
Type safetyOpenAPI codegen (opt-in)Schema-based (strong)Protobuf (very strong)TypeScript (end-to-end)
Code generationOptional (openapi-generator)Optional (graphql-codegen)Required (protoc)None needed
OverfetchingCommon problemSolved (client specifies fields)Solved (typed messages)Solved (typed procedures)
UnderfetchingCommon (multiple requests)Solved (nested queries)Solved (designed messages)Solved (compose procedures)
CachingHTTP caching (excellent)Complex (single endpoint)No HTTP cachingNo HTTP caching
Real-timeSSE, WebSocketSubscriptionsBidirectional streamingWebSocket subscriptions
File uploadNative (multipart)Complex (multipart spec)Streamingmultipart/form-data
StreamingSSE@stream/@deferNative (4 patterns)WebSocket
Browser supportNative (fetch)fetch + client librarygrpc-web (limited)fetch + client
Mobile supportExcellentGood (Apollo, Relay)Excellent (native clients)N/A (TypeScript only)
VersioningURL (/v1/, /v2/) or headersSchema evolution (deprecation)Package versioningTypeScript (breaking changes = type errors)
Error handlingHTTP status codeserrors array in responseStatus codes + detailsTypeScript error types
PaginationVarious (offset, cursor)Relay cursor specApplication-definedApplication-defined
Rate limitingStandard (HTTP headers)Query cost analysisApplication-levelApplication-level
Tooling maturityVery highHighHighMedium
Language supportAllAllAll (10+ languages)TypeScript only

Code Comparison

Define an API (Server)

ts
import express from 'express';

const app = express();
app.use(express.json());

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

// GET /users/:id/posts
app.get('/users/:id/posts', async (req, res) => {
  const posts = await db.posts.findMany({
    where: { authorId: req.params.id },
    take: Number(req.query.limit) || 10,
  });
  res.json(posts);
});

// POST /users
app.post('/users', async (req, res) => {
  // Manual validation required
  const { name, email } = req.body;
  const user = await db.users.create({ data: { name, email } });
  res.status(201).json(user);
});
ts
import { ApolloServer } from '@apollo/server';

const typeDefs = `#graphql
  type User {
    id: ID!
    name: String!
    email: String!
    posts(limit: Int = 10): [Post!]!
  }

  type Post {
    id: ID!
    title: String!
    content: String
    author: User!
  }

  type Query {
    user(id: ID!): User
  }

  type Mutation {
    createUser(name: String!, email: String!): User!
  }
`;

const resolvers = {
  Query: {
    user: (_, { id }) => db.users.findUnique({ where: { id } }),
  },
  User: {
    posts: (parent, { limit }) =>
      db.posts.findMany({
        where: { authorId: parent.id },
        take: limit,
      }),
  },
  Mutation: {
    createUser: (_, { name, email }) =>
      db.users.create({ data: { name, email } }),
  },
};

const server = new ApolloServer({ typeDefs, resolvers });
protobuf
// user.proto
syntax = "proto3";
package user;

service UserService {
  rpc GetUser (GetUserRequest) returns (User);
  rpc GetUserPosts (GetUserPostsRequest) returns (PostList);
  rpc CreateUser (CreateUserRequest) returns (User);
}

message GetUserRequest {
  string id = 1;
}

message GetUserPostsRequest {
  string user_id = 1;
  int32 limit = 2;
}

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

message User {
  string id = 1;
  string name = 2;
  string email = 3;
}

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

message PostList {
  repeated Post posts = 1;
}
ts
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

export const appRouter = t.router({
  user: t.router({
    getById: t.procedure
      .input(z.object({ id: z.string() }))
      .query(async ({ input }) => {
        const user = await db.users.findUnique({
          where: { id: input.id },
        });
        if (!user) throw new TRPCError({ code: 'NOT_FOUND' });
        return user;
      }),

    getPosts: t.procedure
      .input(z.object({
        userId: z.string(),
        limit: z.number().default(10),
      }))
      .query(async ({ input }) => {
        return db.posts.findMany({
          where: { authorId: input.userId },
          take: input.limit,
        });
      }),

    create: t.procedure
      .input(z.object({
        name: z.string().min(1),
        email: z.string().email(),
      }))
      .mutation(async ({ input }) => {
        return db.users.create({ data: input });
      }),
  }),
});

export type AppRouter = typeof appRouter;

Consume the API (Client)

ts
// No type safety without code generation
const res = await fetch('/api/users/1');
const user = await res.json(); // user is `any`

const postsRes = await fetch('/api/users/1/posts?limit=5');
const posts = await postsRes.json(); // posts is `any`

// Two separate requests for user + posts
// Client cannot control which fields are returned
ts
// With graphql-codegen for type safety
const { data } = await client.query({
  query: gql`
    query GetUser($id: ID!) {
      user(id: $id) {
        name
        email
        posts(limit: 5) {
          title
          content
        }
      }
    }
  `,
  variables: { id: '1' },
});

// Single request, exact fields requested
// data.user.name, data.user.posts[0].title — typed with codegen
ts
// Generated client stub — fully typed
const client = new UserServiceClient('localhost:50051', credentials);

const user = await client.getUser({ id: '1' });
console.log(user.name); // Typed from .proto

const posts = await client.getUserPosts({ userId: '1', limit: 5 });
console.log(posts.posts[0].title); // Typed from .proto
ts
// Zero-codegen type safety — just import the type
import type { AppRouter } from '../server/router';
import { createTRPCClient } from '@trpc/client';

const client = createTRPCClient<AppRouter>({ /* config */ });

// Full autocompletion and type checking
const user = await client.user.getById.query({ id: '1' });
console.log(user.name); // Typed — change server, get type error here

const posts = await client.user.getPosts.query({ userId: '1', limit: 5 });
console.log(posts[0].title); // Typed

tRPC's type safety advantage

With tRPC, you change a return type on the server and the client gets a TypeScript error immediately — no code generation step, no schema sync, no runtime validation. This tight coupling is a feature when both sides are in the same codebase. It is a limitation when they are not (which is why tRPC is for fullstack TypeScript only).

Performance

Serialization and Transfer Size

MetricREST (JSON)GraphQL (JSON)gRPC (Protobuf)tRPC (JSON)
Serialization speedMedium (JSON.stringify)Medium (JSON.stringify)Fast (binary encode)Medium (JSON.stringify)
Deserialization speedMedium (JSON.parse)Medium (JSON.parse)Fast (binary decode)Medium (JSON.parse)
Payload size (user object)256 bytes180 bytes (queried fields)95 bytes (binary)256 bytes
Payload size (1000 items)250 KB150 KB (queried fields)80 KB (binary)250 KB

Throughput Benchmarks

ScenarioRESTGraphQLgRPCtRPC
Simple query (req/s)45,00030,00085,00040,000
Complex query (req/s)20,0008,00060,00018,000
Streaming (msg/s)N/A (SSE: 10,000)5,000 (subscriptions)50,00015,000 (WS)
Latency (p50)1.2ms3.5ms0.5ms1.5ms
Latency (p99)5ms15ms2ms6ms

GraphQL's performance cost

GraphQL is slower than REST for simple queries because of query parsing, validation, and resolver execution overhead. GraphQL shines when it reduces the number of network round-trips (fetching user + posts + comments in one query vs. three REST calls). The performance tradeoff is worth it for complex, deeply nested data requirements, not for simple CRUD.

Caching Comparison

Caching StrategyRESTGraphQLgRPCtRPC
HTTP caching (CDN, browser)Excellent (GET + Cache-Control)Poor (single POST endpoint)Not applicablePoor (POST-based)
Server-side cachingStandard (Redis, etc.)DataLoader patternStandardStandard
Client-side cachingManual (SWR, React Query)Normalized cache (Apollo, Relay)ManualReact Query integration
Persisted queriesN/AYes (reduces bandwidth)N/AN/A

Developer Experience

Learning Curve

AspectRESTGraphQLgRPCtRPC
Time to first API10 min30 min (schema + resolvers)45 min (proto + codegen)15 min
Concept countLow (HTTP methods, URLs)Medium (schema, resolvers, queries)Medium (proto, services, streams)Low (procedures, Zod)
DocumentationAbundant (decades)Good (graphql.org)Good (grpc.io)Good (trpc.io)
Debuggingcurl, Postman (easy)GraphQL Playground, Apollo Studiogrpcurl, EvanstRPC panel
Error debuggingClear (HTTP status + body)Confusing (200 with errors)Status codes + metadataClear (typed errors)

Tooling

CategoryRESTGraphQLgRPCtRPC
API documentationOpenAPI/Swagger UIGraphQL PlaygroundProto documentationTypeScript types
Client generationopenapi-generatorgraphql-codegenprotoc / bufNone needed
ValidationManual or OpenAPISchema-enforcedProto-enforcedZod (runtime)
TestingPostman, InsomniaPlayground, Apollo Studiogrpcurl, PostmantRPC panel
MonitoringStandard APMApollo StudiogRPC dashboardsStandard APM
Mock serverPrism (OpenAPI)Apollo MockedProvidergrpc-mockMSW

Team Scalability

AspectRESTGraphQLgRPCtRPC
Multi-team (diff languages)Excellent (universal)Good (schema as contract)Excellent (proto as contract)Poor (TypeScript only)
Frontend-backend collaborationModerate (API design meetings)Good (schema-driven)Good (proto-driven)Excellent (shared types)
Breaking change detectionManual (OpenAPI diff)Schema diff toolsBuf breaking change detectionTypeScript compiler
API versioningURL or header versioningSchema evolutionPackage versioningType changes

When to Use Which

Decision Summary

ScenarioBest ChoiceWhy
Public APIRESTUniversal, cacheable, well-understood
Fullstack TypeScript apptRPCEnd-to-end types, zero overhead
Mobile app with complex dataGraphQLClient controls data shape, reduces requests
Microservice-to-microservicegRPCBinary protocol, streaming, multi-language
Content-heavy site (CMS)GraphQL or RESTFlexible content queries
Real-time datagRPC (streaming) or GraphQL (subscriptions)Native streaming support
Simple CRUD APIREST or tRPCDo not over-engineer
Multi-language microservicesgRPCProto as universal contract
High-throughput data pipelinegRPCBinary serialization, HTTP/2 multiplexing
Third-party integrationsRESTOpenAPI is the industry standard

Migration

REST to GraphQL

  1. Define schema: Map REST resources to GraphQL types
  2. Create resolvers: REST endpoints become resolver functions (often calling the same service layer)
  3. Run both: Serve REST and GraphQL simultaneously during migration
  4. Migrate clients: Update frontend to use GraphQL queries
  5. Deprecate REST endpoints: Remove once all clients have migrated
ts
// REST endpoint
app.get('/users/:id', async (req, res) => {
  const user = await userService.getById(req.params.id);
  res.json(user);
});

// Equivalent GraphQL resolver (reuses same service)
const resolvers = {
  Query: {
    user: (_, { id }) => userService.getById(id),
  },
};

REST to tRPC

  1. Install tRPC: Add @trpc/server and @trpc/client
  2. Define procedures: Map REST endpoints to tRPC procedures
  3. Add Zod schemas: Replace manual validation with Zod input schemas
  4. Export router type: export type AppRouter = typeof appRouter
  5. Create typed client: Import AppRouter type on the frontend
  6. Migrate routes: Replace fetch('/api/...') calls with trpc.procedure.query()
ts
// Before (REST client)
const res = await fetch(`/api/users/${id}`);
const user: any = await res.json();

// After (tRPC client)
const user = await trpc.user.getById.query({ id });
// user is fully typed — no `any`, no manual type assertion

GraphQL to tRPC

  1. Map types: GraphQL types become TypeScript types (already inferred by tRPC)
  2. Map queries: GraphQL queries become tRPC query procedures
  3. Map mutations: GraphQL mutations become tRPC mutation procedures
  4. Replace subscriptions: GraphQL subscriptions become tRPC WebSocket subscriptions
  5. Remove codegen: tRPC does not need graphql-codegen

When NOT to migrate

Do not migrate a public REST API to GraphQL or tRPC — REST is the universal standard for public APIs. Do not migrate multi-language microservices from gRPC to tRPC — tRPC only works in TypeScript. Do not migrate a working GraphQL API with multiple clients to tRPC — tRPC is for single-client or same-monorepo scenarios.

Verdict

Choose REST for public APIs, third-party integrations, or any API that needs to be consumed by unknown clients in unknown languages. REST is the universal standard — every language, every platform, every tool understands it. HTTP caching makes REST the most performant choice for read-heavy, cacheable data. Use OpenAPI for documentation and code generation to add type safety.

Choose GraphQL when you have complex, deeply nested data that multiple different clients (web, mobile, third-party) consume with different data requirements. GraphQL's client-driven queries eliminate the need to build custom REST endpoints for each client's data shape. The tradeoff is complexity in caching, authorization, and query performance — use GraphQL when these tradeoffs are justified, not as a default.

Choose gRPC for service-to-service communication in microservices architectures where latency and throughput matter. gRPC's binary serialization, HTTP/2 multiplexing, and streaming support make it 2-5x faster than JSON-over-HTTP. Proto files serve as a language-neutral API contract. gRPC is the standard for high-performance internal APIs.

Choose tRPC for fullstack TypeScript applications where both the client and server are in the same codebase or monorepo. tRPC provides the best developer experience of any API paradigm — zero code generation, instant type errors across the stack, and autocompletion that makes API calls feel like local function calls. The tradeoff is that tRPC only works in TypeScript and is not suitable for public or multi-language APIs.

Which Would You Choose?

Scenario 1: You are building a public API that third-party developers will integrate with. You expect consumers using Python, Go, Java, and JavaScript. Documentation and discoverability are critical.

Recommendation: REST with OpenAPI

REST is the universal standard for public APIs. Every language has HTTP client libraries, every developer understands REST conventions, and OpenAPI provides standardized documentation, code generation, and testing tools. GraphQL and tRPC add complexity that external consumers should not need to learn.

Scenario 2: You are building a fullstack TypeScript app (Next.js frontend + Node.js backend) in a monorepo. The team is 4 TypeScript developers. You want the tightest possible type safety between frontend and backend.

Recommendation: tRPC

tRPC gives you end-to-end type safety without code generation, schema files, or any runtime overhead. Change a return type on the server and the frontend gets a TypeScript error immediately. For a TypeScript monorepo where both sides share a codebase, nothing beats this developer experience.

Scenario 3: Your microservices architecture has 20 services written in Go, Java, Python, and TypeScript. Services communicate heavily with each other, and latency between services is a bottleneck.

Recommendation: gRPC

gRPC's binary protobuf serialization is 2-5x faster than JSON, HTTP/2 multiplexing eliminates head-of-line blocking, and .proto files serve as a language-neutral API contract. For high-throughput service-to-service communication across multiple languages, gRPC is the industry standard.

Scenario 4: Your mobile app (iOS + Android) needs to display deeply nested data — a user profile with posts, comments, reactions, and follower counts. Each screen needs a different subset of this data.

Recommendation: GraphQL

GraphQL lets each mobile screen request exactly the fields it needs in a single query, eliminating overfetching (wasted bandwidth on mobile networks) and underfetching (multiple round-trips that add latency). The normalized client cache (Apollo, Relay) keeps data consistent across screens.

Common Misconceptions

  • "GraphQL is always better than REST" — GraphQL adds complexity (query parsing, resolver orchestration, N+1 problems, cache invalidation) that is not justified for simple CRUD APIs. For most backends with <20 endpoints, REST is simpler and faster.
  • "REST cannot be type-safe" — REST + OpenAPI + code generation provides strong type safety. Tools like openapi-typescript generate TypeScript types from OpenAPI specs automatically.
  • "gRPC cannot be used in browsers"grpc-web enables browser-to-server gRPC calls, though it requires a proxy (Envoy) to translate HTTP/2 gRPC to HTTP/1.1. For browser clients, REST or GraphQL is still simpler.
  • "tRPC replaces REST" — tRPC is for TypeScript-only, same-codebase scenarios. It cannot serve public APIs, mobile clients in Swift/Kotlin, or services in other languages.

Real Migration Stories

GitHub: REST to GraphQL (API v4) — GitHub launched their GraphQL API in 2016 alongside their REST API v3. The motivation was that mobile clients needed different data shapes than web clients, and building custom REST endpoints for each was unsustainable. Both APIs still coexist — REST for simple integrations, GraphQL for complex queries.

Netflix: REST to gRPC for microservices — Netflix migrated internal service communication from REST to gRPC for latency-sensitive paths. The binary protocol reduced serialization overhead, and HTTP/2 multiplexing improved throughput for their heavily interconnected microservice architecture.

Quiz

1. Why is HTTP caching easy with REST but hard with GraphQL?

REST uses HTTP GET requests with unique URLs (/users/1), which CDNs and browsers cache natively via Cache-Control headers. GraphQL uses a single POST endpoint (/graphql), so every query goes to the same URL — CDNs cannot cache based on URL alone. GraphQL caching requires persisted queries or application-level solutions.

2. What is the N+1 problem in GraphQL, and how is it solved?

When resolving a list of users with their posts, a naive GraphQL resolver makes 1 query for users and N queries for each user's posts (N+1 total). DataLoader batches these into 2 queries: one for users, one for all posts matching those user IDs.

3. How does tRPC achieve type safety without code generation?

tRPC exports the server router type (export type AppRouter = typeof appRouter). The client imports this type and uses TypeScript's type inference to infer input/output types for every procedure. No code generation, no schema files — just TypeScript's type system.

4. What are the four communication patterns in gRPC?

Unary (request-response), server streaming (client sends one request, server streams multiple responses), client streaming (client streams multiple requests, server sends one response), and bidirectional streaming (both sides stream simultaneously).

5. When should you NOT migrate from REST to GraphQL?

When your API is public and consumed by third-party developers (REST is universal), when your data is flat and simple (no overfetching problem to solve), when HTTP caching is critical for performance, or when your team does not have GraphQL expertise.

One-Liner Summary

REST is the universal standard for public APIs, GraphQL solves overfetching for complex client data needs, gRPC is the fastest protocol for service-to-service communication, and tRPC delivers unmatched type safety for TypeScript monorepos.

"What I cannot create, I do not understand." — Richard Feynman