HTTP & APIs in Practice
HTTP & APIs in Practice

REST vs RPC vs gRPC (and where tRPC and GraphQL fit)

Lesson · ~25 min
After this unit you can
  • Tell a resource-shaped API from a procedure-shaped one by its endpoints, and say what each gets from HTTP
  • Explain what gRPC adds — a .proto contract with generated clients, HTTP/2, four call types, deadlines — and why browsers need gRPC-Web and a translating proxy
  • Explain how tRPC v11 shares a contract without code generation, and what that requires of every client
  • Choose REST, gRPC, tRPC or GraphQL from who calls the API, and justify the choice

Read

Simple

Nouns or verbs — the same feature, two API shapes

Flag

Here is one feature — orders you can list, create, read, change and cancel — designed twice.

Resource-shaped (what people usually mean by "a REST API"): URLs name things, and the HTTP method says what to do to them.

GET    /orders?status=open          list
POST   /orders                      create      -> 201 Created, Location: /orders/43
GET    /orders/43                   read
PATCH  /orders/43                   change some fields
DELETE /orders/43                   remove
POST   /orders/43/cancellation      cancel (an action modelled as a thing you create)

Procedure-shaped (RPC — remote procedure call): URLs name functions, and almost every call is a POST with the arguments in the body.

POST /rpc/listOrders       {"status":"open"}
POST /rpc/createOrder      {"sku":"TEA-1","qty":2}
POST /rpc/getOrder         {"id":43}
POST /rpc/cancelOrder      {"id":43,"reason":"duplicate"}

Neither is wrong. They put the meaning in different places, and that decides what the infrastructure around you can do without reading your docs.

What the resource shape gets from HTTP

  • Caching. A GET response is cacheable by browsers, CDNs and proxies (RFC 9110, section 9.3.1). POST /rpc/getOrder is a POST, and a cache never answers a POST with a stored response (section 9.3.3), so every read goes to the server.
  • Retries. Clients and proxies know GET, PUT and DELETE are idempotent and can resend them after a dropped connection. Every RPC call looks like a non-idempotent POST.
  • Generic tooling. Status codes, Location, ETag, logs grouped by method and path, curl commands anyone can read — all of it works without knowing your API.

What the procedure shape gets

  • Names that match the code. cancelOrder(id, reason) on the client is POST /rpc/cancelOrder on the wire. No debate about whether a cancellation is a resource.
  • One obvious way to add an operation: add a function. Resource APIs need a design conversation for every verb that isn't create/read/update/delete.

That trade-off — HTTP's shared meaning versus the code's own vocabulary — runs through every choice in this unit. gRPC and tRPC are both procedure-shaped; they differ in how the procedure's contract reaches the client.

Medium
1 / 2

gRPC on one page — contract, wire, streams, browsers

Flag

gRPC is procedure-shaped RPC with a contract first. You describe the service in a .proto file — Protocol Buffers is gRPC's default interface definition language — and the protobuf compiler's gRPC plugins generate a typed client and a server skeleton in each language you use.

syntax = "proto3";
package shop.v1;

service Orders {
  rpc GetOrder (GetOrderRequest) returns (Order);                      // unary
  rpc WatchOrders (WatchOrdersRequest) returns (stream OrderEvent);    // server streaming
}

message GetOrderRequest { int64 id = 1; }
message Order { int64 id = 1; string status = 2; }
message WatchOrdersRequest { string status = 1; }
message OrderEvent { Order order = 1; }

The numbers (= 1, = 2) are the contract on the wire: a field number "cannot be changed once your message type is in use", and a deleted field's number must be reserved so nobody reuses it. That's how a Go server and a Java client deployed months apart still agree.

On the wire: always HTTP/2, always POST

Every call is an HTTP/2 request with :method POST, :path /shop.v1.Orders/GetOrder and content-type: application/grpc. The body is a stream of length-prefixed protobuf messages — binary, so curl shows you bytes, not JSON. The result travels in trailers after the body: grpc-status: 0 is OK, anything else is an error. An error response still uses HTTP status 200, which is why gRPC servers answer 415 to clients that don't speak gRPC, so a plain HTTP client doesn't mistake a gRPC error for success.

What it gives you beyond "JSON over POST"

  • Four call types: unary (one request, one response), server streaming, client streaming, and bidirectional streaming — both sides sending on one long-lived call.
  • Deadlines. The client says how long it will wait (sent as a grpc-timeout header); past it, the call ends with DEADLINE_EXCEEDED.
  • Cancellation from either side ends the call at once. It doesn't stop your server code, though: gRPC "does not have a mechanism to interrupt the application-provided server handler", so a long handler has to check for cancellation and stop itself. And "changes made before a cancellation are not rolled back".
  • Generated clients in every supported language, from the one .proto.

