DevLift
Back to Blog

gRPC vs REST: When Raw Performance Justifies the Complexity

Your user-facing API is fine. REST, JSON, OpenAPI docs — it works, everyone can call it, no complaints. But internally, your Order service is calling the Inventory service 40 times per request.

Admin
July 8, 202610 min read0 views

gRPC vs REST: When Raw Performance Justifies the Complexity

Your user-facing API is fine. REST, JSON, OpenAPI docs — it works, everyone can call it, no complaints. But internally, your Order service is calling the Inventory service 40 times per request. Each call is a separate HTTP/1.1 round trip with a JSON payload. At low traffic it's invisible. At 50,000 requests per second, it's the reason your p99 latency looks like a crime scene.

That's the moment gRPC starts making sense. Not because REST is bad — it's not — but because you're now paying the JSON serialization tax on every internal hop, and it's compounding.

The Quick Decision Matrix

gRPCREST
TransportHTTP/2HTTP/1.1 (usually)
Payload formatProtocol Buffers (binary)JSON (text)
Browser supportLimited (gRPC-Web required)Native
StreamingBuilt-in (4 patterns)SSE / WebSockets bolt-on
Type safetyEnforced via .proto schemaOptional (OpenAPI)
DebuggingHard (binary payloads)Easy (curl, browser devtools)
Payload size~2–4x smaller (measured below)Baseline
Connection modelOne TCP connection, multiplexedOne request per connection at a time
Learning curveSteepShallow
Public API fitPoorExcellent

What's Actually Different Under the Hood

REST is an architectural style layered over HTTP/1.1. Each request gets its own TCP connection (or relies on keep-alive, which is still serial). The payload is JSON: human-readable, self-describing, easy to log and debug. The server responds and you're done.

gRPC runs over HTTP/2, which changes the physics. HTTP/2 multiplexes multiple requests over a single TCP connection — you can fire 50 requests simultaneously and they don't block each other. Binary framing, header compression (HPACK), and flow control are built in at the protocol level, not bolted on.

The payload format is Protocol Buffers instead of JSON. You define your messages in a .proto file:

// inventory.proto
syntax = "proto3";
 
package inventory;
 
service InventoryService {
  rpc CheckStock (StockRequest) returns (StockResponse);
  rpc WatchStock (StockRequest) returns (stream StockUpdate);
}
 
message StockRequest {
  string product_id = 1;
  int32 quantity = 2;
}
 
message StockResponse {
  bool available = 1;
  int32 current_stock = 2;
  string warehouse_id = 3;
}
 
message StockUpdate {
  string product_id = 1;
  int32 current_stock = 2;
  string timestamp = 3;
}

The compiler generates typed client and server code in your language of choice. No hand-writing API clients. No drift between what the server returns and what the client expects.

The equivalent REST shape for StockResponse looks like this over the wire:

{
  "available": true,
  "current_stock": 842,
  "warehouse_id": "us-east-1a"
}

Let's not guess at the sizes. I encoded that exact message with protobufjs against the .proto above:

JSON, compact   {"available":true,"current_stock":842,"warehouse_id":"us-east-1a"}   66 bytes
JSON, as printed above (2-space indent)                                              79 bytes
Protobuf                                     0801 10ca06 1a0a 75732d656173742d3161   17 bytes

The protobuf encoding is short enough to read by hand, which is a good way to see where the saving comes from. 08 01 is field 1 (available), varint, value 1. 10 ca 06 is field 2 (current_stock), varint, 842. 1a 0a is field 3, length-delimited, 10 bytes, followed by us-east-1a in ASCII. No field names on the wire at all — just tag numbers. That's the whole trick.

So the real ratio is about 3.9x, not the 5x the round numbers suggest, and the absolute saving is 49 bytes. For a single request, irrelevant. For 40 million internal calls per day, it adds up.

