How tRPC, gRPC and REST define an API contract
Each approach puts the source of truth for the contract in a different place, and that choice drives most of the trade-offs in later sections.
tRPC has no schema file. You define procedures on a server router, usually with a runtime validator for inputs, and TypeScript infers input and output types from that code. The client imports only the router's type (import type { AppRouter }). The import is erased at compile time, so no server code ships to the client. The server implementation is the contract. That only works when client and server share a TypeScript build, typically in a monorepo.
gRPC starts from a .proto file that declares services, RPC methods and messages with numbered fields. A compiler generates client stubs and server interfaces for each target language. The .proto file is the contract, and the field numbers in it, not the field names, define the Protocol Buffers wire format.
REST has no built-in contract. Conventions carry the meaning: resource paths, HTTP methods, status codes, and usually JSON bodies. An OpenAPI document can formalize this, and you can generate types from it or generate it from the code. Either way, the document is optional and can drift from the implementation unless CI enforces it.
| tRPC | gRPC | REST | |
|---|---|---|---|
| Source of truth | Server router code | .proto file |
Convention, optionally OpenAPI |
| Schema file | None | Required | Optional |
| Codegen step | None (type inference) | Required | Optional |
| Client languages | TypeScript | Many | Any HTTP client |
// server/router.ts: the router code is the contract (tRPC v11 API)
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.string() })) // runtime validation + inferred input type
.query(({ input }) => ({ id: input.id, name: 'Ada', active: true })),
});
// Clients get only this type, inferred from the implementation above.
export type AppRouter = typeof appRouter;
// client/users.ts: no codegen, just a type-only import
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/router'; // erased at compile time
const client = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: '' })],
});
// Result is typed as { id: string; name: string; active: boolean }
const user = await client.getUser.query({ id: 'u_123' });
/* Equivalent gRPC contract: user.proto is the source of truth,
and stubs are generated from it for each language.
syntax = "proto3";
package users.v1;
service UserService {
rpc GetUser (GetUserRequest) returns (User);
}
message GetUserRequest { string id = 1; }
message User {
string id = 1; // field numbers, not names, define the wire format
string name = 2;
bool active = 3;
}
*/
The same GetUser operation defined two ways: as a tRPC router whose inferred type the client imports directly, and as a .proto service from which gRPC code is generated.
Why each approach is used
Each style exists to solve a different problem, and teams adopt it for that reason rather than for raw performance.
tRPC is used to remove the gap between server and client types in a TypeScript codebase. Without it, a team typically writes handler types on the server, copies or regenerates matching types on the client, and hopes they stay in sync. tRPC eliminates that step: change a procedure, and every caller that compiles against it either still type-checks or fails the build. There is no schema file to maintain, no generator to run and no generated code to review. The appeal is short feedback loops, with editor autocompletion and refactoring that work across the client-server boundary.
gRPC is used when a contract has to hold across languages, teams and deploy schedules. The .proto file is neutral, so a Go service and a TypeScript service can share it without either side owning the other's type system. Teams also choose it for capabilities that plain HTTP and JSON don't provide out of the box: streaming in both directions, deadlines and cancellation that propagate across hops, and well-defined rules for evolving messages without breaking older binaries.
REST is used because it is the lowest common denominator. Any language, tool, proxy, cache and browser already understands HTTP methods, status codes and JSON. It needs no special client library, it maps cleanly onto HTTP caching, and it is the format most external consumers expect. With OpenAPI, it gains a machine-readable contract without giving up that universality.
Who should use each approach
tRPC suits a single team, or a small group of closely coordinated teams, that owns both the client and the server, writes both in TypeScript and builds them together. The typical case is a full-stack TypeScript application in a monorepo: a web frontend and its backend, or a backend-for-frontend and the UI it serves. It also works for internal TypeScript-only services that are always released in lockstep.
gRPC suits platform and backend teams running many services, especially in more than one language, that deploy independently and need a contract enforced by tooling rather than by convention. It is a good fit for organizations that are willing to run a schema repository, codegen in CI and breaking-change checks, and whose infrastructure supports HTTP/2 end to end. Teams with real streaming requirements, such as event feeds, long-running jobs that report progress or bidirectional sessions, benefit most.
REST suits teams whose API has consumers they don't control: other departments, partners, third parties or a mix of languages and tools nobody has inventoried. It is also the pragmatic default for small services, simple CRUD and anything that benefits from HTTP caching. Teams that pick REST for internal APIs should pair it with an OpenAPI document checked in CI once more than one team depends on the API.
What type safety actually guarantees in each model
tRPC's guarantee comes from the compiler. The client imports the router's type, either through a shared monorepo package or a published type-only package. When a procedure's input or output changes, every TypeScript caller fails to build. That only works while both sides compile against the same type definitions. A Go or Python consumer gets nothing from it. To them, a tRPC endpoint is an HTTP route with an undocumented JSON shape.
Compile-time types also say nothing about the bytes that arrive at runtime. Types are erased during compilation, so a stale client, a curl request or a misbehaving proxy can send any payload. For this reason tRPC procedures declare an input parser. A typical procedure passes a schema object to .input() that requires orderId to be a UUID and quantity to be a positive integer. The handler then receives an already-validated, correctly typed value, and malformed requests are rejected before any business logic runs.
Protobuf does enforce structure at decode time, but only structure. It checks field types. It does not check whether a quantity is positive or an email is well formed.
The TypeScript ergonomics also need attention:
- Defaults hide missing data. In proto3, an unset scalar decodes as
0,""orfalse, so a missing value looks the same as a deliberate zero unless the field is declaredoptional. - Optional fields produce
T | undefinedtypes that differ between code generators. int64can exceed JavaScript's safe integer range. Depending on the generator, it surfaces as a string, abigintor a wrapper type.
REST with OpenAPI has the same gap. A spec describes the payload but does not enforce it, so you still need explicit validation at the handler.
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
// Runtime schema: this check runs on every request, regardless of
// what the caller's TypeScript types claimed at compile time.
const placeOrderInput = z.object({
orderId: z.string().uuid(),
quantity: z.number().int().positive(),
});
export const appRouter = t.router({
placeOrder: t.procedure
.input(placeOrderInput)
.mutation(({ input }) => {
// By the time we get here, input has passed validation and is typed
// as { orderId: string; quantity: number }. A payload like
// { quantity: -3 } from curl or a stale client is rejected with a
// BAD_REQUEST error before this handler runs.
return { orderId: input.orderId, accepted: true };
}),
});
// Clients import only this type (from a shared package). It is erased
// at build time, so it protects TypeScript callers during compilation
// and gives nothing to Go, Python or raw HTTP consumers.
export type AppRouter = typeof appRouter;
A tRPC procedure that pairs the compile-time router type with a Zod input schema, so malformed payloads are rejected at runtime before the handler executes.
What gRPC provides beyond type safety, and when it is worth the cost
gRPC gives you more than a typed contract. Messages are encoded as binary Protobuf, which is smaller and usually cheaper to parse than JSON. Calls run over HTTP/2, so many requests share one connection. You get server, client and bidirectional streaming without inventing a framing protocol. Deadlines travel with the call, so a downstream service knows how much time is left and can stop work once the caller has given up, and cancellation propagates the same way. Generated clients exist for most mainstream languages, which matters once a Go or Python team needs to call your TypeScript service.
For most internal APIs, the encoding speed is the weakest argument. Request time is dominated by database queries and network hops, not serialization.
Illustrative estimate (assumed numbers, not a benchmark):
- Database query: 15 ms
- Network round trip: 1 ms
- JSON encode and decode: 0.3 ms
- Protobuf encode and decode: 0.1 ms
Savings share = (t_json − t_proto) / t_total = (0.3 − 0.1) / 16.3 ≈ 1.2%
Plug in your own measured values. The share only becomes meaningful when payloads are large or calls are very chatty.
The cost shows up in the build pipeline. You need protoc or an equivalent toolchain in every build. You have to decide whether generated code is committed or regenerated in CI. You also need a way to share versioned .proto files across repositories, either through a dedicated schema repo or a published package. gRPC earns that overhead when you need streaming, deadline propagation or polyglot clients.
Schema evolution and backward compatibility across independent deploys
Protobuf makes compatibility a property of the wire format. Each field is identified by its number, not its name, so a reader built against an older schema skips numbers it doesn't recognize. Adding a field with a fresh number is safe in both directions. Old services ignore it, and new services see the type's default value when an older peer omits it. Removing a field is safe only if you reserve both its number and its name. Otherwise a later edit can reuse number 4 with a different type, and old binaries will decode new bytes as garbage instead of failing. The example below shows a removed legacy_region field held in reserved alongside a newly added tier field. Linting and breaking-change checks in CI can enforce these rules mechanically.
tRPC has no wire-level contract. The contract is the router's inferred TypeScript type, and it exists only at compile time. Suppose you rename user.getById to user.byId. Every caller in the monorepo updates and type-checks cleanly. But a client deployed yesterday still sends requests to the old procedure path and gets a not-found error. Tightening an input validator breaks old clients the same way. The safe pattern is to keep the old procedure as an alias, deploy the server first, and delete the alias only after every client has rolled forward.
REST sits between the two. Additive response fields and tolerant readers handle most change. Breaking changes go to a new version, signalled in the URL (/v2/) or in a request header.
| Approach | Safe change | Breaking change | Enforced by |
|---|---|---|---|
| gRPC/Protobuf | New field number | Reused or retyped number | Schema rules, CI checks |
| tRPC | New procedure or optional input | Rename, stricter input | Discipline only |
| REST | Additive fields | Removed or renamed fields | Versioning convention |
// user.proto: evolving the User message without breaking deployed peers
syntax = "proto3";
package internal.user.v1;
message User {
// Field 4 (legacy_region) was removed. Reserving both the number and
// the name makes protoc reject any later edit that reuses either one.
// In proto3, numbers and names go in separate reserved statements.
reserved 4;
reserved "legacy_region";
string id = 1;
string email = 2;
string display_name = 3;
// Added in a later release with a fresh number.
// Older readers skip field 5 as an unknown field.
// Newer readers see TIER_UNSPECIFIED when an older writer omits it.
Tier tier = 5;
}
enum Tier {
// Zero is the proto3 default. Keep it meaningless so that
// "not set" stays distinguishable from a real tier.
TIER_UNSPECIFIED = 0;
TIER_FREE = 1;
TIER_PRO = 2;
}
A Protobuf message that reserves the number and name of the removed legacy_region field and adds a new tier field under an unused number, so binaries built against either version of the schema can still decode each other's messages.
Operations, observability and debugging
REST and tRPC run over ordinary HTTP with JSON bodies, so existing infrastructure handles them without changes. Proxies, load balancers, WAFs, access logs and browser devtools all show readable requests. You can replay a tRPC query with plain curl, and a REST call is just curl -i against the resource path.
gRPC needs HTTP/2 end to end. Load balancing is the most common problem. A layer-4 balancer distributes connections, not requests. Because gRPC clients hold one long-lived HTTP/2 connection and multiplex many calls over it, traffic can pin to a single backend. You need an L7 balancer, a mesh sidecar or client-side balancing. Browsers can't speak native gRPC, so they need gRPC-Web and a translating proxy. Streaming and long-lived connections also fit poorly on some serverless platforms and with some idle-timeout settings.
Payloads are binary protobuf frames, so you need schema-aware tools to read them. With server reflection enabled, grpcurl -plaintext localhost:50051 list shows the available services. grpcurl -plaintext -d '{"id": 1}' localhost:50051 user.v1.UserService/GetUser makes a call. Without reflection, you pass the .proto files to grpcurl yourself.
Errors also surface differently, and this affects dashboards and alerts:
| Model | Where the error lives | Example |
|---|---|---|
| REST | HTTP status plus a body format you define | 404, 503 |
| tRPC | TRPCError code, mapped to an HTTP status |
NOT_FOUND → 404 |
| gRPC | grpc-status trailer, usually on HTTP 200 |
NOT_FOUND (5), UNAVAILABLE (14) |
The gRPC row matters most. Metrics that count only HTTP status codes will report failed gRPC calls as successes.
curl -i
curl -i ''
curl -s -o /dev/null -w '%{http_code}\n'
grpcurl -plaintext localhost:50051 list
grpcurl -plaintext localhost:50051 describe user.v1.UserService
grpcurl -plaintext -v -d '{"id": 1}' \
localhost:50051 user.v1.UserService/GetUser
grpcurl -plaintext \
-import-path ./proto -proto user/v1/user.proto \
-d '{"id": 1}' \
localhost:50051 user.v1.UserService/GetUser
if ! grpcurl -plaintext -d '{"id": 999}' \
localhost:50051 user.v1.UserService/GetUser >/dev/null; then
echo "GetUser failed; check the gRPC status, not the HTTP status" >&2
fi
Inspecting the same lookup over REST and tRPC with curl, and over gRPC with grpcurl (with and without server reflection), showing where each model reports its error status.
Choosing by ownership: a decision framework
The right protocol depends less on payload size or latency than on who owns each end of the call. Work through these questions in order. The first answer that points firmly in one direction usually settles it.
| Question | If yes | If no |
|---|---|---|
| Are all consumers TypeScript, now and for the foreseeable future? | tRPC is a candidate | Choose gRPC or schema-first REST |
| Are client and server built and deployed together, such as in one monorepo with one pipeline? | tRPC fits well | Avoid tRPC, because its types exist only at compile time |
| Do you need bidirectional streaming or Go, Python or JVM clients? | gRPC | Streaming alone rarely justifies gRPC |
| Will teams outside yours, or third parties, call the API? | REST with an OpenAPI schema | Any of the three |
| Do responses benefit from CDN or browser HTTP caching? | REST | No constraint |
When each approach should be avoided
Avoid tRPC when services deploy independently or belong to different teams. Nothing enforces the contract at runtime, so a server change can break a client that was compiled against older types. The first Go, Python or JVM consumer also forces you to add a second contract layer, such as an OpenAPI or protobuf definition, alongside the TypeScript types. At that point you are maintaining two sources of truth. tRPC is also a poor choice for public or partner APIs, where consumers expect a documented, language-neutral contract and conventional resource URLs.
Avoid gRPC for browser-heavy clients, because browsers need gRPC-Web and a translating proxy. Also avoid it for simple CRUD, where code generation adds more friction than it removes, and on infrastructure whose load balancers or proxies lack end-to-end HTTP/2 support. Choosing gRPC for speed alone is a weak reason, since serialization is rarely the bottleneck in an internal service.
REST without a formal schema works until a second team relies on it. After that, field names, nullability and error shapes drift with no build step to catch the change. Avoid schemaless REST for any API with more than one consuming team, and avoid REST in general when you need bidirectional streaming or deadline propagation across several hops, because you would have to build those mechanisms yourself.
Alternatives to tRPC, gRPC and REST
These three are not the only options for internal TypeScript service APIs. Depending on the problem, one of the following may fit better:
- GraphQL. A single endpoint with a typed schema that lets clients request exactly the fields they need. It suits aggregation layers where many frontends query overlapping data from several backends. The costs are a resolver layer, query-complexity limits to protect the server, and weaker fit with plain HTTP caching, because most queries are sent as POST requests to one URL.
- JSON-RPC. A small, transport-agnostic specification for method calls encoded as JSON. It gives you RPC-style naming without code generation or HTTP/2 requirements, but it defines no schema, so you still need shared types or a separate contract for safety.
- Asynchronous messaging through a broker or queue. For work that doesn't need an immediate answer, such as order processing, notifications and data fan-out, publishing events decouples producers from consumers in both deploy timing and availability. The contract moves to the message schema, which needs the same evolution discipline described for Protobuf, and you take on retries, ordering and idempotency concerns.
- Server-Sent Events or WebSockets. If streaming is the only reason you're considering gRPC, these HTTP-based mechanisms deliver server push (SSE) or bidirectional messaging (WebSockets) to browsers without gRPC-Web or a translating proxy. They provide transport only, not a typed contract.
- A shared TypeScript types package with a thin HTTP client. For a small number of TypeScript services, publishing request and response types plus a typed fetch wrapper gives most of tRPC's editor experience over ordinary REST routes. Pair it with runtime validation on the server, because the shared types offer no runtime guarantees.
Hybrid architectures and their impact on cost and engineering time
Most organizations that outgrow a single style end up with layered boundaries like these:
Browser / mobile app
│ tRPC (shared TS types, same monorepo)
▼
Backend-for-frontend (TypeScript)
│ gRPC (.proto contracts, generated stubs)
▼
Internal services (TS, Go, Java, ...)
│
▼
Public / partner edge ── REST + OpenAPI
Each layer uses the cheapest contract that its consumers can tolerate. Between a frontend and its BFF, tRPC removes the schema file, the codegen step and the CI job that checks generated code is current. Producer and consumer ship together, so inferred types are enough. Between backend services, gRPC pays for itself once a second language appears. One .proto replaces a hand-written client per language, along with each client's own retry and serialization bugs. At the partner edge, OpenAPI codegen reduces drift between documentation and behavior. The cost is that generated clients can be verbose, unidiomatic, or several releases behind the spec when generation is not part of the release pipeline.
The hard decision is how many styles to allow. If you standardize on one, the platform team supports a single set of interceptors, auth middleware, tracing propagation and error mappings. The trade-off is that some boundaries get the wrong tool: gRPC exposed to browsers needs a proxy, and tRPC exposed to a Go team does not work at all. If you mix styles, you multiply those conventions, the dependency upgrades, and the ways a request can fail across protocol translations. A workable rule is to allow a new style only when a boundary's ownership model actually differs from the boundaries you already support, and to require that the new style share auth, tracing and error semantics with the existing ones.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to trpc vs grpc, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Consumers learn about a breaking change at review time rather than from production errors.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.