
Comparisons of these three protocols tend to cite specific performance multipliers without saying much about what was actually measured. One recent writeup put gRPC's median latency at roughly a third of REST's for the same operation. I wanted to know if that held up in the stack I actually ship — NestJS, on Node — so I built the same GET /users-shaped endpoint three times: once as a REST controller, once as a GraphQL resolver, once as a gRPC service. Same 100-record dataset, same machine, same load test harness as far as the transport allows.
REST won — and not narrowly. REST served roughly 2.8-3x more requests per second than gRPC on my machine, almost the exact inverse of the ratio quoted above. That's not me disputing the other measurement; it's a different stack, different data, different network conditions, and both can be true. The reasons why are more useful than either number. This post is the benchmark, the code, and what I'd actually pick and why.
The question everyone asks in the wrong order
Most REST vs gRPC vs GraphQL comparisons start with the protocol and work backward to your problem: here are three boxes, here's what's inside each one, pick a box. That's backward. The protocol is a consequence of decisions you've usually already made — who's calling this API, how many services sit behind it, and what your actual performance budget is — not an input to them.
So instead of another feature matrix, I built the same thing three ways and measured it. Not because benchmarks settle the argument — they don't, a synthetic localhost test is not your production traffic — but because "gRPC is faster" is a claim you can actually check, and checking it surfaces the real trade-offs better than restating them.
REST, gRPC, and GraphQL in 30 seconds each
REST sends and receives JSON over plain HTTP. Every resource is a URL, every operation is a verb, and the server always returns the full shape of whatever it decides a "user" is. It's the default because there's nothing to explain — any client, in any language, on any platform, can call it with nothing but an HTTP library.
gRPC replaces JSON with Protocol Buffers, a binary format, and plain HTTP/1.1 with HTTP/2. Every message is defined in a .proto schema up front, both sides generate strongly-typed client and server code from it, and calls run over persistent, multiplexed connections instead of a new request each time. It's built for service-to-service traffic where both ends are code you control.
GraphQL keeps JSON and HTTP but replaces fixed endpoints with a single query language: the client states exactly which fields it wants, and the server resolves only those fields. It trades REST's simplicity for the ability to shape the response per request — which matters most when one screen needs data assembled from several underlying sources.
What I actually built and measured
I built one dataset — 100 users, each with a nested address (street, city, zip) — and served it identically through three servers: Express for REST, graphql-yoga for GraphQL, and @grpc/grpc-js with a hand-written .proto schema for gRPC. All three read from the same in-memory array, so none of them do any real work beyond serializing and sending it — this measures transport and serialization overhead, not database time.
For load testing, REST and GraphQL got hit with autocannon — 10 concurrent connections, 10 seconds, keep-alive. Autocannon doesn't speak gRPC, so I wrote a matching harness: 10 concurrent workers calling the gRPC client in a loop for the same 10 seconds, timing each call with process.hrtime. Payload sizes came from a separate, deterministic measurement — no load test needed, just encoding the identical 100-record dataset as JSON versus Protobuf and comparing byte counts directly.
Everything ran on one machine, Node 22, localhost — meaning zero network latency, no TLS handshake cost, and no cross-language client. That matters for reading the results honestly, and I'll come back to it.
Payload size: the protobuf number that surprised me
The pitch for Protobuf is that binary encoding beats JSON's text encoding by a wide margin. Here's what encoding the same 100 users actually produced:

