"Should we use gRPC for this?" comes up in almost every microservices design discussion I've been part of. The question is usually asked as if gRPC were an alternative to HTTP — it isn't. gRPC runs on HTTP/2. The real comparison is between two API styles: the resource-oriented HTTP+JSON APIs most of us build by default, and the contract-first RPC style that gRPC brings. Once you see what each one actually puts on the wire, the "when to use which" question mostly answers itself.
HTTP APIs: the default for a reason
A plain HTTP API (REST-style or otherwise) is text-based, human-readable, and works with every tool ever made:
POST /users HTTP/1.1
Host: api.example.com
Content-Type: application/json
{ "name": "Saikat", "email": "[email protected]" }
HTTP/1.1 201 Created
Location: /users/42
Content-Type: application/json
{ "id": 42, "name": "Saikat", "email": "[email protected]" }
The strengths are enormous and easy to underrate:
- Universality. Every language, browser, CDN, proxy, load balancer, API gateway, and monitoring tool speaks HTTP+JSON natively.
curland your browser's network tab are your debuggers. - Human-readable payloads. You can read the request in a log and know exactly what happened.
- Loose coupling. The client needs no generated code — just the docs (or an OpenAPI spec if you want typed clients anyway).
- Cacheability.
GETresponses can be cached by CDNs and browsers using standard HTTP semantics. No RPC framework gives you this for free.
The weaknesses only show up at scale or on hot paths:
- JSON is verbose. Field names are repeated in every message; numbers are ASCII text. A payload that's 500 bytes in JSON might be 100 bytes in a binary format.
- Serialization cost. Parsing JSON and validating it against a schema burns CPU on both ends — a real cost when a request fans out to five internal services.
- No contract by default. Nothing stops a server from renaming a field and silently breaking clients. OpenAPI helps, but it's opt-in and often drifts from the implementation.
- HTTP/1.1 head-of-line blocking. One TCP connection processes one request at a time, so clients open connection pools and pay the overhead. (HTTP/2 fixes this — more below.)
What gRPC actually is
gRPC is Google's RPC framework, open-sourced in 2015. It's three ideas composed together:
- Protocol Buffers as the interface definition language and wire format,
- HTTP/2 as the transport,
- Generated code for clients and servers in every major language.
You start with a .proto contract:
syntax = "proto3";
package users;
service UserService {
rpc GetUser (GetUserRequest) returns (User);
rpc CreateUser (CreateUserRequest) returns (User);
rpc WatchUserEvents (WatchRequest) returns (stream UserEvent);
}
message User {
int64 id = 1;
string name = 2;
string email = 3;
}
message GetUserRequest {
int64 id = 1;
}
From this single file, protoc generates a typed client and server stubs for TypeScript, Go, Python, Java, and dozens of other languages. The contract isn't documentation that might drift — it is the code. If the server changes the contract, the client fails to compile, not at 2 a.m. in production.
The wire format difference
That User message as JSON is ~60 bytes of text with every field name spelled out. As a protobuf binary it's ~20 bytes: field numbers (the = 1, = 2 tags) replace field names, integers are varint-encoded, and there's no whitespace or quoting. Multiply that difference by millions of requests per day across chained service calls and it's real bandwidth and real CPU — protobuf encoding/decoding is typically several times faster than JSON parsing.
The trade: you can no longer read the payload with your eyes. grpcurl and reflection help, but debugging is genuinely less convenient than a browser network tab.
Why HTTP/2 matters
gRPC requires HTTP/2, and gets three big wins from it:
- Multiplexing. Many concurrent requests share one TCP connection with no head-of-line blocking at the HTTP layer. No connection pools, fewer TCP/TLS handshakes.
- Binary framing. Messages are length-prefixed frames, which is what makes streaming possible.
- Header compression (HPACK). Repeated metadata (auth tokens, tracing headers) costs almost nothing after the first request.
Worth noting: plain HTTP APIs can use HTTP/2 too — most CDNs and modern servers already do. But gRPC depends on it and is designed around it, particularly for streaming.
The four gRPC call types
This is where gRPC pulls structurally ahead of request/response HTTP. A .proto service can declare four shapes of call:
| Type | Signature | Typical use |
|---|---|---|
| Unary | rpc Get(Req) returns (Res) |
Ordinary request/response — like a POST |
| Server streaming | rpc Watch(Req) returns (stream Res) |
Live feeds, progress updates, change notifications |
| Client streaming | rpc Upload(stream Req) returns (Res) |
Chunked uploads, metric ingestion |
| Bidirectional streaming | rpc Chat(stream Req) returns (stream Res) |
Chat, multiplayer sync, interactive sessions |
With plain HTTP you'd reach for WebSockets, server-sent events, or long polling to get these shapes — each a separate protocol with its own auth story, its own reconnection logic, and no typed contract. In gRPC, a stream is just another method on the service, fully typed, over the same connection as everything else.
Deadlines, cancellation, and status codes
Two gRPC features deserve special mention because HTTP has no real equivalent:
Deadlines are propagated. A gRPC client attaches a deadline to every call (deadline: 200ms). If service A calls B, which calls C, the remaining time travels with the request — when the deadline expires, the entire chain cancels, including in-flight database work in C. With HTTP APIs you get this only if you build timeout propagation by hand, and almost nobody does; the usual result is a user who gave up 10 seconds ago while three services keep grinding on their abandoned request.
Status codes are RPC-shaped. Instead of overloading HTTP's document-oriented codes, gRPC has its own: INVALID_ARGUMENT, NOT_FOUND, ALREADY_EXISTS, RESOURCE_EXHAUSTED, UNAVAILABLE, DEADLINE_EXCEEDED. The useful part is that they're classified for retries — client libraries and service meshes know UNAVAILABLE is retryable and INVALID_ARGUMENT isn't, without any custom logic.
Where gRPC hurts
Every one of these has bitten a real project, so weigh them honestly:
- Browsers can't speak native gRPC. The fetch API doesn't expose the HTTP/2 framing gRPC needs, so browser clients require gRPC-Web plus a proxy (usually Envoy) that translates — an extra moving part, and bidirectional streaming doesn't survive the translation.
- Debugging is harder. No pasting a URL into a browser, no eyeballing payloads in logs. You'll want
grpcurl, server reflection enabled in dev, and good observability from day one. - Load balancing needs care. HTTP/2's long-lived multiplexed connections defeat naive L4 load balancers — one client can pin all its traffic to one backend. You need L7 (gRPC-aware) balancing: Envoy, Linkerd, or client-side balancing.
- The toolchain is heavier.
.protofiles need a home (usually a shared repo or a Buf registry), codegen needs wiring into every service's build, and schema changes need discipline (never reuse field numbers, only add optional fields). - Public APIs mostly still want JSON. Third-party consumers expect
curl-able endpoints and OpenAPI docs. Forcing protobuf on external partners is a support burden.
A gRPC microservice in NestJS
NestJS treats gRPC as just another transport, which makes hybrid setups easy. The server:
// main.ts
const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, {
transport: Transport.GRPC,
options: {
package: 'users',
protoPath: join(__dirname, 'proto/users.proto'),
url: '0.0.0.0:50051',
},
});
await app.listen();
// users.controller.ts
@Controller()
export class UsersController {
@GrpcMethod('UserService', 'GetUser')
getUser(data: GetUserRequest): Promise<User> {
return this.usersService.findById(data.id);
}
}
And the consuming service gets a typed client:
@Injectable()
export class UsersClient implements OnModuleInit {
private userService: UserServiceClient;
constructor(@Inject('USERS_PACKAGE') private client: ClientGrpc) {}
onModuleInit() {
this.userService = this.client.getService<UserServiceClient>('UserService');
}
getUser(id: number) {
return firstValueFrom(this.userService.getUser({ id }));
}
}
The pattern I keep shipping: a NestJS HTTP API gateway facing the outside world, gRPC between the internal services behind it. External consumers get JSON and OpenAPI; internal hops get typed contracts, small payloads, and deadline propagation.
Head-to-head summary
| HTTP + JSON API | gRPC | |
|---|---|---|
| Transport | HTTP/1.1 or HTTP/2 | HTTP/2 (required) |
| Payload | JSON — text, human-readable | Protobuf — binary, compact |
| Contract | Optional (OpenAPI), can drift | Mandatory (.proto), enforced by codegen |
| Typical latency/CPU | Higher (text parsing, bigger payloads) | Lower (binary, multiplexed) |
| Streaming | Bolt-on (WebSockets, SSE) | Built-in, four call types |
| Browser support | Native | Via gRPC-Web + proxy |
| Caching | Standard HTTP/CDN caching | None built-in |
| Debugging | curl, browser devtools, readable logs | grpcurl, reflection, more setup |
| Load balancing | Any | Needs L7 / gRPC-aware |
| Timeout propagation | Manual | Built-in deadlines |
| Best at | Public APIs, browser clients, CRUD | Internal service-to-service, streaming, polyglot |
How I actually choose
My decision process, compressed:
- Is a browser or third party calling it? → HTTP+JSON. Full stop. The ecosystem fit is worth more than any performance gain.
- Is it internal service-to-service in a polyglot or growing system? → gRPC. The enforced contract alone pays for the toolchain, and you get deadlines and streaming as a bonus.
- Do you need real-time streams between services? → gRPC. Bidirectional streaming with types beats hand-rolled WebSocket protocols.
- Is it a small team with three internal services all in TypeScript? → Honest answer: plain HTTP is fine. gRPC's overhead (proto management, codegen, L7 balancing) is a tax you should pay only when the benefits compound — many services, multiple languages, hot paths.
- Latency-critical hot path with chained calls? → gRPC, and measure. The wins are real but workload-dependent; benchmark your payloads before selling the migration on performance alone.
Wrapping up
HTTP+JSON and gRPC aren't rivals so much as tools tuned for different audiences. HTTP APIs optimize for reach — anything can call them, anyone can debug them, any CDN can cache them. gRPC optimizes for efficiency and rigor between machines you control — binary payloads, enforced contracts, first-class streaming, propagated deadlines.
That's why the pattern you see everywhere — and the one I default to — is both at once: JSON at the edge, gRPC on the inside. Use each where its trade-offs are strengths, and don't migrate internal traffic to gRPC until the pain it solves (contract drift, payload cost, streaming hacks) is pain you've actually felt.