REST vs tRPC vs GraphQL

The same data through three API styles.

What it is

All three run on this same API, over the same database. REST (every other page in this lab) names resources with URLs and lets HTTP do the rest: methods, status codes, caching. tRPC drops the URL design: the server exports TypeScript functions, and the client calls them with full type checking, because it imports the router's type; calls made together are batched into one request. GraphQL publishes a typed schema, and each client writes a query for exactly the fields it needs, across any number of types, answered in one round trip. None of them is best: they trade simplicity, type safety and flexibility differently.

When to use it

  • REST: public APIs, anything cacheable, clients in any language
  • tRPC: a TypeScript front end and back end in one codebase
  • GraphQL: many clients needing different slices of rich, connected data
Official docs ↗

1. Ask the same question three ways

Pick a question to send it all three ways.

GraphQL's answers are smaller because the query names only the fields it shows. For the dashboard, REST takes three requests, tRPC batches its three calls into one, and GraphQL asks for everything in one query. The mistake shows three error styles: REST's 422 Problem, tRPC's 400 with its own error envelope, and GraphQL's 400 from validating the query against the schema. (The tRPC call couldn't even be written without a cast: TypeScript knows "pong" isn't a game.)

2. Side by side

AspectRESTtRPCGraphQL
ContractURLs, methods and status codes, described by OpenAPIThe router's TypeScript type, shared with the clientA typed schema, queried by the client
Who decides the response's shapeThe server, per endpointThe server, per procedureThe client, per query
Several things at onceOne request each (or a purpose-built endpoint)Batched into one HTTP requestOne query, any mix of fields
ErrorsHTTP status + Problem DetailsHTTP status + tRPC's error envelope400 if it can't run; else 200 with an errors list beside the data
HTTP cachingBuilt in: GET, ETags, CDNsGET queries, but long URLsMostly POST: needs persisted queries or client caches
ClientsAnything that speaks HTTPTypeScript (others can, without the types)Anything, best with a GraphQL client
Watch out forMany round trips; over-fetchingTies the client to the server's codeExpensive queries: limit depth and cost

Endpoints: tRPC at /api/trpc/<procedure> (queries as GET, mutations as POST), GraphQL at POST /api/graphql (try { __schema { types { name } } }: the schema describes itself). Both reuse the REST endpoints' database code and rate limits. The GraphQL handler is written out by hand around the reference graphql package, with a length and nesting limit checked before anything runs.

Show code · src/api/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import { z } from "zod";
import type { Auth } from "./auth";
import { board, GAMES, type Game } from "./routes/scores";
import { createTask, findTask, newestTasks, toTask } from "./routes/tasks";

// The same data as the REST endpoints, as tRPC procedures. There's no URL
// design and no schema file: a procedure is a function with a Zod input, and
// the client imports this router's *type* (AppRouter), so calling it from
// TypeScript is type-checked end to end. Rename a field here and the page
// that calls it stops compiling.
//
// Over the wire it's still HTTP: queries are GETs to /api/trpc/<path>?input=…,
// mutations are POSTs, and the client can batch several calls into one request.

const t = initTRPC.context<{ auth?: Auth }>().create();

const gameIds = Object.keys(GAMES) as [Game, ...Game[]];

export const appRouter = t.router({
  tasks: t.router({
    newest: t.procedure
      .input(z.object({ limit: z.number().int().min(1).max(50).default(5) }))
      .query(async ({ input }) => (await newestTasks(input.limit)).map(toTask)),
    byId: t.procedure
      .input(z.object({ id: z.uuid() }))
      .query(async ({ input }) => {
        const row = await findTask(input.id);
        if (!row) throw new TRPCError({ code: "NOT_FOUND", message: `There's no task ${input.id}.` });
        return toTask(row);
      }),
    create: t.procedure
      .input(z.strictObject({ title: z.string().trim().min(1, "Give the task a title").max(200) }))
      .mutation(async ({ input }) => toTask(await createTask(input))),
  }),
  scores: t.router({
    top: t.procedure
      .input(z.object({ game: z.enum(gameIds), limit: z.number().int().min(1).max(50).default(10) }))
      .query(({ input }) => board(input.game, input.limit)),
  }),
});

