API Paradigms: REST vs GraphQL vs RPC
Choosing an API paradigm shapes how clients interact with your services, what tooling you'll use, and how your system scales. This article compares the three dominant paradigms—REST, GraphQL, and RPC—with concrete examples, trade-offs, and decision criteria.
REST (Representational State Transfer)
REST is the most widely adopted API style for web services. It models your API as a collection of resources (nouns) that clients interact with using standard HTTP methods (verbs).
Core Principles
- Resources: Everything is a resource with a unique URL (
/users/123,/posts/456) - HTTP Methods:
GETreads,POSTcreates,PUT/PATCHupdates,DELETEremoves - Stateless: Each request contains all information needed to process it
- Cacheable: Responses can be cached using standard HTTP headers
REST Example
A typical REST API for a blog platform:
// src/routes/posts.ts (Express/Fastify style)
import { Router } from 'express';
import { z } from 'zod';
const router = Router();
// GET /posts - List all posts
router.get('/posts', async (req, res) => {
const { limit = 20, cursor } = req.query;
const posts = await db.post.findMany({
take: Number(limit) + 1,
cursor: cursor ? { id: String(cursor) } : undefined,
orderBy: { createdAt: 'desc' },
include: { author: { select: { id: true, name: true } } },
});
const hasMore = posts.length > Number(limit);
const data = hasMore ? posts.slice(0, -1) : posts;
res.json({
data,
nextCursor: hasMore ? data[data.length - 1].id : null,
});
});
// GET /posts/:id - Get single post
router.get('/posts/:id', async (req, res) => {
const post = await db.post.findUnique({
where: { id: req.params.id },
include: {
author: true,
comments: { include: { author: true } },
},
});
if (!post) {
return res.status(404).json({
error: { code: 'NOT_FOUND', message: 'Post not found' },
});
}
res.json({ data: post });
});
// POST /posts - Create new post
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
published: z.boolean().default(false),
});
router.post('/posts', async (req, res) => {
const parsed = createPostSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid request body',
details: parsed.error.flatten().fieldErrors,
},
});
}
const post = await db.post.create({
data: {
...parsed.data,
authorId: req.user.id,
},
});
res.status(201).json({ data: post });
});
// PUT /posts/:id - Update post (full replacement)
router.put('/posts/:id', async (req, res) => {
const parsed = createPostSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: { code: 'VALIDATION_ERROR', message: 'Invalid request body' },
});
}
const post = await db.post.update({
where: { id: req.params.id },
data: parsed.data,
});
res.json({ data: post });
});
// DELETE /posts/:id - Delete post
router.delete('/posts/:id', async (req, res) => {
await db.post.delete({ where: { id: req.params.id } });
res.status(204).send();
});
export default router;
When to Use REST
Choose REST when:
- Building public APIs that external developers will consume
- Your data model maps cleanly to CRUD operations
- Caching is important (CDNs, browser caching work out of the box)
- You want maximum compatibility—every HTTP client works with REST
- Your team is familiar with REST conventions
REST strengths:
- Universal tooling support
- Built-in caching via HTTP headers
- Easy to understand and debug (just use curl)
- Well-defined status codes communicate intent
- Works with any client, any language
REST weaknesses:
- Over-fetching: Endpoints return fixed shapes, even if you only need a few fields
- Under-fetching: Getting related data often requires multiple requests
- No built-in type safety between client and server
- Versioning can be awkward as APIs evolve
GraphQL
GraphQL is a query language that lets clients request exactly the data they need. Instead of multiple endpoints, you have a single endpoint with a typed schema.
Core Concepts
- Schema: Defines all types, queries, and mutations available
- Queries: Read operations—client specifies exact fields needed
- Mutations: Write operations—create, update, delete
- Resolvers: Functions that fetch data for each field
GraphQL Example
The same blog API in GraphQL:
# schema.graphql
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String!
published: Boolean!
createdAt: DateTime!
author: User!
comments: [Comment!]!
}
type Comment {
id: ID!
body: String!
author: User!
post: Post!
}
type Query {
posts(limit: Int = 20, cursor: String): PostConnection!
post(id: ID!): Post
user(id: ID!): User
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
}
input CreatePostInput {
title: String!
content: String!
published: Boolean = false
}
input UpdatePostInput {
title: String
content: String
published: Boolean
}
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
Server implementation with GraphQL Yoga (works with Node.js, Bun, Deno):
// src/schema/resolvers.ts
import { createSchema, createYoga } from 'graphql-yoga';
import { db } from '../lib/db';
const resolvers = {
Query: {
posts: async (_, { limit = 20, cursor }) => {
const posts = await db.post.findMany({
take: limit + 1,
cursor: cursor ? { id: cursor } : undefined,
orderBy: { createdAt: 'desc' },
});
const hasNextPage = posts.length > limit;
const edges = (hasNextPage ? posts.slice(0, -1) : posts).map(post => ({
node: post,
cursor: post.id,
}));
return {
edges,
pageInfo: {
hasNextPage,
endCursor: edges[edges.length - 1]?.cursor ?? null,
},
};
},
post: (_, { id }) => db.post.findUnique({ where: { id } }),
user: (_, { id }) => db.user.findUnique({ where: { id } }),
},
Mutation: {
createPost: (_, { input }, ctx) => {
if (!ctx.user) throw new Error('Unauthorized');
return db.post.create({
data: { ...input, authorId: ctx.user.id },
});
},
updatePost: async (_, { id, input }, ctx) => {
const post = await db.post.findUnique({ where: { id } });
if (post?.authorId !== ctx.user?.id) {
throw new Error('Forbidden');
}
return db.post.update({ where: { id }, data: input });
},
deletePost: async (_, { id }, ctx) => {
const post = await db.post.findUnique({ where: { id } });
if (post?.authorId !== ctx.user?.id) {
throw new Error('Forbidden');
}
await db.post.delete({ where: { id } });
return true;
},
},
// Field resolvers for relationships
Post: {
author: (post) => db.user.findUnique({ where: { id: post.authorId } }),
comments: (post) => db.comment.findMany({ where: { postId: post.id } }),
},
Comment: {
author: (comment) => db.user.findUnique({ where: { id: comment.authorId } }),
post: (comment) => db.post.findUnique({ where: { id: comment.postId } }),
},
User: {
posts: (user) => db.post.findMany({ where: { authorId: user.id } }),
},
};
// src/server.ts
import { createYoga, createSchema } from 'graphql-yoga';
import { typeDefs } from './schema/typeDefs';
import { resolvers } from './schema/resolvers';
const yoga = createYoga({
schema: createSchema({ typeDefs, resolvers }),
context: ({ request }) => ({
user: getUserFromRequest(request), // Your auth logic
}),
});
// Works with any server - Bun, Node, Deno
Bun.serve({
port: 4000,
fetch: yoga.fetch,
});
Client-side query:
// Client requests exactly what it needs
const query = `
query GetPostWithComments($id: ID!) {
post(id: $id) {
id
title
content
author {
name
}
comments {
body
author {
name
}
}
}
}
`;
const response = await fetch('http://localhost:4000/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
variables: { id: '123' },
}),
});
const { data, errors } = await response.json();
When to Use GraphQL
Choose GraphQL when:
- Clients have varied data requirements (mobile vs web, different views)
- Your data is highly relational with complex nested queries
- You want to avoid multiple round trips for related data
- Frontend teams need flexibility without backend changes
- You're building a BFF (Backend for Frontend) aggregating multiple services
GraphQL strengths:
- No over-fetching—clients get exactly what they request
- Single request for complex, nested data
- Strong typing with introspection and tooling
- Self-documenting via schema
- Excellent developer experience with GraphiQL/Apollo Studio
GraphQL weaknesses:
- Caching is harder (no URL-based caching, need Apollo Client or similar)
- N+1 query problem requires dataloaders to solve
- All requests are POST, harder to debug with browser
- Complexity overhead for simple CRUD
- Potential for expensive queries (need query cost analysis)
Solving N+1 with DataLoaders
Without DataLoader, fetching 20 posts with authors makes 21 database queries:
// BAD: N+1 queries
Post: {
author: (post) => db.user.findUnique({ where: { id: post.authorId } }),
}
DataLoader batches and caches these calls:
// src/lib/dataloaders.ts
import DataLoader from 'dataloader';
export const createLoaders = () => ({
userLoader: new DataLoader(async (ids: readonly string[]) => {
const users = await db.user.findMany({
where: { id: { in: [...ids] } },
});
// Must return in same order as input IDs
const userMap = new Map(users.map(u => [u.id, u]));
return ids.map(id => userMap.get(id) ?? null);
}),
});
// In context
const yoga = createYoga({
context: () => ({
loaders: createLoaders(),
}),
});
// In resolver
Post: {
author: (post, _, ctx) => ctx.loaders.userLoader.load(post.authorId),
}
Now fetching 20 posts with authors makes 2 queries: one for posts, one for all authors.
RPC (Remote Procedure Call)
RPC treats API calls as function calls. Instead of resources and HTTP methods, you have procedures (actions) that execute on the server. Modern TypeScript stacks often use tRPC for end-to-end type safety without code generation.
tRPC Example
tRPC shares types between server and client in TypeScript monorepos:
// packages/api/src/router.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.context<{ user: User | null }>().create();
const publicProcedure = t.procedure;
const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { user: ctx.user } });
});
export const appRouter = t.router({
// Queries (read operations)
posts: t.router({
list: publicProcedure
.input(z.object({
limit: z.number().min(1).max(100).default(20),
cursor: z.string().optional(),
}))
.query(async ({ input }) => {
const posts = await db.post.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' },
include: { author: { select: { id: true, name: true } } },
});
const hasMore = posts.length > input.limit;
return {
posts: hasMore ? posts.slice(0, -1) : posts,
nextCursor: hasMore ? posts[posts.length - 2].id : null,
};
}),
byId: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input }) => {
const post = await db.post.findUnique({
where: { id: input.id },
include: { author: true, comments: { include: { author: true } } },
});
if (!post) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Post not found' });
}
return post;
}),
}),
// Mutations (write operations)
createPost: protectedProcedure
.input(z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
published: z.boolean().default(false),
}))
.mutation(async ({ input, ctx }) => {
return db.post.create({
data: { ...input, authorId: ctx.user.id },
});
}),
updatePost: protectedProcedure
.input(z.object({
id: z.string().uuid(),
title: z.string().min(1).max(200).optional(),
content: z.string().min(1).optional(),
published: z.boolean().optional(),
}))
.mutation(async ({ input, ctx }) => {
const post = await db.post.findUnique({ where: { id: input.id } });
if (post?.authorId !== ctx.user.id) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
const { id, ...data } = input;
return db.post.update({ where: { id }, data });
}),
deletePost: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ input, ctx }) => {
const post = await db.post.findUnique({ where: { id: input.id } });
if (post?.authorId !== ctx.user.id) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
await db.post.delete({ where: { id: input.id } });
return { success: true };
}),
});
export type AppRouter = typeof appRouter;
Client usage with full type safety:
// apps/web/src/lib/trpc.ts
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@acme/api';
export const trpc = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/trpc',
}),
],
});
// Usage - fully typed, autocomplete works
const { posts, nextCursor } = await trpc.posts.list.query({ limit: 10 });
const newPost = await trpc.createPost.mutate({
title: 'Hello World',
content: 'This is my first post',
});
// TypeScript errors if you pass wrong types
await trpc.createPost.mutate({
title: 123, // Error: Type 'number' is not assignable to type 'string'
});
gRPC for Microservices
gRPC uses Protocol Buffers for binary serialization. It's ideal for high-performance internal communication between services.
// proto/posts.proto
syntax = "proto3";
package posts;
service PostService {
rpc ListPosts(ListPostsRequest) returns (ListPostsResponse);
rpc GetPost(GetPostRequest) returns (Post);
rpc CreatePost(CreatePostRequest) returns (Post);
rpc DeletePost(DeletePostRequest) returns (Empty);
}
message Post {
string id = 1;
string title = 2;
string content = 3;
bool published = 4;
string author_id = 5;
int64 created_at = 6;
}
message ListPostsRequest {
int32 limit = 1;
string cursor = 2;
}
message ListPostsResponse {
repeated Post posts = 1;
string next_cursor = 2;
}
message GetPostRequest {
string id = 1;
}
message CreatePostRequest {
string title = 1;
string content = 2;
bool published = 3;
}
message DeletePostRequest {
string id = 1;
}
message Empty {}
When to Use RPC
Choose tRPC when:
- Full-stack TypeScript monorepo
- Same team owns client and server
- Type safety is a priority
- You want the best developer experience
Choose gRPC when:
- High-performance microservices communication
- Polyglot environment (services in different languages)
- Streaming is needed (bidirectional, server-side, client-side)
- Binary protocol efficiency matters
RPC strengths:
- Type safety without code generation (tRPC)
- Excellent performance (gRPC)
- Actions are explicit, not mapped to HTTP semantics
- Built-in streaming support (gRPC)
RPC weaknesses:
- tRPC requires TypeScript on both ends
- gRPC requires proto compilation
- Harder to debug (binary protocol)
- Less tooling for external documentation
- Not suitable for public APIs
Comparison Matrix
| Factor | REST | GraphQL | tRPC | gRPC |
|---|---|---|---|---|
| Best for | Public APIs | Complex frontends | TS monorepos | Microservices |
| Type safety | Manual | Schema-based | Built-in | Proto-based |
| Caching | HTTP native | Complex | Manual | Manual |
| Learning curve | Low | Medium | Low* | Medium |
| Browser support | Native | Native | Native | Requires proxy |
| Tooling | Excellent | Excellent | Good | Good |
| Debugging | Easy (curl) | Medium | Easy | Hard (binary) |
| Performance | Good | Good | Good | Excellent |
| Over-fetching | Yes | No | Depends | No |
| Code generation | Optional | Optional | None | Required |
*Low if you already know TypeScript
Decision Framework
Use this flowchart to pick a paradigm:
Is this a public API?
├── Yes → REST
│ - Universal compatibility
│ - Easy to document
│ - Standard HTTP caching
│
└── No → Internal service
│
├── Full-stack TypeScript?
│ └── Yes → tRPC
│ - Best DX, zero code gen
│ - Type errors at compile time
│
├── Complex relational data?
│ └── Yes → GraphQL
│ - Client-driven queries
│ - Single request for nested data
│
└── High-performance polyglot?
└── Yes → gRPC
- Binary protocol, streaming
- Multi-language support
Hybrid Approaches
Real systems often combine paradigms:
┌─────────────────────────────────────────────────────────────┐
│ Public API │
│ (REST) │
└─────────────────────────┬───────────────────────────────────┘
│
┌─────────────────────────▼───────────────────────────────────┐
│ API Gateway / BFF │
│ (GraphQL) │
└───────┬─────────────────┼─────────────────────┬─────────────┘
│ │ │
┌───────▼──────┐ ┌───────▼──────┐ ┌───────────▼──────────┐
│ User Service │ │ Post Service │ │ Notification Service │
│ (gRPC) │ │ (gRPC) │ │ (gRPC) │
└──────────────┘ └──────────────┘ └──────────────────────┘
- Public-facing: REST for maximum compatibility
- Frontend aggregation: GraphQL for flexible queries
- Service-to-service: gRPC for performance
See Also
- HTTP Best Practices — Status codes, versioning, pagination
- Authentication — OAuth, sessions, JWTs
- Node.js article — Server implementations
- TypeScript article — Type system foundations
- tRPC documentation
- GraphQL specification