Picking service boundaries is one problem — I’ve covered that separately when writing about bounded contexts and communication between services. What actually crosses those boundaries is a different decision. “Should this be REST or gRPC?” gets asked as if it’s a single choice for a whole system. It isn’t — the right answer is usually different for different pairs of services talking to each other inside the same architecture, and the third option, event-driven messaging, isn’t really competing with the first two so much as answering a different question entirely. Worth separating these clearly, because picking the wrong one for a given interaction shows up months later as either unnecessary latency or unnecessary coupling.
REST: The Right Default for Anything Facing a Browser or a Third Party
REST over HTTP/JSON wins by default for public and browser-facing APIs for reasons that have nothing to do with performance: every language has an HTTP client, every browser speaks it natively, every API testing tool (curl, Postman, a browser’s own devtools network tab) can inspect a request without any special tooling, and the semantics — GET is safe, POST creates, PUT replaces — are broadly understood without reading your specific documentation first.
GET /api/orders/42 HTTP/1.1
Accept: application/json
{"id": 42, "status": "shipped", "total": 89.99}The costs are real but specific: JSON parsing and HTTP/1.1 text overhead add latency that matters at high internal call volume, and REST has no enforced contract — the shape of that response is whatever your documentation says it is, until someone changes it and a client silently breaks in production. Neither cost is disqualifying for a public API; both start to matter once you’re calling a service thousands of times per second from inside your own infrastructure.
gRPC: For Internal, High-Volume, Latency-Sensitive Calls
gRPC starts from a .proto file that defines the contract before any code exists:
service OrderService {
rpc GetOrder (OrderRequest) returns (OrderReply);
}
message OrderRequest { int32 id = 1; }
message OrderReply { int32 id = 1; string status = 2; float total = 3; }From that one file, protoc generates client and server code in whatever languages you need — the contract is enforced by generated code on both sides, not by hoping the docs stayed accurate. Under the hood it runs over HTTP/2 with Protocol Buffers instead of JSON: binary encoding instead of text, and HTTP/2’s multiplexing means many concurrent calls share one TCP connection instead of the connection-per-request overhead HTTP/1.1 carries. The real-world effect is measured in the tens-of-percent range for typical payloads, not orders of magnitude — worth having for a service mesh handling heavy internal traffic, not worth the switch for an endpoint your web app calls twice a page load.
The trade-off that catches people who adopt it for the wrong reason: gRPC is genuinely awkward to call directly from a browser (it needs a proxy layer like grpc-web, because browsers don’t expose the raw HTTP/2 trailers gRPC depends on), and it’s much harder to poke at with curl or a browser devtools panel when something’s misbehaving — you need a dedicated tool like grpcurl and the .proto definitions on hand just to make one ad hoc request. It’s the right choice for service-to-service calls inside your own infrastructure, not for anything a browser or a third-party integrator will call directly.
Event-Driven: Not Faster REST, a Different Question Entirely
This is the one that gets miscategorized as “a third option to compare on performance.” It isn’t answering “how do I call another service” — it’s answering “does the caller need to know the callee exists at all.” REST and gRPC are both synchronous, point-to-point: service A calls service B, waits, gets a response, and if B is down, A’s call fails. Event-driven messaging (Kafka, RabbitMQ, SQS) removes both of those properties on purpose:
// order service publishes, doesn't know or care who's listening
await producer.send({
topic: "order.shipped",
messages: [{ value: JSON.stringify({ orderId: 42, address: "..." }) }],
});
// inventory service and notification service both consume independently
consumer.subscribe({ topic: "order.shipped" });
consumer.run({ eachMessage: async ({ message }) => { /* update stock */ } });The order service publishes one event and has zero knowledge of how many consumers exist, or whether they’re online right now. A new consumer can be added later — an analytics pipeline, say — without changing a single line in the order service, because publisher and subscriber were never coupled to begin with. If the inventory service is down when the event fires, the message sits in the queue until it’s back, instead of the order service’s request failing outright the way a synchronous call would.
The cost is a different failure mode that catches teams used to synchronous debugging: there’s no request/response to trace end to end, “eventual consistency” is a real property you have to design around rather than a phrase in a slide deck (the inventory count really can be briefly stale after an order ships, and code that assumes otherwise will have subtle bugs), and answering “why didn’t X happen” requires actual distributed tracing across the message bus, not just reading one service’s logs.
Picking, in Practice
The realistic architecture for a mid-sized system uses all three at once, for different edges of the same graph: REST at the boundary facing browsers and external API consumers, gRPC between internal services that call each other synchronously and care about latency, and events for anything where the caller shouldn’t need to know or care who else reacts to what just happened — order placed, user signed up, payment settled. Treating this as one system-wide choice usually means over-applying whichever one a team happens to know best, and the cost shows up later as either gRPC’s operational overhead on a rarely-called browser-facing endpoint, or REST’s synchronous coupling on a workflow that should have been fire-and-forget from the start.
The Checklist
- REST for anything a browser or external party calls directly — the tooling ubiquity is worth more than the performance you’d gain elsewhere.
- gRPC for internal, high-volume, latency-sensitive service-to-service calls where both ends are code you control and can regenerate from a shared
.proto. - Events when the real question is “who needs to know this happened,” not “how fast can I get a response” — and only once you’re prepared to design for eventual consistency and trace across a message bus.
- Don’t pick one for the whole architecture — the right choice is usually different for different edges of the same system.