export type AppRouter = typeof appRouter;
Show code · src/api/graphql.ts
import {
  type DocumentNode, getOperationAST, GraphQLBoolean, GraphQLEnumType, GraphQLError, GraphQLID, GraphQLInt, GraphQLList,
  GraphQLNonNull, type GraphQLNullableType, GraphQLObjectType, GraphQLSchema, GraphQLString, Kind, type SelectionSetNode,
} from "graphql";
import { board, GAMES, type Game } from "./routes/scores";
import { createTask, findTask, newestTasks, toTask } from "./routes/tasks";

// The same data as the REST endpoints, as one GraphQL schema. The client
// writes a query naming exactly the fields it wants, across as many types as
// it likes, and gets one JSON response of the same shape: no over-fetching,
// and one round trip instead of several. The cost moves to the server, which
// has to guard against queries that ask for too much (see maxDepth).

const nonNull = <T extends GraphQLNullableType>(type: T) => new GraphQLNonNull(type);
const list = <T extends GraphQLNullableType>(type: T) => nonNull(new GraphQLList(nonNull(type)));

const Task = new GraphQLObjectType({
  name: "Task",
  fields: {
    id: { type: nonNull(GraphQLID) },
    title: { type: nonNull(GraphQLString) },
    done: { type: nonNull(GraphQLBoolean) },
    createdAt: { type: nonNull(GraphQLString), description: "ISO 8601" },
    updatedAt: { type: nonNull(GraphQLString) },
  },
});

const GameEnum = new GraphQLEnumType({
  name: "Game",
  values: Object.fromEntries(Object.entries(GAMES).map(([id, g]) => [id, { value: id, description: g.name }])),
});

const ScoreEntry = new GraphQLObjectType({
  name: "ScoreEntry",
  fields: {
    rank: { type: nonNull(GraphQLInt) },
    id: { type: nonNull(GraphQLID) },
    initials: { type: nonNull(GraphQLString) },
    value: { type: nonNull(GraphQLInt), description: "Points, or milliseconds for Marble Maze" },
    createdAt: { type: nonNull(GraphQLString) },
  },
});

const Leaderboard = new GraphQLObjectType({
  name: "Leaderboard",
  fields: {
    game: { type: nonNull(GameEnum) },
    name: { type: nonNull(GraphQLString) },
    unit: { type: nonNull(GraphQLString) },
    better: { type: nonNull(GraphQLString), description: "higher or lower" },
    entries: { type: list(ScoreEntry) },
  },
});

const limitArg = (fallback: number) => ({ type: GraphQLInt, defaultValue: fallback, description: "1–50" });
const checkLimit = (limit: number) => {
  if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw new GraphQLError("limit must be between 1 and 50", { extensions: { code: "BAD_USER_INPUT" } });
  return limit;
};
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

export const schema = new GraphQLSchema({
  query: new GraphQLObjectType({
    name: "Query",
    fields: {
      tasks: {
        type: list(Task), description: "The newest tasks", args: { limit: limitArg(5) },
        resolve: async (_, { limit }) => (await newestTasks(checkLimit(limit))).map(toTask),
      },
      task: {
        type: Task, description: "One task, or null", args: { id: { type: nonNull(GraphQLID) } },
        resolve: async (_, { id }) => {
          if (!UUID.test(id)) throw new GraphQLError("id must be a UUID", { extensions: { code: "BAD_USER_INPUT" } });
          const row = await findTask(id);
          return row ? toTask(row) : null;
        },
      },
      leaderboard: {
        type: nonNull(Leaderboard), args: { game: { type: nonNull(GameEnum) }, limit: limitArg(10) },
        resolve: (_, { game, limit }) => board(game as Game, checkLimit(limit)),
      },
    },
  }),
  mutation: new GraphQLObjectType({
    name: "Mutation",
    fields: {
      createTask: {
        type: nonNull(Task), args: { title: { type: nonNull(GraphQLString) } },
        resolve: async (_, { title }) => {
          const trimmed = String(title).trim();
          if (trimmed.length < 1 || trimmed.length > 200) throw new GraphQLError("title must be 1–200 characters", { extensions: { code: "BAD_USER_INPUT" } });
          return toTask(await createTask({ title: trimmed }));
        },
      },
    },
  }),
});

