Modern RPC (gRPC & tRPC)
REST treats the network as a set of resources. RPC treats it as function calls. Both are valid; the trade-off is coupling versus ergonomics.
Two modern flavours dominate:
- gRPC: language-neutral, schema-first, HTTP/2, streaming.
- tRPC: TypeScript-only, no schema file, inferred types end to end.
gRPC
The contract
syntax = "proto3";
package orders.v1;
service OrderService {
rpc GetOrder(GetOrderRequest) returns (Order);
rpc WatchOrders(WatchRequest) returns (stream OrderEvent);
rpc UploadItems(stream Item) returns (UploadSummary);
}
message GetOrderRequest { string id = 1; }
message Order {
string id = 1;
string status = 2;
int64 total_cents = 3;
string currency = 4;
}
- proto3 is the schema; code is generated for every language.
- Field numbers are the wire contract — never reuse or renumber them.
- Add fields freely; remove/rename only after all clients migrate (use
reserved).
Transports
gRPC runs over HTTP/2, giving:
- multiplexed streams over one connection,
- binary framing and header compression,
- four call types: unary, server-streaming, client-streaming, bidirectional.
That makes it a good fit for service-to-service calls, large payloads, and streaming telemetry.
Deadlines and cancellation
Every call should carry a deadline:
const client = new OrderServiceClient(addr);
const order = await client.getOrder(
{ id: "ord_1" },
{ deadline: Date.now() + 500 },
);
Deadlines propagate: when the caller's budget expires, the server is cancelled. Without deadlines, a slow dependency holds connections indefinitely.
Errors
gRPC has its own status codes:
| Code | HTTP analogue | Retry? |
|---|---|---|
OK |
200 | — |
INVALID_ARGUMENT |
400 | no |
UNAUTHENTICATED |
401 | after refresh |
PERMISSION_DENIED |
403 | no |
NOT_FOUND |
404 | no |
ALREADY_EXISTS / ABORTED |
409 | re-read/retry |
RESOURCE_EXHAUSTED |
429 | backoff |
FAILED_PRECONDITION |
412 | no |
UNAVAILABLE |
503 | yes, with backoff |
DEADLINE_EXCEEDED |
504 | idempotent only |
INTERNAL |
500 | not blindly |
Return google.rpc.Status details for structured errors; map to your domain codes at the boundary.
Interceptors
Cross-cutting behaviour lives in interceptors (the middleware equivalent):
const auth: Interceptor = (options, nextCall) =>
new InterceptingCall(nextCall(options), {
start(metadata, listener, next) {
metadata.set("authorization", `Bearer ${token()}`);
next(metadata, listener);
},
});
Use them for auth, tracing, metrics, retries, and deadlines. Keep business logic out.
Load balancing and health
- Client-side LB (round robin/least request) needs service discovery.
- Server-side LB via proxies (Envoy) is common.
- Health checking:
grpc.health.v1.Health/Check. - Long-lived connections need keepalive pings and reconnect/backoff.
Browsers
Browsers cannot speak raw HTTP/2 gRPC. Options:
- grpc-web via a proxy (Envoy) — limited streaming.
- Connect protocol (gRPC-compatible over HTTP/1.1/JSON or binary).
- REST/JSON at the edge for public clients.
Versioning
- Package name carries the version (
orders.v1). - Add fields, never repurpose numbers.
- Reserve removed numbers/names.
- Run old and new services side by side during migration.
tRPC
tRPC removes the schema file by using TypeScript types directly:
export const appRouter = router({
order: router({
byId: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input, ctx }) => ctx.orders.findById(input.id)),
cancel: protectedProcedure
.input(z.object({ id: z.string(), reason: z.string().max(200) }))
.mutation(({ input, ctx }) => ctx.orders.cancel(input.id, input.reason)),
}),
});
export type AppRouter = typeof appRouter;
Client:
const order = await trpc.order.byId.query({ id: "ord_1" });
Benefits:
- end-to-end type safety with zero codegen;
- input validation with Zod (runtime + types);
- excellent DX in a TypeScript monorepo;
- works over HTTP (queries/mutations) and WebSockets (subscriptions).
Constraints:
- TypeScript only — no Python/Go/Java clients.
- The client is coupled to the server implementation package; not suitable for public APIs.
- Versioning is a shared-package/versioning problem, not a wire contract.
- Large routers can slow type-checking; split them.
Use tRPC for internal full-stack TypeScript products; use REST/gRPC where other languages or third parties are involved.
Choosing
| Need | Choose |
|---|---|
| Public API, broad clients, caching | REST |
| Service-to-service, streaming, polyglot | gRPC |
| Internal TS full-stack, fastest DX | tRPC |
| Legacy enterprise contract | SOAP |
| Browser push/live | SSE/WebSockets (or gRPC-web) |
Failure modes
- No deadlines → connection exhaustion and cascading timeouts.
- Retrying non-idempotent calls → duplicate writes; add idempotency keys.
- Field-number reuse → silent data corruption across versions.
- Blocking the event loop in Node gRPC servers → all calls stall.
- tRPC types drift between client/server versions → runtime shape mismatches; deploy together.
- Unbounded streaming without backpressure → memory growth.
Checklist
- Every call has a deadline/timeout and cancellation.
- Errors map to a documented status taxonomy.
- Field numbers are never reused; removed fields are reserved.
- Retries are limited to idempotent calls with backoff.
- Auth/tracing/metrics live in interceptors/middleware.
- Streaming has backpressure and size limits.
- Browser access uses grpc-web/Connect or a REST edge.
- The choice matches clients and team topology, not fashion.
Next: Real-Time APIs — polling, SSE, and when push is worth the complexity.