Skip to main content

5 - REST vs GraphQL vs tRPC: When to Use What

In-depth comparison between REST, GraphQL and tRPC: performance, developer experience, security, caching and decision framework.

What do you think about this article?

At a Glance

In-depth comparison between REST, GraphQL and tRPC: performance, developer experience, security, caching and decision framework.

REST vs GraphQL vs tRPC: Which One to Choose?

Choosing the communication paradigm between client and server is one of the most important architectural decisions in a modern project. REST, GraphQL, and tRPC represent three radically different approaches, each with its own philosophy, strengths, and trade-offs.

In this article, we will analyze each approach in depth, compare their characteristics in a detailed table, and build a decision framework to help you choose the right one for your next project.

What You Will Learn

  • REST fundamentals and Richardson's maturity model
  • GraphQL schema, resolvers, and codegen
  • tRPC's end-to-end type safety
  • Detailed comparison: performance, DX, caching, security
  • A decision framework for choosing
  • Practical code examples for each approach

REST: The Reliable Veteran

REST (Representational State Transfer) is the most widespread architectural style for web APIs. Defined by Roy Fielding in his doctoral dissertation in 2000, it is based on resources identified by URLs, standard HTTP methods, and stateless representations.

Richardson's Maturity Model

Leonard Richardson defined four maturity levels for REST APIs:

  • Level 0 - The Tunnel: A single endpoint that accepts everything (RPC-over-HTTP style)
  • Level 1 - Resources: Different endpoints for different resources, but only one HTTP method
  • Level 2 - HTTP Verbs: Proper use of GET, POST, PUT, DELETE, and status codes
  • Level 3 - HATEOAS: Responses contain links to subsequent actions (hypermedia)

REST Example in TypeScript

REST Server with Express and TypeScript

import express from 'express';

interface Product {
  id: string;
  name: string;
  price: number;
  category: string;
  inStock: boolean;
}

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

// GET /api/products - Product list with filters
app.get('/api/products', async (req, res) => {
  const { category, minPrice, maxPrice, page = '1', limit = '20' } = req.query;

  const filters: ProductFilters = {
    category: category as string,
    minPrice: minPrice ? Number(minPrice) : undefined,
    maxPrice: maxPrice ? Number(maxPrice) : undefined
  };

  const products = await productService.findAll(
    filters,
    Number(page),
    Number(limit)
  );

  res.json({
    data: products.items,
    pagination: {
      page: products.page,
      limit: products.limit,
      total: products.total,
      totalPages: Math.ceil(products.total / products.limit)
    }
  });
});

// GET /api/products/:id - Product detail
app.get('/api/products/:id', async (req, res) => {
  const product = await productService.findById(req.params.id);
  if (!product) {
    return res.status(404).json({
      error: 'NOT_FOUND',
      message: 'Product not found'
    });
  }
  res.json({ data: product });
});

// POST /api/products - Create product
app.post('/api/products', async (req, res) => {
  const product = await productService.create(req.body);
  res.status(201).json({ data: product });
});

// PUT /api/products/:id - Update product
app.put('/api/products/:id', async (req, res) => {
  const product = await productService.update(req.params.id, req.body);
  res.json({ data: product });
});

// DELETE /api/products/:id - Delete product
app.delete('/api/products/:id', async (req, res) => {
  await productService.delete(req.params.id);
  res.status(204).send();
});
    
REST Client with Angular HttpClient

@Injectable({ providedIn: 'root' })
export class ProductApiService {
  private readonly http = inject(HttpClient);
  private readonly baseUrl = '/api/products';

  getProducts(filters?: ProductFilters): Observable<PaginatedResponse<Product>> {
    const params = this.buildParams(filters);
    return this.http.get<PaginatedResponse<Product>>(this.baseUrl, { params });
  }

  getProduct(id: string): Observable<Product> {
    return this.http.get<Product>(this.baseUrl + '/' + id);
  }

  createProduct(data: CreateProductDto): Observable<Product> {
    return this.http.post<Product>(this.baseUrl, data);
  }

  updateProduct(id: string, data: UpdateProductDto): Observable<Product> {
    return this.http.put<Product>(this.baseUrl + '/' + id, data);
  }

