Skip to content
Go back

gRPC Benchmark (1): Streaming vs REST for Large-Volume Data Transfer

Learning
Published:  at  08:00 AM
··· Views

In the last post I covered what gRPC is and how to use it, and I ended by saying I’d go run some actual tests — to see just how much you actually gain from this “trade readability for performance” approach. This post is that test log.

The code lives in a separate repo: the grpc_test folder in Clark1945/java. The main files:

Test Design

This round focuses on one scenario: bulk transfer of large volumes of data, split into two comparisons:

ScenariogRPC RPCREST counterpart
Bulk upload (client streaming)SensorService.ReportReadingsBatchedPOST /sensor-readings (one shot, whole JSON array)
Bulk download (server streaming)OrderService.ListOrdersBatchedGET /orders (one shot, whole JSON array)

Other settings:

Results

Both scenarios — bulk upload (client streaming) and bulk download (server streaming) — were measured for latency (ms) from 1,000 up to 1,000,000 records:

Bar chart comparing gRPC and REST latency across record counts; gRPC leads REST throughout both the upload and download scenarios

And the payload size (MB) for the same scenarios, where protobuf’s serialized size consistently comes out smaller than JSON’s:

Bar chart comparing gRPC (protobuf) and REST (JSON) payload size across record counts; protobuf is roughly 40%-50% the size of JSON

Observations and Takeaways

Once batched, gRPC streaming beats REST across the board, and the gap widens as record counts grow: at 1 million records, upload time differs by 3.6x (296ms vs 1,080ms), and download time by 3.9x (436ms vs 1,694ms).

The gap in payload size is one of the main drivers of the performance gap. With the same 8 fields and the same record count, protobuf’s serialized size consistently comes out to about 40%-50% of JSON’s (at 1 million records for download: 108MB for gRPC vs 213MB for HTTP). The reason isn’t hard to see: every JSON record repeats the field name (like "shippingAddress") as a key, while a protobuf field tag is just a 1-2 byte number — and at scale, that small difference per record adds up to something substantial.

Streaming’s performance advantage comes from amortizing the fixed overhead per message. Sending record-by-record (one message per record) doesn’t show much difference at small volumes, but as data volume grows, the per-message framing/serialization/scheduling overhead stacks up fast — and, as I found out the hard way in this exact test, can straight-up blow out your memory. That also validates why gRPC’s flow control mechanism exists in the first place: using it naively in production is genuinely dangerous.

Worth noting: these numbers come from local loopback testing. In a real network environment — with actual latency and bandwidth limits — gRPC’s relative advantage should be even more pronounced, since a smaller protobuf payload means shorter transfer time. But the exact multiplier still needs to be verified against a real network before drawing a firm conclusion.

A War Story: Sending Record-by-Record Blew Up My Memory

My first version had the client call onNext() once per record — no batching, no flow control check whatsoever. Turns out this approach performs far worse than batching at scale: at 500,000 records, the record-by-record version took 6,000ms+, while the batched version was in the ~100ms range.

Push it to 1,000,000 records and it gets even more dramatic — the record-by-record version blew out gRPC-netty’s direct memory buffer entirely, throwing:

OutOfMemoryError: Cannot reserve 2097152 bytes of direct buffer memory

The cause: the client side was hammering onNext() via the async stub without ever checking isReady() (i.e., no flow control), so messages were being produced far faster than the network could consume them. Netty’s outbound buffer just kept accumulating until it hit the memory ceiling.

Makes sense in hindsight — nobody actually sends records one at a time in a real large-volume scenario; it’s simply not a usable approach in practice. So the final benchmark only kept the one comparison that’s actually meaningful — batched streaming vs. a single big REST payload — and dropped the record-by-record version from the benchmark entirely. (That said, the record-by-record methods for both RPCs are still kept in SensorServiceImpl / OrderServiceImpl, as basic teaching examples for small-data scenarios.)

Known Limitations

How to Reproduce

cd grpc_test
mvn compile
mvn dependency:build-classpath -Dmdep.outputFile=cp.txt
java -Xmx3g -cp "target/classes;$(cat cp.txt)" com.example.benchmark.StreamingVsHttpBenchmark

Alright, this round of testing gave me a concrete sense of gRPC’s performance edge in large-volume scenarios, and along the way I got a firsthand lesson in just how badly things go when flow control isn’t handled properly.

That’s it for this one. See you in some other blue moon.


Suggest Changes
Share this post on:

Next Post
Getting Started with React (1): Setup, JSX, and Your First Component