Serialization: Where the Speed Lives (And Where It Doesn't)

JSON parsing has to scan every byte, handle unicode escapes, and build dynamic objects from field names it discovers as it goes. Protobuf parsing is schema-aware: the parser knows field 1 is a boolean, field 2 is an int32, field 3 is a string, and it reads directly into known slots.

That's the theory. Here's what it's worth on a realistic payload — a list of products with 8 fields each, measured on Node 22.22.3:

// REST — JSON response
const res = await fetch(`/api/products?ids=${ids.join(",")}`);
const restProducts = await res.json(); // JSON.parse over the whole body
// gRPC — binary deserialization
const { products: grpcProducts } = await inventoryClient.listProducts({
  productIds: ids,
});
// Protobuf decoded directly into a typed ProductList

Wire size (round-tripped and verified, not estimated):

ItemsJSONProtobufRatio
100 products15.9 KB8.9 KB1.8x smaller
1000 products160.3 KB89.6 KB1.8x smaller

Note the ratio dropped from 3.9x to 1.8x. That's not noise — it's the point. The saving comes almost entirely from not repeating field names, so it shrinks as your values get longer relative to your keys. Short keys and long string values means protobuf saves you very little. A message full of small integers with long field names is where it shines.

Decode time, 1000-item message, median of 9 runs of 300 iterations each after warm-up:

Median
JSON.parse0.48 ms
protobufjs decode (lazy message)0.41 ms
protobufjs decode + toObject0.53 ms

This is the result that should change how you think about gRPC in Node. Protobuf decoding is roughly at parity with JSON.parse — 1.17x faster if you work with the lazy message wrapper, and slightly slower if you convert to a plain JS object, which most application code does. The "protobuf parses 10x faster" claim is true in Go, C++ and Rust, where JSON parsing is library code competing against generated struct decoders. It is not true in JavaScript, because JSON.parse is native V8 C++ and protobufjs is JavaScript.

So if you are a Node shop, be honest with yourself about which win you're buying. The payload-size reduction is real and the HTTP/2 multiplexing is real. CPU savings on deserialization are not the reason to adopt gRPC here. In a polyglot mesh where Go and Java services do the heavy lifting, the calculus is different — but measure it on your own stack rather than importing someone else's benchmark, including this one.

Streaming: gRPC's Actual Killer Feature

REST has request-response. That's it. If you need streaming, you're bolting on WebSockets or Server-Sent Events separately, with different auth models, different error handling, different client libraries.

gRPC has four first-class communication patterns baked into the same transport and auth layer:

Unary — classic request-response, same shape as REST:

rpc CheckStock (StockRequest) returns (StockResponse);

Server streaming — one request, continuous response (live feed, progress updates):

rpc WatchStock (StockRequest) returns (stream StockUpdate);

Client streaming — client sends a stream, server replies once (bulk upload, telemetry):

rpc BatchUpdate (stream UpdateRequest) returns (BatchResult);

Bidirectional streaming — both sides stream independently (chat, collaborative editing, real-time sync):

rpc SyncInventory (stream SyncRequest) returns (stream SyncResponse);

The Node.js server implementation for server streaming looks like:

// gRPC server — Node.js (@grpc/grpc-js)
import type { ServerWritableStream } from "@grpc/grpc-js";
 
export function watchStock(
  call: ServerWritableStream<StockRequest, StockUpdate>
): void {
  const { productId } = call.request;
  const topic = `stock:${productId}`;
 
  const listener = (update: StockUpdate) => {
    // guard: the client may have vanished between events
    if (call.cancelled || call.writableEnded) return;
    call.write(update);
  };
 
  inventoryEvents.on(topic, listener);
 
  // EventEmitter.on() returns the emitter, NOT an unsubscribe function.
  // You have to hold the listener reference and call off() yourself.
  const cleanup = () => inventoryEvents.off(topic, listener);
 
  call.on("cancelled", cleanup); // client cancelled or deadline exceeded
  call.on("error", cleanup);
  call.on("close", cleanup);
}
⚠️
Two things that bite people in that handler. First, EventEmitter.on() returns the emitter itself, so the common const unsubscribe = emitter.on(...) pattern gives you an object and unsubscribe() throws TypeError: unsubscribe is not a function. Keep the listener reference and use off(). Second, don't call call.end() in the cancelled handler — grpc-js already destroys the stream immediately after emitting the event, so ending it again writes a status to a dead call. On cancellation, release your own resources and nothing else.

The client side in TypeScript:

const stream = inventoryClient.watchStock({ productId: "SKU-8821" });
 
stream.on("data", (update: StockUpdate) => {
  console.log(`Stock: ${update.currentStock}`);
});
 
stream.on("error", (err) => handleStreamError(err));
stream.on("end", () => console.log("Stream closed"));

Compare that to adding WebSocket support to a REST API: separate server setup, separate auth middleware, separate connection management, separate client library. gRPC streaming costs you the .proto definition; REST streaming costs you a parallel system.

The Architecture Difference

Rendering diagram...

This is the architecture most high-traffic platforms end up with: REST at the edge for public clients, gRPC for internal service mesh communication.

Developer Experience: The Honest Cost

gRPC is not simpler. The toolchain is real overhead.

Setting up a gRPC service in Node.js:

npm install @grpc/grpc-js @grpc/proto-loader
# Or the higher-level client:
npm install nice-grpc nice-grpc-common

Then you need a codegen step to turn .proto files into TypeScript. The modern answer is buf driving a plugin, rather than a bundled protoc:

npm i -D @bufbuild/buf ts-proto
buf.gen.yaml
version: v2
plugins:
  - local: ./node_modules/.bin/protoc-gen-ts_proto
    out: ./src/generated
    opt:
      - outputServices=grpc-js
      - esModuleInterop=true
      - useOptionals=messages
npx buf generate

buf also gives you buf lint and buf breaking, which catches an incompatible schema change in CI before it reaches a consumer — worth more than the codegen itself.

⚠️
If you follow an older tutorial using grpc-tools + grpc_tools_node_protoc_ts, watch the output-target prefixes. --grpc_out=./generated and --ts_out=./generated both succeed with exit code 0 while emitting code that imports the deprecated grpc package (last published 2021) instead of @grpc/grpc-js. You need --grpc_out=grpc_js:./generated and --ts_out=grpc_js:./generated. And note that --ts_out=service=grpc-js:... — which looks right, because that is ts-proto's option syntax — is silently ignored by that plugin. A generator that fails silently into the wrong runtime is exactly why the buf-based setup is worth the migration; grpc_tools_node_protoc_ts hasn't shipped a release since February 2023.

Either way: that's a CI step, a generated code folder, and a toolchain dependency before you've written a line of service logic.

REST in comparison:

// Express — no generated code, no compiler step
app.get("/api/stock/:id", async (req, res) => {
  const stock = await db.inventory.findById(req.params.id);
  res.json(stock);
});

And debugging gRPC in production is genuinely harder. You can't curl a gRPC endpoint and read the response. You need tools like grpcurl or gRPC reflection enabled:

# gRPC debugging
grpcurl -plaintext \
  -d '{"product_id": "SKU-8821", "quantity": 1}' \
  localhost:50051 \
  inventory.InventoryService/CheckStock
 
# REST debugging — everyone knows this
curl localhost:3000/api/stock/SKU-8821
⚠️

gRPC-Web exists for browser clients, but it requires a proxy (like Envoy or Nginx) that translates between gRPC-Web's HTTP/1.1 framing and gRPC's HTTP/2. You can't call a gRPC server directly from a browser without this setup. For public APIs with browser consumers, this overhead rarely makes sense.

Schema Evolution: Protobuf vs OpenAPI

Protobuf field numbering gives you backward-compatible evolution by default. New fields added to a message don't break old clients, and removing a field is wire-safe: since protobuf 3.5.0 (November 2017), proto3 preserves unknown fields rather than discarding them, so a field a binary doesn't recognise is kept in its unknown-field set and re-emitted when it serialises. A message can pass through an old proxy without losing data it didn't know about.

(Two caveats. This is the binary format only — ProtoJSON has no unknown-field support and rejects unrecognised fields by default. And some non-Google runtimes, protobufjs among them, discard unknowns unless you configure otherwise.)

// v1 of the message
message StockResponse {
  bool available = 1;
  int32 current_stock = 2;
}
// v2 — fully backward compatible with v1
message StockResponse {
  bool available = 1;
  int32 current_stock = 2;
  string warehouse_id = 3;  // New field — old clients keep it as an unknown field
  reserved 4;               // Field 4 existed once and was removed. Never reuse it.
}

REST with OpenAPI can do this too, but it's convention, not enforcement. Nothing stops a REST service from changing field types or removing fields in ways that break clients. The .proto file is a hard contract; OpenAPI is documentation that describes a contract.

The real hazard isn't removing a field — it's reusing its number. The wire format carries tag numbers, not names, so it cannot distinguish the old field 3 from a new field 3 of a different type. A recycled tag silently mis-decodes, and protobuf's own documentation lists the outcomes as parse errors, data corruption, and leaked PII. Always add the number to a reserved statement when you delete a field.

What I Am Not Going to Tell You

You will find a lot of tables online giving you P50 and P99 latency, CPU percentage and max RPS for "gRPC vs REST" on "equivalent infrastructure." I'm leaving that table out, because I haven't run it and neither had most of the people publishing it.

End-to-end latency and throughput for the two protocols depend on connection reuse, TLS termination, the number of concurrent streams, payload shape, whether the REST side is on HTTP/1.1 or HTTP/2, and how far apart the two hosts are. Any single pair of numbers is a measurement of one specific setup, and the ones circulating tend to compare a connection-pooled gRPC client against a REST client opening a fresh connection per request — which measures connection setup, not protocol.

What I can stand behind is what I measured above: the wire format is ~1.8–3.9x smaller depending on message shape, and in Node the deserialization CPU win is close to zero. The multiplexing benefit is architectural and real — one connection carrying 50 concurrent streams genuinely beats six HTTP/1.1 connections head-of-line blocking each other — but its size depends entirely on your fan-out pattern.

If p99 latency on an internal hop is the thing you're trying to fix, the honest move is to put a load generator in front of both versions of your service and look at your numbers. It's an afternoon of work and it beats any table on the internet.

When to Use gRPC

Internal microservices where you control both the client and server. High call volume between services — anything over a few hundred RPS per path where latency is measurable. ML inference services: gRPC is the default protocol for TensorFlow Serving, Triton, and most model servers. Real-time data pipelines where you need server or bidirectional streaming without a separate WebSocket infrastructure. Polyglot environments where services in Go, Python, Java, and Node.js need to talk to each other — Protobuf-generated clients handle the impedance mismatch better than hand-written REST clients.

When to Use REST

Public APIs consumed by external developers. Any endpoint called from a browser without a proxy layer. CRUD operations on resources where the JSON tax is negligible (low volume, large payloads where the relative overhead shrinks). Teams without existing Protobuf toolchain experience — the learning curve is real, and shipping is more important than optimizing a path that isn't hot yet. Webhooks and callback patterns. Any context where debuggability is more important than throughput.

When to Use Both

This is actually the right answer for most companies past a certain scale. The pattern is consistent across Uber, Netflix, Google, Lyft:

  • REST for the external API layer (clients, third-party integrations, mobile apps)
  • gRPC for internal service-to-service communication
  • A gateway that translates between them
// API Gateway pattern — REST in, gRPC out
app.get("/api/products/:id", async (req, res) => {
  // Translate REST request to gRPC call
  const product = await productClient.getProduct({
    productId: req.params.id,
  });
 
  const stock = await inventoryClient.checkStock({
    productId: req.params.id,
    quantity: 1,
  });
 
  // Merge and return as JSON to the browser
  res.json({
    ...product,
    inStock: stock.available,
    stockLevel: stock.currentStock,
  });
});

The gateway owns the REST contract. Behind it, every internal call benefits from gRPC's HTTP/2 multiplexing — the gateway fans out calls to three services simultaneously over single connections and assembles the response. What the browser sees is a clean REST API. What your infrastructure sees is a dramatically lower internal latency.


The decision really comes down to who's calling your service. If it's a browser or an external developer you've never met, REST is the right default — the tooling, debuggability, and universal support aren't worth trading away. If it's another service you own, the call volume is high, and you're watching your p99 latency with growing anxiety — gRPC is not premature optimization anymore. The protobuf schema upfront costs an afternoon. The performance headroom it buys is permanent.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

A monotonic stack maintains elements in order and pops when that order breaks — finding the next greater element for every popped value in O(n) total.
AdminAugust 3, 20265 min read
The EventEmitter pattern lets components in the same process react to the same event without being directly coupled — no message broker needed.
AdminAugust 3, 20266 min read
One file to guard every route — plus the Next.js 16 rename that moves it off the Edge runtime, the request-vs-response header trap that leaks user IDs to the browser, and the CVE that explains why this can never be your only auth layer.
AdminAugust 3, 20268 min read