Protobuf came in at 8,922 bytes against REST's 16,415 — smaller, but by 1.84x, not the 3-11x figure that gets thrown around. I re-ran the same comparison at 2,000 records to check whether the ratio widens at scale: it didn't. Protobuf stayed at 1.81x smaller. That's because this dataset is mostly strings — names, emails, addresses — and Protobuf's real advantage is in numeric fields, where varint encoding beats JSON's decimal text representation by a much wider margin, and in not repeating field names on every record. A schema that's mostly IDs, timestamps, and enums will show a bigger gap than one that's mostly text. Know your data before you trust a generic ratio.
The bigger number on that chart is the one nobody markets: GraphQL asking for only id and name came back at 2,704 bytes — 83.5% smaller than asking for everything. That's not a protocol-level trick, it's just not fetching data you don't need, but it's the one place in this whole comparison where the savings are dramatic instead of incremental.
Throughput and latency: REST wins on my machine
Here's what 10 concurrent connections hammering each server for 10 seconds actually produced:

Protocol | 100 records: req/s | 100 records: avg / p95 latency | 2,000 records: req/s | 2,000 records: avg / p95 latency |
|---|---|---|---|---|
REST | 4,785 | 1.6 ms / 6 ms | 515 | 18.9 ms / 33 ms |
gRPC | 1,712 | 5.8 ms / 15 ms | 168 | 59.5 ms / 82 ms |
GraphQL | 1,624 | 5.7 ms / 14 ms | 133 | 74.4 ms / 95 ms |
REST beat gRPC by roughly 2.8x at 100 records and 3.1x at 2,000. Three honest reasons, not one:
@grpc/grpc-js is pure JavaScript. The performance case for gRPC is usually built on Go, C++, or Java implementations that call into a native core library. Node's official gRPC client is a pure-JS reimplementation — it doesn't get that native speed, and the well-known cross-language benchmarks don't transfer to it automatically.
Express serving static JSON is about as close to the metal as Node gets. There's no query parsing, no schema validation, no protobuf encode step — just JSON.stringify and a socket write. It's a low bar for the other two to clear, and in this test, neither did.
Zero network latency erases gRPC's actual advantage. gRPC's HTTP/2 multiplexing and binary framing pay off when you're crossing a real network with real round-trip time and many concurrent streams — that's the internal-microservices case it's built for. On localhost, there's no latency to hide behind, so the picture flips.
The 2,000-record run adds one more data point worth noting: gRPC's lead over GraphQL widened from a 5% edge to a 26% edge as payload size grew. GraphQL's per-field resolver execution scales with the number of fields resolved; gRPC's schema-fixed encode/decode scales more predictably. Neither caught REST.
Take this for what a single-machine benchmark is: a controlled comparison of Node-specific implementations, not a verdict on the protocols themselves. If you're calling gRPC services written in Go from a Node gateway over a real network, expect a very different result.
Wiring up each transport in NestJS
The benchmark used plain Express, graphql-yoga, and grpc-js directly — that's what let me measure transport overhead in isolation, since NestJS's own layer adds a small, roughly constant cost on top of all three. Here's the shape of the same endpoint idiomatically wired into NestJS.
REST is a controller, nothing more:
// users.controller.ts
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
findAll(): User[] {
return this.usersService.findAll();
}
}GraphQL, code-first with @nestjs/graphql, needs a resolver plus a typed object:
// users.resolver.ts
@Resolver(() => User)
export class UsersResolver {
constructor(private readonly usersService: UsersService) {}
@Query(() => [User])
users(): User[] {
return this.usersService.findAll();
}
}
// user.model.ts
@ObjectType()
export class User {
@Field(() => Int) id: number;
@Field() name: string;
@Field() email: string;
}gRPC is the one that needs real setup — a .proto file, a microservice bootstrap, and @GrpcMethod instead of a controller decorator, per NestJS's own docs:
// main.ts
const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, {
transport: Transport.GRPC,
options: {
package: 'users',
protoPath: join(__dirname, 'users.proto'),
},
});
await app.listen();
// users.controller.ts
@Controller()
export class UsersGrpcController {
constructor(private readonly usersService: UsersService) {}
@GrpcMethod('UserService', 'ListUsers')
listUsers(): { users: User[] } {
return { users: this.usersService.findAll() };
}
}That extra setup is the real cost of gRPC in NestJS — not runtime speed, but a schema file, a codegen or dynamic-load step, and a client that has to inject a ClientGrpc and call getService() instead of just hitting a URL:
// api-gateway/users.controller.ts (calling the gRPC service from a REST gateway)
@Controller('users')
export class GatewayUsersController implements OnModuleInit {
constructor(@Inject('USER_PACKAGE') private readonly client: ClientGrpc) {}
private userService: UserServiceClient;
onModuleInit() {
this.userService = this.client.getService<UserServiceClient>('UserService');
}
@Get()
findAll() {
return this.userService.listUsers({});
}
}REST needed one decorator. GraphQL needed a resolver and a typed model. gRPC needed a schema file, a second bootstrap function, and a client wrapper. That ordering held up in the benchmark too.
Where each one actually wins
REST | gRPC | GraphQL | |
|---|---|---|---|
Best for | Public APIs, simple CRUD, anything a stranger's HTTP client needs to call | Internal service-to-service calls where both ends are your code | Frontends assembling data from several sources with different shapes |
What it costs you | Over-fetching, one endpoint per shape | A schema file, codegen, and — in Node specifically — a slower client than the marketing suggests | Resolver complexity, N+1 query risk, a slower ceiling under load |
What my numbers back up | Fastest raw throughput, by a wide margin, in this stack | Modest payload savings on this data shape; the throughput case needs a real network to prove out | The only place a "protocol" choice cut payload size dramatically — by not asking for fields you don't need |
None of these are new conclusions. What's different is having a number attached to each one instead of a plausible-sounding claim.
The pattern that shows up in real systems
Few real systems pick just one. The common shape is REST or GraphQL at the edge, talking to gRPC internally: an external-facing layer that public and mobile clients can call with an ordinary HTTP client, backed by internal services that talk to each other over gRPC's faster, strongly-typed connections where the caller is also your code.
NestJS makes this specific composition cheap because its microservices layer is transport-agnostic: the same message-handler pattern that talks gRPC can talk TCP, Redis, NATS, Kafka, or RabbitMQ without changing the handler logic, so a gateway can front several backend transports without a rewrite for each one. The ClientGrpc snippet above is exactly that seam — a REST controller at the edge, calling into a gRPC service behind it. GraphQL slots into the same position when the edge needs to assemble one response from several backend calls instead of proxying a single one straight through.
A decision framework for your next service
Skip the protocol question and answer these first:
Who's calling this? A browser, mobile app, or third party you don't control → REST or GraphQL. Only your own services → gRPC is on the table.
Is the client shape fixed or variable? One consistent shape per resource → REST is simplest. Several different screens need different slices of the same data → GraphQL earns its complexity.
What's actually slow? If it's your database or a downstream call, changing transport protocol won't fix it — my own numbers show transport differences in the single-digit milliseconds at this scale, dwarfed by anything hitting disk or another network hop.
Are you willing to own a schema file and codegen step? gRPC's contract is also its tax. If your team won't maintain
.protofiles, you'll fight the tooling more than you benefit from the wire format.
Takeaways
The usual expectation — that gRPC comfortably outruns REST — didn't hold on Node: REST beat gRPC by roughly 2.8-3x in my own benchmark, largely because
@grpc/grpc-jsis a pure-JS client and localhost erases the network-latency advantage gRPC is actually built for.Protobuf's payload savings were real but modest — 1.84x smaller than JSON — for data that's mostly strings. Numeric-heavy schemas will see a bigger gap.
The most dramatic savings in this whole comparison came from GraphQL simply not fetching fields nobody asked for: 83.5% smaller than the equivalent full REST response.
In NestJS specifically, gRPC's real cost is setup — a proto schema, a second bootstrap path, and a client wrapper — not runtime speed on a single machine.
None of this replaces testing your own service, your own data shape, and your own network. Benchmark it before you build your architecture around someone else's numbers, including mine.
Comments (0)
Login to post a comment.