The browser problem

A browser can't make a native gRPC call: fetch gives you no control over HTTP/2 framing and no access to trailers. gRPC-Web is a variant protocol for browsers, and something has to translate it to real gRPC: usually a proxy in front of the service — Envoy is grpc-web's default, and its README lists others such as Nginx — or a server framework with gRPC-Web support built in (the README names Armeria and Tonic). The grpc-web client supports only unary and server-streaming calls (server streaming only in its grpcwebtext mode); client and bidirectional streaming are "not currently supported".

So gRPC shines between services you own: many languages, high call volume, streaming, a contract the compiler enforces. For browsers and third-party developers, expose a JSON/HTTP API instead (or in front of it).

Complex

Choosing by who calls you — and where GraphQL fits

Flag

The useful question isn't "which style is best" but "who has to understand this contract, and what do they already have?"

CallersReach forBecause
Third-party developers, partners, any languageREST (resource-shaped JSON, described with OpenAPI)They have curl and an HTTP library, nothing else of yours. Caching, retries and status codes work without your docs
Your own services in several languages, high volume, streaminggRPCOne .proto generates every client; HTTP/2 streams, deadlines and binary protobuf; a contract the compiler checks across languages
One TypeScript app and its own backend, same repo, same deploytRPCThe router's type is the contract — no schema file, no generator, compile errors on mismatch
Many client screens wanting different slices of a connected graph of dataGraphQL (below)Clients ask for exactly the fields they need in one round trip

Mixed answers are normal: gRPC between internal services and a REST edge for the public; tRPC for the web app and a small REST surface for a partner integration.

The three questions that decide it

  1. Can the caller share your build? If yes (same repo, TypeScript), tRPC's type sharing is nearly free. If no, the contract must be a document both sides can read: OpenAPI or .proto.
  2. Does HTTP's shared meaning pay for itself? Public reads that a CDN can cache, links people paste into curl, retries by generic clients — that's resource-shaped HTTP. Service-to-service calls inside one platform rarely need any of it.
  3. Is streaming part of the feature? gRPC has it in the contract (server, client and bidirectional streams). Plain HTTP APIs reach for SSE or WebSockets — the subject of this track's last unit.

GraphQL in one paragraph

A GraphQL server exposes one endpoint (usually /graphql) and a typed schema; the client sends a query naming exactly the fields it wants, across related objects, and gets that shape back. It fixes over- and under-fetching for many varied clients. The costs move to the server: every operation goes to the same URL, usually as a POST (GET is allowed only for queries, and long queries outgrow URL limits), so HTTP caching needs extra machinery such as persisted documents; resolvers can turn one query into N+1 backend calls unless you batch them (DataLoader); and arbitrary queries need depth and complexity limits. Partial failures come back as an errors array next to data — with a 2xx status when data is present, since HTTP has no "partial success" code.

Quiz

An API has POST /api/getInvoice, POST /api/sendInvoice and POST /api/voidInvoice. What shape is it, and what does that cost?

Flag

A product catalogue is read millions of times a day through a CDN. Which endpoint can the CDN cache with standard HTTP rules and no extra machinery?

Flag

A gRPC call fails with status NOT_FOUND. What HTTP status code does the response carry?

Flag

Your React app wants to call an internal gRPC service directly from the browser. What does that take?

Flag

You delete string note = 3; from a protobuf message that deployed services still exchange. What should you do with the number 3?

Flag

What does tRPC require that a REST + OpenAPI design doesn't?

Flag

A payments company has (a) a public API for merchants in many languages, (b) a dozen internal Go, Java and Python services that stream events to each other, and (c) its own Next.js dashboard backed by a TypeScript server in the same repo. Which mapping fits best?

Flag

A GraphQL response arrives with HTTP 200 and a body containing both data and an errors array. What happened?

Flag

Fill in the blank

Make WatchOrders a server-streaming call, and retire the deleted note field's number for good.

Flag
service Orders {
  rpc GetOrder (GetOrderRequest) returns (Order);
  rpc WatchOrders (WatchOrdersRequest) returns ( OrderEvent);
}

message Order {
  int64 id = 1;
   2;            // was: string note = 2;
  string status = 3;
}

Give the client the server's contract without importing any server code.

Flag
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import  { AppRouter } from "../server/appRouter";

const trpc = createTRPCClient<>({
  links: [httpBatchLink({ url: "/trpc" })],
});

One procedure reads, one writes. Pick the procedure types (they decide GET vs POST on the wire).

Flag
export const appRouter = t.router({
  userById: t.procedure.input(z.string()).(({ input }) => db.users.find(input)),
  userCreate: t.procedure
    .input(z.object({ name: z.string() }))
    .(({ input }) => db.users.create(input)),
});

Further reading