/**
 * How deeply the operation nests, following fragments. This schema is
 * shallow, but a graph with cycles (a user's friends' friends' friends…) lets
 * one small query ask for an enormous amount of work, so GraphQL servers cap
 * depth (and often a cost score) before executing anything.
 */
export function maxDepth(document: DocumentNode, operationName?: string | null) {
  const fragments = new Map(document.definitions.flatMap((d) => (d.kind === Kind.FRAGMENT_DEFINITION ? [[d.name.value, d] as const] : [])));
  const depth = (set: SelectionSetNode | undefined, seen: Set<string>): number => {
    if (!set) return 0;
    let deepest = 0;
    for (const s of set.selections) {
      // Introspection (__schema, __type…) is bounded by the schema itself, and
      // tools' standard introspection query nests deeply, so it isn't counted.
      if (s.kind === Kind.FIELD) { if (!s.name.value.startsWith("__")) deepest = Math.max(deepest, 1 + depth(s.selectionSet, seen)); }
      else if (s.kind === Kind.INLINE_FRAGMENT) deepest = Math.max(deepest, depth(s.selectionSet, seen));
      else if (!seen.has(s.name.value)) deepest = Math.max(deepest, depth(fragments.get(s.name.value)?.selectionSet, new Set([...seen, s.name.value])));
    }
    return deepest;
  };
  return depth(getOperationAST(document, operationName)?.selectionSet, new Set());
}
Show code · src/api/routes/graphql.ts
import { execute, GraphQLError, parse, specifiedRules, validate } from "graphql";
import { Hono } from "hono";
import type { AuthEnv } from "../auth";
import { maxDepth, schema } from "../graphql";
import { rateLimit } from "../ratelimit";

// GraphQL over HTTP, written out by hand (the graphql package does the
// parsing, validation and execution). One endpoint, POST, JSON in and out.
// Status codes follow the GraphQL-over-HTTP spec with its own media type:
// a request that can't run at all (bad JSON, a syntax error, an unknown
// field) is a 400; once execution starts the answer is 200, and anything
// that went wrong is listed in "errors" next to whatever "data" succeeded.

const MAX_QUERY_LENGTH = 4000;
const MAX_DEPTH = 5;
const MEDIA_TYPE = "application/graphql-response+json; charset=utf-8";

export const graphqlApi = new Hono<AuthEnv>();

const respond = (status: 200 | 400 | 405, body: object) =>
  new Response(JSON.stringify(body), { status, headers: { "content-type": MEDIA_TYPE } });
const fail = (status: 400 | 405, message: string) => respond(status, { errors: [{ message }] });

graphqlApi.all("/graphql", rateLimit({ name: "graphql", windowSec: 60, limit: (c) => (c.get("auth") ? 240 : Number(process.env.LAB_WRITE_LIMIT ?? 60)) }), async (c) => {
  if (c.req.method !== "POST") {
    const res = fail(405, "Send queries with POST and a JSON body: { \"query\": \"…\", \"variables\": {…} }");
    res.headers.set("allow", "POST");
    return res;
  }
  let body: { query?: unknown; variables?: unknown; operationName?: unknown };
  try {
    body = await c.req.json();
  } catch {
    return fail(400, "The body isn't JSON.");
  }
  if (typeof body.query !== "string" || !body.query.trim()) return fail(400, "Send a \"query\" string.");
  if (body.query.length > MAX_QUERY_LENGTH) return fail(400, `Queries are limited to ${MAX_QUERY_LENGTH} characters.`);
  const variables = body.variables ?? undefined;
  if (variables !== undefined && variables !== null && (typeof variables !== "object" || Array.isArray(variables))) return fail(400, "\"variables\" must be an object.");
  const operationName = typeof body.operationName === "string" ? body.operationName : undefined;

  let document;
  try {
    document = parse(body.query);
  } catch (e) {
    return respond(400, { errors: [(e as GraphQLError).toJSON()] });
  }
  const errors = validate(schema, document, specifiedRules);
  if (errors.length) return respond(400, { errors: errors.map((e) => e.toJSON()) });
  const depth = maxDepth(document, operationName);
  if (depth > MAX_DEPTH) return fail(400, `This query nests ${depth} levels deep; the limit is ${MAX_DEPTH}.`);

  const result = await execute({ schema, document, variableValues: variables as Record<string, unknown> | undefined, operationName, contextValue: { auth: c.get("auth") } });
  return respond(200, JSON.parse(JSON.stringify(result)));
});
Show code · src/app/styles/Compare.tsx
"use client";

