REST, gRPC or GraphQL? Deciding by Measuring the Same Bookstore Scenario in Three Styles
The client type and the screen's needs decide which API style to pick: in our measurements REST JSON spent the least server CPU on small service-to-service calls, gRPC halved the bytes on the wire, and on the book detail screen single-request GraphQL and gRPC-Web beat four-request REST. We measure the same Bookstore scenarios in three styles with k6 and discuss the decision dimensions and using them together.
Version note: The code in this post was built and run with .NET 10, Grpc.AspNetCore 2.85, Hot Chocolate 16.7 and k6 2.4; all measurements were taken on the same machine with a Release build.
Guess First
The Ordering service asks Inventory for a two-line stock reservation on every order. We wrote the same operation in three ways: a REST (Representational State Transfer) endpoint with a JSON (JavaScript Object Notation) body, a gRPC RPC and a GraphQL mutation. Same server, same business rule, same data. Under 30 seconds of load from 20 concurrent clients, which one makes the most reservations per second and spends the least server CPU per request?
If you said "gRPC, of course; binary, HTTP/2 (HyperText Transfer Protocol 2), protobuf", you're not alone. In my measurement it wasn't the winner. In this post we talk with numbers about why not, and what that means for the decision.
The Setup
In the series' earlier posts we built the three styles in separate applications. For the comparison I put them all in one ASP.NET Core application, backed by the same in-memory data (50 books, 10 authors, 200 reviews). I deliberately left the database out: database time would be the same in all three styles and would hide the cost of the API style itself. Kestrel listens on two ports:
builder.WebHost.ConfigureKestrel(kestrel =>
{
// HTTP/1.1 for browsers and REST/GraphQL clients, h2c for service-to-service gRPC
kestrel.ListenLocalhost(5300, o => o.Protocols = HttpProtocols.Http1);
kestrel.ListenLocalhost(5301, o => o.Protocols = HttpProtocols.Http2);
});
builder.Services.AddSingleton<BookstoreData>();
builder.Services.AddGrpc();
builder.Services.AddGraphQLServer().AddBenchTypes();
var app = builder.Build();
app.UseGrpcWeb();
app.MapPost("/api/reservations", (ReserveRequest request, BookstoreData data) =>
data.Reserve(request.OrderId, request.Lines) is { } reservation
? Results.Created($"/api/reservations/{reservation.ReservationId}", reservation)
: Results.Conflict());
app.MapGrpcService<InventoryGrpcService>().EnableGrpcWeb();
app.MapGrpcService<CatalogGrpcService>().EnableGrpcWeb();
app.MapGraphQL();
There are two scenarios. (a) Service-to-service: Ordering's stock reservation at Inventory; a POST in REST, InventoryService/ReserveStock in gRPC, the reserveStock mutation in GraphQL. (b) The Angular book detail screen: book, author, stock and the last two reviews. In REST one endpoint per resource, in GraphQL the single query from the series' earlier posts, and in gRPC a screen-specific CatalogService/GetBookDetail RPC, over gRPC-Web since browsers can't speak gRPC directly.
I generated the load with k6. All scripts use the same settings: 20 virtual users, each waiting for the previous response before sending a new request, first a 5-second warm-up and then a 30-second measurement:
export const options = {
vus: 20,
duration: __ENV.DURATION || '30s',
summaryTrendStats: ['med', 'p(95)'],
};
import grpc from 'k6/net/grpc';
import { check } from 'k6';
import { summarize } from './summary.js';
import { lines } from './lines.js';
export { options } from './options.js';
const client = new grpc.Client();
client.load(['../Bookstore.Bench/Protos'], 'bench.proto');
const request = { order_id: 'ord-1', lines: lines() };
export default function () {
if (__ITER === 0) {
client.connect('localhost:5301', { plaintext: true });
}
const res = client.invoke('bookstore.bench.v1.InventoryService/ReserveStock', request);
check(res, { ok: (r) => r.status === grpc.StatusOK });
}
export function handleSummary(data) { return summarize((__ENV.LINES ? 'L' + __ENV.LINES + '-' : '') + 'a-grpc', data); }
handleSummary prints a one-line summary at the end of each run: operations per second, p50/p95 duration, requests per operation and bytes sent/received. I read the server process's CPU time before and after the run and divided the difference by the number of operations. The REST and GraphQL scripts follow the same layout with http.post. The REST version of the book detail does what a browser would: since the author needs the book's authorId, it takes two round trips.
// book detail screen: book, stock and reviews in parallel; the author needs the book's authorId
export default function () {
const [book, stock, reviews] = http.batch([
['GET', `${base}/books/1`],
['GET', `${base}/books/1/stock`],
['GET', `${base}/books/1/reviews?limit=2`],
]);
const author = http.get(`${base}/authors/${book.json('authorId')}`);
check(null, { ok: () => [book, stock, reviews, author].every((r) => r.status === 200) });
}
k6 has no gRPC-Web support, so I framed the request by hand. A gRPC-Web body consists of a flag byte, a 4-byte length and the protobuf message:
// gRPC-Web frame: 1 flag byte + 4-byte length + protobuf (book_id=1, review_count=2)
const frame = new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x04, 0x08, 0x01, 0x10, 0x02]).buffer;
const params = { headers: { 'Content-Type': 'application/grpc-web+proto', 'X-Grpc-Web': '1' }, responseType: 'binary' };
A caveat: the server and k6 ran on the same machine and shared the same CPU. There's no network latency; every request goes over loopback. So operations per second is the capacity of the "server + client" pair, not the server alone; for the server's own cost, server CPU per operation is the more reliable indicator. The absolute numbers will differ on your machine; the rankings and ratios are the real information.
Results
| Scenario | Style | Ops/s | p50 / p95 (ms) | Requests/op | Bytes sent / received | Server CPU (µs/op) |
|---|---|---|---|---|---|---|
| (a) 2 lines | REST | 87,388 | 0.19 / 0.42 | 1 | 245 / 337 | 31 |
| (a) 2 lines | gRPC | 59,279 | 0.28 / 0.67 | 1 | 117 / 108 | 47 |
| (a) 2 lines | GraphQL | 85,924 | 0.21 / 0.36 | 1 | 409 / 298 | 47 |
| (a) 50 lines | REST | 77,871 | 0.22 / 0.44 | 1 | 2,070 / 337 | 44 |
| (a) 50 lines | gRPC | 20,287 | 0.81 / 2.10 | 1 | 1,029 / 108 | 70 |
| (a) 50 lines | GraphQL | 61,566 | 0.29 / 0.50 | 1 | 2,234 / 298 | 91 |
| (b) book detail | REST | 30,817 | 0.61 / 1.03 | 4 | 348 / 1,046 | 97 |
| (b) book detail | gRPC-Web | 102,427 | 0.17 / 0.33 | 1 | 203 / 294 | 28 |
| (b) book detail | GraphQL | 82,973 | 0.22 / 0.38 | 1 | 302 / 429 | 47 |
The bytes include HTTP headers and framing; they're the real traffic k6 measured. Looking at the response bodies alone, the book detail is 410 bytes in REST (93 + 66 + 27 + 224), 257 bytes in GraphQL and 140 bytes in gRPC-Web (a 114-byte protobuf message, the frame and the grpc-status: 0 trailer).
Reading the Results: Service-to-Service
The opening guess didn't hold. For the two-line reservation, REST was the cheapest on the server at 31 µs of CPU per operation; gRPC and GraphQL were at 47 µs. As the message grew (50 lines) the gap widened but the ranking didn't change: REST 44, gRPC 70, GraphQL 91 µs. In this scenario System.Text.Json over HTTP/1.1 spent less CPU than protobuf over HTTP/2. HTTP/2's stream management and header compression, plus gRPC's framing and trailers, are a fixed overhead on a small unary call; while messages are small, that overhead stays larger than what protobuf saves.
gRPC's throughput looked even lower (59 thousand versus 87 thousand). Much of that is on the client side: when I separately measured the k6 process's own CPU time, k6 spent 51 µs of CPU per call for REST and 87 µs for gRPC. k6's gRPC client processes messages dynamically from the proto definition; it's not the same thing as .NET's compiled clients. Since they run on the same machine, the CPU the client burns is taken from the server and lowers throughput. So my conclusion isn't "gRPC is slow" but "for small unary calls gRPC has no CPU advantage".
Where gRPC clearly wins is bytes on the wire. With two lines, the 117 bytes sent are less than half of REST's, and the 108 bytes received are a third; with 50 lines, the 1,029 bytes sent are half of REST's. Part of the gain comes from protobuf and part from HTTP/2's header compression (HPACK, Header Compression for HTTP/2): repeated headers on the same connection aren't re-sent with every request. In a cloud where cross-region traffic is billed, or on limited bandwidth, these numbers are a direct cost.
GraphQL didn't buy anything service-to-service. The body is larger than REST's (the query text goes with every request; I didn't use persisted operations), and the CPU is higher than REST's. Parsing, validation and the execution engine are the price of a flexibility that has no payoff in a fixed-shape call between two services.
Reading the Results: The Book Detail Screen
Here the table turns around. REST sent four requests and spent 97 µs of server CPU per screen: each of the four requests has its own routing, serialization and HTTP handling cost. GraphQL finished with one request in 47 µs, and the screen-specific gRPC-Web RPC in 28 µs. Bytes received were 1,046 for REST, 429 for GraphQL and 294 for gRPC-Web. The REST responses also carried fields the screen doesn't use (isbn, bio, createdAt), and the headers went four times.
On the latency side, the p95 gap on the local machine (1.03 ms versus 0.38 ms) looks small, because a round trip over loopback takes microseconds. For a real user the picture is different: the REST screen has to wait for the book's response before asking for the author, meaning at least two sequential round trips. On a mobile connection with an 80 ms round trip, that alone is at least 160 ms of network waiting; for single-request GraphQL and gRPC-Web it's at least 80 ms. This isn't a measurement but a simple calculation from the number of round trips; but this is exactly GraphQL's real value on mobile screens.
gRPC-Web came out cheapest here because the RPC was designed for exactly this screen. You could do the same in REST with a screen-specific BFF endpoint; then the gap shrinks to a single request. So in this scenario the winner isn't a technology but the design that meets the screen's needs in one call. GraphQL provides this through the client's query without writing a separate endpoint for each screen; gRPC-Web and a BFF need server code for each screen.
Decision Dimensions

Measurement is only one part of the decision. The dimensions we ran into throughout the series:
- Client type: browsers can't speak gRPC directly (you need gRPC-Web or transcoding); third-party developers expect REST; for internal services, gRPC's generated clients are the path of least friction.
- Contract management: .proto files and field number rules in gRPC, a schema and versionless evolution with
@deprecatedin GraphQL, OpenAPI and URL or header versioning in REST. - Caching: in REST, per-URL HTTP caching is free; in GraphQL only with persisted operations and GET; in gRPC practically none.
- Streaming: all three streaming types are first-class in gRPC; GraphQL has subscriptions and
@defer; REST has SSE or a separate channel. - Security surface: in GraphQL, without cost analysis, depth limits and persisted operations, a single query can wear the server out; in REST and gRPC a request's cost is tied to the endpoint and predictable.
- Observability and tooling: every tool is ready for REST; gRPC's status codes and deadlines are rich but hard to try with curl; GraphQL's single endpoint forces you to measure by operation name.
Using Them Together
In real systems the question is usually not "which one" but "which one where". The layout I arrived at for Bookstore: the public catalog API used by third parties is REST; the layer the Angular and mobile apps talk to is GraphQL; between services, especially in calls like Ordering → Inventory where streaming and deadlines matter, gRPC. The GraphQL layer acts as a BFF here: its resolvers call the gRPC services behind it, like the stock field in the series' GraphQL fundamentals post. The "what happens at the edge" discussion in the Microservices series' API Gateway and Backend for Frontend post, and the API Gateway and YARP series, cover where this layer should sit.
As the number of services grows, if every team writes its own GraphQL server, clients end up dealing with several graphs. Hot Chocolate Fusion is a gateway that merges the schemas of several GraphQL services into a single graph; it builds on the Composite Schemas specification work in the GraphQL working group. With a few services and one team it's usually unnecessary: a single BFF GraphQL server calling the gRPC services behind it does the same job with fewer moving parts. Consider Fusion in an organization where different teams want to deploy their own domains independently.
| Situation | Don't use | Why |
|---|---|---|
| Public CRUD API, third-party clients | GraphQL | You lose HTTP caching, predictable cost and tooling, and the flexibility isn't needed in return |
| An application used only from the browser | gRPC | gRPC-Web adds a proxy layer; REST or GraphQL does the same job with fewer parts |
| Simple, fixed-shape internal call (few fields, low traffic) | GraphQL, and often gRPC | REST JSON spent the least CPU in the measurement; without a need for contracts or streaming, the extra layer doesn't pay off |
| High-volume internal traffic with streaming or deadline chains | REST | Bytes, streaming and deadline propagation are gRPC's natural territory |
| Mobile screen combining many sources | A chain of REST calls per screen | Sequential round trips add hundreds of milliseconds on mobile networks |
Production Notes
Do your own measurement with your own traffic. The numbers here were taken on a developer machine, over loopback and without a database. In a real system database and network time often drown out the difference between API styles; repeat the measurement in a production-like environment with real message sizes. We cover how to design load tests with k6 in detail in the Performance and Load Testing series.
Separate the load generator from the server. A load generator on the same machine shares the CPU and skews the results, especially for protocols that are expensive on the client side, like gRPC. That's why measuring server CPU per operation separately matters.
Count bytes as a cost too. Even if the CPU gap is small, gRPC's messages at half the size show up on the bill and in battery life for cross-region or mobile traffic.
Don't tie the decision to a single number. The biggest gap in this post (REST's four requests for the book detail) came not from the technology but from the endpoint design.
Common Pitfalls
The "gRPC is always faster" assumption. For small unary calls, JSON over HTTP/1.1 can spend less server CPU; choose gRPC for contracts, streaming, deadlines and bytes.
Measuring REST with one endpoint per resource and GraphQL with a screen query, then declaring REST the loser. In a fair comparison, REST's BFF option must be on the table too.
Using GraphQL service-to-service. A query engine whose flexibility goes unused pays the parsing and validation cost on every call.
Load testing a Debug build or with the log level at Information. Logging alone can multiply the time per call.
A Small Experiment
★☆☆ Run the k6 scripts on your own machine and compare the ranking in the table. Which ratios hold, which change?
★★☆ Add a single-request BFF endpoint for the book detail to REST (/api/screens/book-detail/1) and measure scenario (b) again. How much of the gap to GraphQL remains?
★★★ Measure scenario (a) with a .NET client (Grpc.Net.Client and HttpClient, from a separate machine or container) instead of k6, and write down how client-side cost changes the results.
Throughout the series we built gRPC and GraphQL with .NET 10 and Hot Chocolate 16. But most projects you'll read were written with older versions. In the series' last post we talk about how gRPC changed from .NET Core 3.0 to .NET 11 and the GraphQL side from Hot Chocolate 13 to 16, and what to watch for when reading an old project.