Stackvora.tech

REST vs GraphQL vs tRPC: Pick the Right API Style

Choosing the appropriate API architectural style is a critical decision in backend development, directly impacting client-side development, data fetching…

Choosing the appropriate API architectural style is a critical decision in backend development, directly impacting client-side development, data fetching efficiency, and overall system maintainability. This article compares three prominent API styles—REST, GraphQL, and tRPC—detailing their design principles, operational characteristics, and ideal use cases to help engineers make informed choices.

REST: The Ubiquitous Standard

Representational State Transfer (REST) is an architectural style, not a protocol, that leverages standard HTTP methods (GET, POST, PUT, DELETE, PATCH) for stateless communication. Resources are identified by URIs, and interactions manipulate the state of these resources. Its simplicity and widespread adoption make it a common choice for many applications.

Core Principles and Characteristics

  • Statelessness: Each request from client to server must contain all the information needed to understand the request. The server cannot store any client context between requests.
  • Client-Server: Clear separation of concerns between client and server, allowing independent evolution.
  • Cacheable: Responses can be declared cacheable or non-cacheable, improving scalability and performance.
  • Layered System: Intermediary servers (proxies, load balancers) can be inserted between client and server to improve scalability and security.
  • Uniform Interface: Simplifies overall system architecture by having a common way to interact with resources.

Data Fetching and Structure

REST APIs typically expose fixed-structure endpoints. For example, GET /users returns a list of users, and GET /users/{id} returns a single user. To fetch related data, clients often need to make multiple requests (e.g., fetching a user, then their posts). This can lead to:

  • Over-fetching: Receiving more data than necessary from an endpoint.
  • Under-fetching: Needing to make additional requests to get all required data.

While some REST APIs support partial responses via query parameters (e.g., GET /users/{id}?fields=name,email), this is not a standardized feature and relies on custom server-side implementation.

Caching in REST

HTTP caching mechanisms work natively with REST. Responses can include headers like Cache-Control, ETag, and Last-Modified, allowing browsers, CDNs, and proxies to cache responses effectively. This is a significant advantage for performance and reducing server load for frequently accessed, static data.

GET /api/v1/products/123 HTTP/1.1
Host: api.example.com
Accept: application/json

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=3600
ETag: "product_123_v2"
Last-Modified: Tue, 15 Feb 2024 10:00:00 GMT

{
  "id": "123",
  "name": "Super Widget",
  "price": 29.99,
  "description": "An essential component for your setup."
}

GraphQL: Client-Driven Data Fetching

GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data. It allows clients to request exactly the data they need, no more and no less. Developed by Facebook, it addresses the over-fetching and under-fetching issues prevalent in traditional REST APIs, especially for applications with diverse client needs.

Core Principles and Characteristics

  • Single Endpoint: Typically, all GraphQL interactions occur over a single HTTP POST endpoint (e.g., /graphql).
  • Declarative Data Fetching: Clients specify the exact structure and fields of the data they require.
  • Strongly Typed Schema: A GraphQL schema defines all possible data types, fields, and operations (queries, mutations, subscriptions), providing a contract between client and server.
  • Introspection: Clients can query the schema itself to understand available data and operations.

Data Fetching and Structure

Clients send a query string to the GraphQL server. The server then executes this query against its data sources (databases, other REST APIs, etc.) and returns a JSON response matching the query's structure.

query GetUserProfileAndPosts {
  user(id: "user123") {
    id
    name
    email
    posts(limit: 5) {
      id
      title
      createdAt
    }
  }
}

This single query retrieves user details and their five most recent posts, solving both over-fetching and under-fetching. Mutations are used for data modification, and subscriptions for real-time data updates.

Caching in GraphQL

Caching in GraphQL is more complex than in REST. Since all requests typically go to a single endpoint with dynamic payloads, standard HTTP caching (browser, CDN) is less effective. Caching strategies include:

  • Client-side Caching: Libraries like Apollo Client or Relay manage a normalized cache on the client, storing data by ID and updating components reactively.
  • Server-side Caching: Implementing custom caching layers within the GraphQL resolver chain (e.g., caching database query results or third-party API calls).
  • DataLoader: A utility that batches and caches requests made during a single query execution, preventing the "N+1 problem."

tRPC: Type-Safe RPC for TypeScript Monorepos

tRPC (TypeScript Remote Procedure Call) is not an API architectural style in the same vein as REST or GraphQL. Instead, it's a framework that enables end-to-end type safety between your TypeScript backend and TypeScript frontend, often used within a monorepo setup. It leverages TypeScript's powerful inference capabilities to eliminate the need for schema generation or manual type synchronization.

Core Principles and Characteristics

  • End-to-End Type Safety: The primary benefit. Function calls from the frontend to the backend are type-checked at compile time.
  • Zero Runtime Overhead: tRPC itself has minimal runtime footprint; it primarily generates types.
  • No Code Generation: Unlike GraphQL (which often uses code generators for client types), tRPC infers types directly from your backend code.
  • RPC-based: Exposes backend functions directly as callable procedures on the frontend.
  • Schema-less API: No separate schema definition language (like GraphQL SDL or OpenAPI). The TypeScript code is the API definition.