  deleteProduct(id: string): Observable<void> {
    return this.http.delete<void>(this.baseUrl + '/' + id);
  }

  private buildParams(filters?: ProductFilters): HttpParams {
    let params = new HttpParams();
    if (filters?.category) params = params.set('category', filters.category);
    if (filters?.minPrice) params = params.set('minPrice', filters.minPrice.toString());
    if (filters?.maxPrice) params = params.set('maxPrice', filters.maxPrice.toString());
    return params;
  }
}
    

GraphQL: Total Flexibility

GraphQL, created by Facebook in 2012 and publicly released in 2015, offers a completely different approach: the client specifies exactly which data it wants to receive. This eliminates the problems of over-fetching (receiving too much data) and under-fetching (needing to make multiple requests to get all necessary data).

Schema and Type System

GraphQL Schema

# schema.graphql
type Product {
  id: ID!
  name: String!
  price: Float!
  category: Category!
  inStock: Boolean!
  reviews: [Review!]!
  averageRating: Float
}

type Category {
  id: ID!
  name: String!
  products: [Product!]!
}

type Review {
  id: ID!
  author: String!
  rating: Int!
  comment: String
  createdAt: String!
}

type PaginatedProducts {
  items: [Product!]!
  total: Int!
  hasNextPage: Boolean!
}

input ProductFilter {
  category: String
  minPrice: Float
  maxPrice: Float
  inStock: Boolean
}

type Query {
  products(filter: ProductFilter, page: Int, limit: Int): PaginatedProducts!
  product(id: ID!): Product
  categories: [Category!]!
}

type Mutation {
  createProduct(input: CreateProductInput!): Product!
  updateProduct(id: ID!, input: UpdateProductInput!): Product!
  deleteProduct(id: ID!): Boolean!
}

input CreateProductInput {
  name: String!
  price: Float!
  categoryId: ID!
}

input UpdateProductInput {
  name: String
  price: Float
  categoryId: ID
  inStock: Boolean
}
    

Resolvers and Server

GraphQL Resolver with Apollo Server

const resolvers = {
  Query: {
    products: async (_, { filter, page = 1, limit = 20 }, context) => {
      const result = await context.productService.findAll(filter, page, limit);
      return {
        items: result.items,
        total: result.total,
        hasNextPage: page * limit < result.total
      };
    },

    product: async (_, { id }, context) => {
      return context.productService.findById(id);
    },

    categories: async (_, __, context) => {
      return context.categoryService.findAll();
    }
  },

  Product: {
    // Resolver for relational fields (loaded only when requested)
    reviews: async (product, _, context) => {
      return context.reviewService.findByProductId(product.id);
    },

    averageRating: async (product, _, context) => {
      const reviews = await context.reviewService.findByProductId(product.id);
      if (reviews.length === 0) return null;
      const sum = reviews.reduce((acc, r) => acc + r.rating, 0);
      return sum / reviews.length;
    },

    category: async (product, _, context) => {
      return context.categoryService.findById(product.categoryId);
    }
  },

  Mutation: {
    createProduct: async (_, { input }, context) => {
      return context.productService.create(input);
    },

    updateProduct: async (_, { id, input }, context) => {
      return context.productService.update(id, input);
    },

    deleteProduct: async (_, { id }, context) => {
      await context.productService.delete(id);
      return true;
    }
  }
};
    

Client Queries

GraphQL queries from the client

// The client requests EXACTLY the fields it needs
const GET_PRODUCT_LIST = gql`
  query GetProducts($filter: ProductFilter, $page: Int) {
    products(filter: $filter, page: $page, limit: 20) {
      items {
        id
        name
        price
        inStock
        category {
          name
        }
      }
      total
      hasNextPage
    }
  }
`;

// The detail page needs more fields
const GET_PRODUCT_DETAIL = gql`
  query GetProduct($id: ID!) {
    product(id: $id) {
      id
      name
      price
      inStock
      category {
        id
        name
      }
      reviews {
        id
        author
        rating
        comment
        createdAt
      }
      averageRating
    }
  }
`;
    

tRPC: End-to-End Type Safety

tRPC is the most recent of the three approaches and represents a paradigm shift: it completely eliminates the serialization layer between client and server when both are written in TypeScript. Thanks to type inference, the client automatically knows the input and output types of every procedure without needing codegen or external schemas.