import { createTRPCClient, httpBatchLink } from "@trpc/client";
import { useState } from "react";
import type { AppRouter } from "@/api/trpc";
import { Button, cx } from "@/components/ui";

// The same three questions asked three ways, for real. Every HTTP exchange
// is recorded as it happens: how many requests, how many bytes came back,
// and the raw bodies. (Bytes are the JSON as received, before compression.)

type Exchange = { method: string; url: string; status: number; bytes: number; body: string };
type Style = "REST" | "tRPC" | "GraphQL";
type Run = Record<Style, { exchanges: Exchange[]; code: string }>;

const encoder = new TextEncoder();

function recorder() {
  const exchanges: Exchange[] = [];
  const fetchAndRecord = async (input: RequestInfo | URL, init?: RequestInit) => {
    const res = await fetch(input, { ...init, cache: "no-store" });
    const body = await res.clone().text();
    const url = new URL(String(input instanceof Request ? input.url : input), location.href);
    exchanges.push({ method: init?.method ?? "GET", url: decodeURIComponent(url.pathname + url.search), status: res.status, bytes: encoder.encode(body).length, body });
    return res;
  };
  return { exchanges, fetch: fetchAndRecord };
}

// The tRPC client knows every procedure, input and output from the router's
// type alone: `import type` brings no server code into this bundle.
const trpcWith = (f: typeof fetch) => createTRPCClient<AppRouter>({ links: [httpBatchLink({ url: "/api/trpc", fetch: f })] });

const graphql = (f: typeof fetch, query: string) =>
  f("/api/graphql", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ query }) });

type Scenario = { label: string; code: Record<Style, string>; run: Record<Style, (f: typeof fetch) => Promise<unknown>> };

const SCENARIOS: Scenario[] = [
  {
    label: "Top 3 on Runner",
    code: {
      REST: `fetch("/api/scores?game=runner&limit=3")`,
      tRPC: `trpc.scores.top.query({ game: "runner", limit: 3 })`,
      GraphQL: `{ leaderboard(game: runner, limit: 3) {\n    entries { rank initials value }\n} }`,
    },
    run: {
      REST: (f) => f("/api/scores?game=runner&limit=3"),
      tRPC: (f) => trpcWith(f).scores.top.query({ game: "runner", limit: 3 }),
      GraphQL: (f) => graphql(f, "{ leaderboard(game: runner, limit: 3) { entries { rank initials value } } }"),
    },
  },
  {
    label: "A dashboard: two boards and the newest tasks",
    code: {
      REST: `Promise.all([\n  fetch("/api/scores?game=runner&limit=3"),\n  fetch("/api/scores?game=stacker&limit=3"),\n  fetch("/api/tasks?limit=3"),\n])`,
      tRPC: `Promise.all([\n  trpc.scores.top.query({ game: "runner", limit: 3 }),\n  trpc.scores.top.query({ game: "stacker", limit: 3 }),\n  trpc.tasks.newest.query({ limit: 3 }),\n])  // batched into one request`,
      GraphQL: `{\n  runner: leaderboard(game: runner, limit: 3) { entries { initials value } }\n  stacker: leaderboard(game: stacker, limit: 3) { entries { initials value } }\n  tasks(limit: 3) { title done }\n}`,
    },
    run: {
      REST: (f) => Promise.all([f("/api/scores?game=runner&limit=3"), f("/api/scores?game=stacker&limit=3"), f("/api/tasks?limit=3")]),
      tRPC: (f) => {
        const trpc = trpcWith(f);
        return Promise.all([trpc.scores.top.query({ game: "runner", limit: 3 }), trpc.scores.top.query({ game: "stacker", limit: 3 }), trpc.tasks.newest.query({ limit: 3 })]);
      },
      GraphQL: (f) => graphql(f, "{ runner: leaderboard(game: runner, limit: 3) { entries { initials value } } stacker: leaderboard(game: stacker, limit: 3) { entries { initials value } } tasks(limit: 3) { title done } }"),
    },
  },
  {
    label: "A mistake: a game that doesn't exist",
    code: {
      REST: `fetch("/api/scores?game=pong")`,
      tRPC: `// TypeScript rejects this line before it ever runs;\n// the demo forces it through with a cast.\ntrpc.scores.top.query({ game: "pong" as never })`,
      GraphQL: `{ leaderboard(game: pong) { entries { rank } } }`,
    },
    run: {
      REST: (f) => f("/api/scores?game=pong"),
      tRPC: (f) => trpcWith(f).scores.top.query({ game: "pong" as never }).catch(() => null),
      GraphQL: (f) => graphql(f, "{ leaderboard(game: pong) { entries { rank } } }"),
    },
  },
];

