REST, GraphQL, gRPC, tRPC and webhooks: choosing an API style
Which API style fits this boundary, and what does each one cost in contracts, caching, errors, versioning and browser support?
The style is a choice about who the consumers are and how tightly they are coupled to you. REST leans on HTTP semantics and caching, GraphQL lets clients shape responses, gRPC gives fast typed RPC between services, tRPC shares TypeScript types inside one codebase, and webhooks push events to systems you do not control.
THE MENTAL MODEL
Start from the boundary, not the technology. Ask three questions: who calls this (your own frontend, other teams, the public internet, a partner's server), how often the caller can be redeployed in lockstep with you, and whether intermediaries such as browsers and CDNs should understand the traffic. Resource-shaped, widely consumed, cache-friendly boundaries want REST with an OpenAPI contract. Many screens composing data from many sources want GraphQL with a gateway mindset. Internal service-to-service calls with strict latency and schemas want gRPC or Connect. A TypeScript monorepo where client and server ship together can skip the contract file entirely with tRPC. And when the other side needs to know something happened, call them: that is a webhook or an event, not a polling endpoint.
HOW IT FITS TOGETHER
| Contract | Best fit | HTTP caching | Browser support | Evolution | |
|---|---|---|---|---|---|
| REST (JSON over HTTP) | OpenAPI 3.1/3.2 (optional, often drifts) | Public and partner APIs, CRUD resources | Native: GET, Cache-Control, ETag, CDNs | Everywhere, including curl | Additive fields; /v2 or date-based versions for breaks |
| GraphQL | SDL schema, introspection | Many clients and screens composing nested data | Hard: mostly POST; needs GET + persisted queries | Everywhere (plain HTTP + JSON) | One evolving graph; @deprecated fields, no versions |
| gRPC | Protobuf .proto, code generated | Internal service-to-service, streaming, polyglot | None by default (POST over HTTP/2) | Not native; needs gRPC-Web proxy or Connect | Field numbers are forever; add, reserve, never reuse |
| tRPC | Inferred TypeScript types, no IDL | TS monorepo, client and server deploy together | Queries can be GET; usually app-level caching | Everywhere (HTTP + JSON) | Breaks old clients silently unless you version procedures |
| Webhooks / async events | Event catalog, JSON Schema or AsyncAPI | Notifying systems you do not control | Not applicable | Server-to-server only | Versioned event types; consumers must ignore unknown fields |
- Client → GraphQL serverPOST /graphql: posts { title, author }Validated against the schema before any resolver runs; depth and cost limits apply here.
- GraphQL server → DatabaseQuery.posts: SELECT ... LIMIT 20
- Database → GraphQL server20 posts with authorId
- GraphQL server → DataLoader (per req)Post.author x20: load(authorId)Each call returns a promise immediately; duplicate keys are memoised.
- DataLoader (per req) → DatabaseOne batch: WHERE id = ANY($1), 7 idsDispatched after the current tick, once all sibling resolvers have queued their keys.
- Database → DataLoader (per req)7 user rows, any order
- DataLoader (per req) → GraphQL serverResolve 20 promises in key orderThe batch function must return one value or Error per key, in the same order.
- GraphQL server → Client200 { data, errors? }Partial failures come back as errors[] next to data, usually with HTTP 200.
KEY TERMS
- Contracts
- OpenAPI describes HTTP resources, parameters, status codes and media types (3.2.0, September 2025, uses JSON Schema 2020-12). GraphQL SDL describes a typed graph that the server enforces on every request. Protobuf describes messages and services and generates clients in many languages. tRPC has no contract file: the router's TypeScript type is the contract, which only works while both sides compile together. Generate from or validate against the contract in CI, or it will drift.
- Error models
- REST should use real status codes plus RFC 9457 problem details (application/problem+json with type, title, status, detail, instance and extension members); it obsoletes RFC 7807. GraphQL returns errors[] alongside partial data, usually with 200, so monitoring must read the body; model expected business failures as union result types. gRPC and Connect use a fixed set of status codes (NOT_FOUND, INVALID_ARGUMENT, UNAVAILABLE) plus typed error details. tRPC throws TRPCError with codes mapped to HTTP statuses.
- Caching
- REST GETs get browser and CDN caching for free (see the HTTP caching explainer). GraphQL over POST bypasses HTTP caches; GET with persisted queries (the client sends a hash) makes it cacheable and also allowlists operations. gRPC is POST over HTTP/2; Connect can send methods marked idempotency_level = NO_SIDE_EFFECTS as GET so CDNs can cache them. Most GraphQL and tRPC caching ends up client-side (normalised caches, TanStack Query).
- Versioning
- Additive change is the default everywhere: new optional fields, new endpoints, never repurposed meanings. REST breaks go to /v2 or a dated version header. GraphQL marks fields @deprecated and watches field usage before removal. Protobuf field numbers identify fields on the wire, so removed fields must be reserved, never reused. Webhook payloads need versioned event types because you cannot redeploy the receivers.
- Pagination
- Offset pagination (?page=40) is simple but slow on deep pages and skips or repeats rows when data changes. Cursor (keyset) pagination encodes the last row's sort key (created_at, id) in an opaque cursor and uses an index range scan. GraphQL's Relay connection spec (edges, pageInfo.endCursor, hasNextPage) is the same idea.
- N+1 in GraphQL
- Resolvers run per field per object, so a list of 20 posts each resolving author makes 20 queries. DataLoader batches all load(key) calls made in the same tick into one batch function call and memoises keys for the request. Create one loader per request: its cache is request-scoped memoisation, and sharing it across users leaks data past authorization checks.
- gRPC in browsers
- Browsers cannot speak native gRPC (no access to HTTP/2 framing or trailers), so gRPC-Web uses its own wire format through a proxy such as Envoy, with unary and server-streaming calls only. Connect servers speak gRPC, gRPC-Web and the Connect protocol (plain HTTP/1.1 or HTTP/2 with JSON or binary) from the same handlers, so browsers can call them without a proxy.
IN YOUR STACK
YAML · OpenAPI 3.2 Server
openapi: 3.2.0
info: { title: Orders API, version: "1.4.0" }
paths:
/v1/orders/{id}:
get:
operationId: getOrder
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
"200":
description: The order
headers: { ETag: { schema: { type: string } } }
content: { application/json: { schema: { $ref: "#/components/schemas/Order" } } }
"404":
description: No order with this id for the caller
content: { application/problem+json: { schema: { $ref: "#/components/schemas/Problem" } } }
components:
schemas:
Problem: # RFC 9457
type: object
properties:
type: { type: string, format: uri-reference, default: "about:blank" }
title: { type: string }
status: { type: integer }
detail: { type: string }
instance: { type: string, format: uri-reference }- A 404 body then looks like {"type": "https://api.example.com/problems/order-not-found", "title": "Order not found", "status": 404, "detail": "No order 7c1e... for this account"}. Clients branch on type, not on title text.
- Validation errors can carry an extension array, for example errors: [{ detail, pointer: "#/items/0/quantity" }]. Clients must ignore extensions they do not understand.
- Quote version strings in YAML: an unquoted 2026-11-01 parses as a date.
TypeScript · GraphQL.js + DataLoader Server
import DataLoader from "dataloader";
export const typeDefs = /* GraphQL */ `
type User { id: ID! name: String! }
type Post { id: ID! title: String! author: User }
type Query { posts(first: Int = 20, after: String): [Post!]! }
`;
type Ctx = { viewer: Viewer; users: DataLoader<string, User | null> };
// Called once per request by your server (Yoga, Apollo, Mercurius...).
export function createContext(viewer: Viewer): Ctx {
const users = new DataLoader<string, User | null>(async (ids) => {
const rows = await db.user.findMany({ where: { id: { in: [...ids] } } });
const byId = new Map(rows.map((u) => [u.id, u]));
return ids.map((id) => byId.get(id) ?? null); // same length and order as ids
}, { maxBatchSize: 500 });
return { viewer, users };
}
export const resolvers = {
Query: { posts: (_: unknown, args: { first: number; after?: string }, ctx: Ctx) =>
listPostsVisibleTo(ctx.viewer, Math.min(args.first, 100), args.after) },
Post: { author: (post: Post, _: unknown, ctx: Ctx) => ctx.users.load(post.authorId) },
};- The batch function receives deduplicated keys and must return exactly one value (or Error) per key in the same order; returning rows in database order assigns authors to the wrong posts.
- Clamp list arguments and add depth or cost limits; a nested query can otherwise fan out to millions of resolver calls.
- In production, prefer persisted (trusted) documents: clients send a hash, the server only runs known operations, and GET requests become cacheable.
Protobuf + TypeScript · Connect for Node.js Server
// proto/orders/v1/orders.proto
syntax = "proto3";
package orders.v1;
service OrderService {
rpc GetOrder(GetOrderRequest) returns (Order) {
option idempotency_level = NO_SIDE_EFFECTS; // Connect clients may use GET
}
}
message GetOrderRequest { string id = 1; }
message Order {
reserved 3; // removed field: the number is never reused
string id = 1;
int64 total_cents = 2;
}
// src/routes.ts (code generated with buf + protoc-gen-es)
import { Code, ConnectError, type ConnectRouter } from "@connectrpc/connect";
import { OrderService } from "./gen/orders/v1/orders_pb";
export default (router: ConnectRouter) =>
router.service(OrderService, {
async getOrder(req, ctx) {
const order = await findOrder(req.id, ctx.requestHeader.get("authorization"));
if (!order) throw new ConnectError(`order ${req.id} not found`, Code.NotFound);
return { id: order.id, totalCents: BigInt(order.totalCents) };
},
});- Register the routes with @connectrpc/connect-fastify (fastifyConnectPlugin) or connectNodeAdapter; the same handlers then serve the gRPC, gRPC-Web and Connect protocols.
- Browsers call it with createConnectTransport (set useHttpGet: true for GET on side-effect-free methods); grpc-js or any gRPC client works server-side.
- Bidirectional streaming needs HTTP/2 end to end, which rules out most browsers and many proxies.
TypeScript · tRPC v11 Server
import { initTRPC, TRPCError } from "@trpc/server";
import { z } from "zod";
type Context = { user: { id: string } | null };
const t = initTRPC.context<Context>().create();
const authed = t.procedure.use(({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" });
return next({ ctx: { user: ctx.user } });
});
export const appRouter = t.router({
order: t.router({
byId: authed.input(z.object({ id: z.uuid() })).query(async ({ ctx, input }) => {
const order = await findOrder(input.id, ctx.user.id);
if (!order) throw new TRPCError({ code: "NOT_FOUND", message: "Order not found" });
return order;
}),
cancel: authed
.input(z.object({ id: z.uuid(), reason: z.string().max(200) }))
.mutation(({ ctx, input }) => cancelOrder(input.id, ctx.user.id, input.reason)),
}),
});
// Only the type crosses to the client: createTRPCClient<AppRouter>({ links: [httpBatchLink({ url: "/trpc" })] })
export type AppRouter = typeof appRouter;- Invalid input never reaches the handler; tRPC answers BAD_REQUEST with the validator error as cause. Any Standard Schema validator (Zod, Valibot, ArkType) works.
- Add .output(schema) to strip fields you did not mean to return; the inferred type alone does not stop a leaked column at runtime.
- Renaming a procedure compiles fine in the monorepo but breaks clients still running yesterday's bundle; keep old procedures until those sessions expire.
TypeScript · Express 5 + node-postgres Server
// GET /v1/orders?limit=50&cursor=eyJ0IjoiMjAyNi0xMS0wNl...
app.get("/v1/orders", async (req, res) => {
const limit = clampInt(req.query.limit, { min: 1, max: 100, fallback: 50 });
const after = decodeCursor(req.query.cursor); // { createdAt, id } | null | "invalid"
if (after === "invalid") {
return res.status(400).type("application/problem+json").json({
type: "https://api.example.com/problems/invalid-cursor",
title: "Invalid cursor", status: 400, detail: "Restart from the first page.",
});
}
const { rows } = await pool.query(
`SELECT id, total_cents, created_at FROM orders
WHERE tenant_id = $1 AND ($2::timestamptz IS NULL OR (created_at, id) < ($2, $3))
ORDER BY created_at DESC, id DESC LIMIT $4`,
[req.tenantId, after?.createdAt ?? null, after?.id ?? null, limit + 1],
);
const page = rows.slice(0, limit);
const next = rows.length > limit ? encodeCursor(page.at(-1)!) : null;
if (next) res.links({ next: `/v1/orders?limit=${limit}&cursor=${next}` });
res.json({ data: page, next_cursor: next, has_more: next !== null });
});
// -> { "data": [...50 orders], "next_cursor": "eyJ0Ijoi...", "has_more": true }- Fetching limit + 1 rows tells you whether another page exists without a COUNT(*). Index (tenant_id, created_at DESC, id DESC) so each page is a range scan.
- The (created_at, id) row comparison breaks ties, so rows with equal timestamps are neither skipped nor repeated.
- Keep cursors opaque (base64url JSON, optionally signed) so you can change the sort key later; clampInt, decodeCursor and encodeCursor are small app helpers.
WHEN TO REACH FOR WHAT
REST + OpenAPI
Use when Public, partner or multi-team APIs with resource-shaped data, where HTTP caching, curl-ability and broad tooling matter.
Avoid when Every screen needs a different nested shape and you are adding bespoke endpoints per view.
GraphQL
Use when Several clients (web, mobile, partners) compose data from many backends, and a team can own the schema, cost limits and persisted queries.
Avoid when A single client with a few simple screens, file-heavy or highly cacheable public content, or no capacity to run query cost controls.
gRPC / Connect
Use when Internal service-to-service calls across languages, with streaming, deadlines and strict schemas; Connect when browsers must call the same services.
Avoid when The consumers are third parties expecting JSON and curl, or you rely on CDN caching of responses.
tRPC
Use when One TypeScript monorepo where the frontend and backend deploy together and the API has no outside consumers.
Avoid when Mobile apps or other teams consume the API on their own release cycle, or non-TypeScript clients exist.
Webhooks / async events
Use when Other systems need to react when something happens (payment succeeded, order shipped) without polling.
Avoid when The caller needs an immediate answer; that is a request/response API. Webhooks still need retries, signatures, idempotency and a replay endpoint.
WHERE IT BITES
- Choosing GraphQL for one web client and then rebuilding HTTP caching, rate limiting by cost and error monitoring that REST would have given for free.
- Running a public GraphQL endpoint without depth, breadth or cost limits and without persisted queries, which lets a single crafted query exhaust the database.
- Returning 200 with { "error": ... } bodies from REST endpoints. Caches, retries, monitoring and client libraries all key on the status code; use 4xx/5xx with application/problem+json.
- Reusing or renumbering protobuf fields. Old binaries decode the new data into the wrong field without any error.
- Treating tRPC's shared types as a versioned contract. They only guarantee compatibility for code compiled together, not for mobile apps or tabs open since the last deploy.
- Offset pagination on large, changing tables: deep pages get slower linearly and concurrent inserts make clients skip or duplicate items.
CLOSE THE AI. EXPLAIN THIS.
A mobile app, a web dashboard and two partner integrations need order data, and the backend is split into three services. Propose an API style for each boundary and justify it in terms of contract, caching, error model and versioning.WHEN IT BREAKS IN PRODUCTION
SOURCES
- RFC 9457: Problem Details for HTTP APIs (opens in new tab)Checked
- OpenAPI Specification v3.2.0 (opens in new tab)Checked
- GraphQL: Performance (opens in new tab)Checked
- DataLoader README (opens in new tab)Checked
- Connect: Get requests and caching (opens in new tab)Checked
- gRPC-Web (opens in new tab)Checked
Explainer reviewed