Router Definition (Server)

tRPC Router with Zod validation

import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.context<Context>().create();

// Validation schema with Zod
const productFilterSchema = z.object({
  category: z.string().optional(),
  minPrice: z.number().min(0).optional(),
  maxPrice: z.number().min(0).optional(),
  inStock: z.boolean().optional()
});

const createProductSchema = z.object({
  name: z.string().min(1).max(200),
  price: z.number().positive(),
  categoryId: z.string().uuid()
});

const updateProductSchema = z.object({
  name: z.string().min(1).max(200).optional(),
  price: z.number().positive().optional(),
  categoryId: z.string().uuid().optional(),
  inStock: z.boolean().optional()
});

// Router: defines all procedures
export const productRouter = t.router({
  list: t.procedure
    .input(z.object({
      filter: productFilterSchema.optional(),
      page: z.number().int().positive().default(1),
      limit: z.number().int().min(1).max(100).default(20)
    }))
    .query(async ({ input, ctx }) => {
      const result = await ctx.productService.findAll(
        input.filter, input.page, input.limit
      );
      return {
        items: result.items,
        total: result.total,
        hasNextPage: input.page * input.limit < result.total
      };
    }),

  getById: t.procedure
    .input(z.object({ id: z.string().uuid() }))
    .query(async ({ input, ctx }) => {
      const product = await ctx.productService.findById(input.id);
      if (!product) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'Product not found'
        });
      }
      return product;
    }),

  create: t.procedure
    .input(createProductSchema)
    .mutation(async ({ input, ctx }) => {
      return ctx.productService.create(input);
    }),

  update: t.procedure
    .input(z.object({
      id: z.string().uuid(),
      data: updateProductSchema
    }))
    .mutation(async ({ input, ctx }) => {
      return ctx.productService.update(input.id, input.data);
    }),

  delete: t.procedure
    .input(z.object({ id: z.string().uuid() }))
    .mutation(async ({ input, ctx }) => {
      await ctx.productService.delete(input.id);
      return { success: true };
    })
});

export type ProductRouter = typeof productRouter;
    

tRPC Client with Full Type Safety

tRPC Client: types are automatically inferred

import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { ProductRouter } from '../server/routers/product';

// The client knows ALL types automatically
const trpc = createTRPCClient<ProductRouter>({
  links: [
    httpBatchLink({
      url: 'http://localhost:3000/trpc'
    })
  ]
});

// Full autocompletion and type checking
async function loadProducts() {
  // TypeScript knows the result has items, total, hasNextPage
  const result = await trpc.product.list.query({
    filter: { category: 'electronics', minPrice: 10 },
    page: 1,
    limit: 20
  });

  // Compilation error if accessing a non-existent field
  // result.items[0].nonExistent  // TS Error!

  return result;
}

// Mutations are also typed
async function createProduct() {
  const newProduct = await trpc.product.create.mutate({
    name: 'New Product',
    price: 29.99,
    categoryId: '550e8400-e29b-41d4-a716-446655440000'
  });

  // newProduct has the Product type inferred from the server
  console.log(newProduct.id);
}
    

Detailed Comparison

Complete Comparison Table

Aspect REST GraphQL tRPC
Paradigm Resource-oriented Query language RPC with type inference
Type Safety Manual (OpenAPI/Swagger) Codegen required Automatic end-to-end
Over/Under-fetching Common (solved with BFF) Solved by design Depends on definition
HTTP Caching Native (ETags, Cache-Control) Difficult (everything is POST) Possible for GET queries
Learning Curve Low Medium-high Low (if you know TS)
Tooling Mature and vast Rich (Apollo, Relay) Rapidly growing
Performance Good, predictable Variable (N+1 problem) Excellent (native batching)
Errors HTTP status codes Always 200 (errors in body) Typed error codes
Versioning URL or header versioning Schema evolution deprecation Router versioning
File Upload Native (multipart) Complex (separate spec) Dedicated plugins
Real-time Separate WebSocket / SSE Native subscriptions Subscriptions via WebSocket
Non-TS Clients Any language Any language TypeScript only
Maturity 20+ years, de facto standard 10+ years, widely adopted 3+ years, rapidly growing