Data Fetching and Structure

With tRPC, you define procedures (queries, mutations, subscriptions) on your backend using TypeScript functions. These functions directly correspond to client-side calls. A typical tRPC setup might look like this:

// Backend (server/trpc.ts)
import { initTRPC } from '@trpc/server';
import { z } from 'zod'; // For input validation

const t = initTRPC.create();

const appRouter = t.router({
  getUser: t.procedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      // Imagine fetching from DB
      return { id: input.id, name: 'John Doe', email: 'john@example.com' };
    }),
  updateUser: t.procedure
    .input(z.object({ id: z.string(), name: z.string() }))
    .mutation(async ({ input }) => {
      // Imagine updating DB
      return { id: input.id, name: input.name };
    }),
});

export type AppRouter = typeof appRouter;

// Frontend (client/index.ts)
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/trpc'; // Import types directly from server

const trpc = createTRPCProxyClient({
  links: [
    httpBatchLink({
      url: 'http://localhost:3000/trpc',
    }),
  ],
});

async function main() {
  const user = await trpc.getUser.query({ id: '123' });
  console.log(user.name); // Type-safe access!
  // user.email.toUpperCase(); // Also type-safe!

  const updatedUser = await trpc.updateUser.mutate({ id: '123', name: 'Jane Doe' });
  console.log(updatedUser.name);
}

main();

The frontend client code directly calls trpc.getUser.query() and trpc.updateUser.mutate(). TypeScript ensures that the arguments passed and the data returned match the backend procedure's definition at compile time. This eliminates a whole class of API integration bugs.

Caching in tRPC

tRPC integrates well with query caching libraries like TanStack Query (React Query). These libraries manage client-side state, re-fetching, and invalidation for tRPC queries. Since tRPC uses standard HTTP POST requests under the hood (though the payload is a custom RPC format), native HTTP caching is not directly applicable to the tRPC query itself, but you can configure HTTP caching headers for the underlying responses if your tRPC server implements it. Often, a combination of client-side data fetching libraries and server-side application-level caching is used.

Decision Matrix: Picking the Right API Style

Feature REST GraphQL tRPC
Primary Use Case Public, general-purpose APIs; simple resource interaction. Complex data fetching for multiple client types; avoiding over/under-fetching. Monorepos with TypeScript frontend & backend, same team ownership.
Client Relationship Decoupled; diverse languages/platforms. Decoupled; diverse languages/platforms. Tight coupling; TypeScript frontend required.
Data Fetching Model Fixed resources/endpoints, multiple requests often needed. Client-defined queries, single request for complex data. RPC-like function calls, direct backend interaction.
Type Safety (End-to-End) Manual (OpenAPI/Swagger + code generation, or Mongoose/Sequelize models). Schema-driven (SDL + code generation for client/server). Native TypeScript inference (zero-config).
Over/Under-fetching Common, requires careful design or multiple requests. Solved by design. Solved by design (client requests specific procedure).
Caching Strategy Native HTTP caching (browser, CDN, proxy). Client-side normalized cache (Apollo, Relay), server-side DataLoader. Client-side query library (TanStack Query), server-side application caching.
Learning Curve Low (familiar HTTP concepts). Moderate (schema, queries, resolvers, client libraries). Low for TypeScript developers (looks like normal function calls).
Tooling Ecosystem Mature (OpenAPI, Postman, cURL). Mature (GraphQL Playground, Apollo/Relay Dev Tools). Growing (integrates with TanStack Query, Next.js).
HTTP Methods GET, POST, PUT, DELETE, PATCH. POST (primarily for queries/mutations). POST (via a single endpoint).

Common Pitfalls and Considerations

  • REST - Versioning: As your API evolves, managing different versions (e.g., /v1/users, /v2/users) can become complex.
  • REST - N+1 Problem: Naively fetching related resources often leads to an N+1 query problem, requiring optimization (e.g., eager loading, batching).
  • GraphQL - Performance: Complex queries can be resource-intensive on the server. Proper rate limiting, query depth limits, and DataLoader implementation are crucial.
  • GraphQL - File Uploads: While supported, file uploads in GraphQL have historically been less straightforward than in REST, often requiring multipart forms with specific GraphQL directives.
  • tRPC - Ecosystem Lock-in: tRPC is deeply tied to TypeScript and a monorepo structure. If your frontend isn't TypeScript or isn't in the same repository, tRPC's benefits diminish significantly.
  • tRPC - Public APIs: tRPC is generally not suitable for public-facing APIs where clients might be written in arbitrary languages and you want to maintain a strict separation of concerns. It thrives in internal, controlled environments.

Back to the knowledge base · Ask the AI assistant