const STYLES: Style[] = ["REST", "tRPC", "GraphQL"];
const pretty = (text: string) => { try { return JSON.stringify(JSON.parse(text), null, 2); } catch { return text; } };

export function Compare() {
  const [index, setIndex] = useState(0);
  const [run, setRun] = useState<Run | null>(null);
  const [busy, setBusy] = useState(false);

  const go = async (i: number) => {
    setIndex(i);
    setBusy(true);
    try {
      const scenario = SCENARIOS[i];
      const result = {} as Run;
      for (const style of STYLES) {
        const rec = recorder();
        await scenario.run[style](rec.fetch as typeof fetch).catch(() => null);
        result[style] = { exchanges: rec.exchanges, code: scenario.code[style] };
      }
      setRun(result);
    } finally {
      setBusy(false);
    }
  };

  return (
    <div>
      <div className="mb-3 flex flex-wrap gap-2" role="group" aria-label="Questions">
        {SCENARIOS.map((s, i) => (
          <Button key={s.label} variant={run && i === index ? "primary" : "ghost"} aria-pressed={!!run && i === index} disabled={busy} onClick={() => go(i)}>{s.label}</Button>
        ))}
      </div>
      {!run && <p className="m-0 text-sm text-muted">Pick a question to send it all three ways.</p>}
      {run && (
        <div className="grid gap-3 lg:grid-cols-3" aria-live="polite">
          {STYLES.map((style) => {
            const { exchanges, code } = run[style];
            const bytes = exchanges.reduce((n, e) => n + e.bytes, 0);
            return (
              <section key={style} className="min-w-0 rounded-lg border border-line bg-bg p-3" data-style={style}>
                <h3 className="mt-0 flex items-baseline justify-between gap-2">
                  {style}
                  <span className="text-sm font-normal text-muted" data-testid="totals">
                    {exchanges.length} request{exchanges.length === 1 ? "" : "s"} · {bytes.toLocaleString("en")} bytes
                  </span>
                </h3>
                <pre className="m-0 mb-2 overflow-x-auto rounded bg-code px-2 py-1.5 font-mono text-xs" tabIndex={0}>{code}</pre>
                {exchanges.map((e, i) => (
                  <details key={i} className="mb-1" open={exchanges.length === 1}>
                    <summary className="cursor-pointer font-mono text-xs break-all">
                      <span className={cx("font-semibold", e.status >= 400 ? "text-danger" : "text-accent-ink")}>{e.status}</span> {e.method} {e.url}
                    </summary>
                    <pre className="m-0 mt-1 max-h-60 overflow-auto rounded bg-code px-2 py-1.5 font-mono text-xs" tabIndex={0}>{pretty(e.body)}</pre>
                  </details>
                ))}
              </section>
            );
          })}
        </div>
      )}
    </div>
  );
}