Decision Framework

Choosing between REST, GraphQL, and tRPC is not absolute: it depends on the project context. Here is a framework to guide the decision.

Choose REST when:

  • The API must be consumed by clients in different languages (mobile, third parties)
  • Native HTTP caching is critical for performance
  • The team has established experience with REST
  • The API is public and must follow universally understood standards
  • Resources are well-defined and queries are predictable
  • A mature ecosystem with extensive documentation is needed

Choose GraphQL when:

  • Many different clients have different data needs (web, mobile, TV)
  • Entity relationships are complex and deeply nested
  • Over-fetching is a real performance problem (limited bandwidth)
  • The team is willing to invest in the learning curve
  • A subscription system for real-time data is needed
  • The API aggregates data from multiple microservices (GraphQL Federation)

Choose tRPC when:

  • Client and server are both in TypeScript (monorepo ideal)
  • End-to-end type safety is an absolute priority
  • The team wants maximum development speed with autocompletion
  • There are no non-TypeScript clients to support
  • The project is a full-stack application (Next.js, Nuxt, SvelteKit)
  • You want to eliminate codegen boilerplate and manual contracts

Security: Considerations for Each Approach

REST

  • Authentication: Standard Authorization header (Bearer token, API key)
  • Rate limiting: Per endpoint, simple to implement
  • Validation: Server-side responsibility (middleware like Joi, Zod)
  • CORS: Standard and well-documented configuration

GraphQL

  • Query depth limiting: Necessary to prevent malicious recursive queries
  • Query cost analysis: Estimate computational cost before execution
  • Introspection: Disable in production to avoid exposing the schema
  • Persisted queries: Only pre-approved queries in production

tRPC

  • Built-in validation: Zod automatically validates every input
  • Middleware: Middleware chain for auth, logging, rate limiting
  • Type safety: Many errors are caught at compile-time
  • Batching: Be careful with timeout and batch size

The Hybrid Approach

In many real-world projects, the best solution is a hybrid approach. For example:

  • tRPC for the internal frontend (admin dashboard, company portal) where client and server are both TypeScript
  • REST for public APIs intended for third parties and mobile clients
  • GraphQL as a gateway to aggregate data from multiple microservices
Hybrid architecture example

        +------------------+
        |   Web App (TS)   |--- tRPC ---+
        +------------------+            |
                                        v
        +------------------+    +---------------+    +----------------+
        |  Mobile App      |    |   API Gateway |    |  Microservices |
        |  (Swift/Kotlin)  |--->|  (REST/GQL)   |--->|  (gRPC/REST)   |
        +------------------+    +---------------+    +----------------+
                                        ^
        +------------------+            |
        |  Partner APIs    |--- REST ---+
        +------------------+
    

Conclusion

There is no absolute winner among REST, GraphQL, and tRPC. Each approach excels in specific scenarios:

  • REST remains the safest choice for public APIs, interoperability, and when HTTP caching is critical
  • GraphQL shines when data is complex, clients are heterogeneous, and query flexibility is essential
  • tRPC is unbeatable for developer experience in full-stack TypeScript projects

Key Takeaways

  • Context first: The choice depends on the project, not the trending technology
  • Type safety: tRPC offers the best experience, but requires TypeScript everywhere
  • Flexibility: GraphQL solves over/under-fetching but adds complexity
  • Standards: REST is universally understood and supported
  • Hybrid: In large projects, combining multiple approaches is often the best choice

Frequently asked questions

What is the main difference between GraphQL and REST?

GraphQL allows clients to specify exactly which data they want to receive, eliminating over-fetching and under-fetching issues, whereas REST relies on standard HTTP methods and stateless representations.

What is tRPC's key feature compared to other approaches?

tRPC eliminates the serialization layer between client and server when both are written in TypeScript, providing end-to-end type safety through type inference.

How does Richardson's Maturity Model relate to REST APIs?

Richardson defined four maturity levels for REST APIs, which can be used as a framework to evaluate and improve the design of REST-based systems.

What is often the best solution in real-world projects according to the article?

A hybrid approach combining elements from different paradigms, such as using tRPC for certain parts and another approach for others.

Sources and further reading

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects