REST vs gRPC for Go Microservices
A practical comparison of REST/JSON and gRPC for Go microservices, with a decision matrix covering performance, streaming, debugging, and browser compatibility.
The problem
Once a system has more than one Go service, every service-to-service call needs a transport and a contract. Reaching for REST/JSON by default because it's what the public API already uses — or reaching for gRPC because it's "what microservices use" — both skip the actual question: what does this specific call need in terms of latency, streaming, and who consumes it.
The two aren't interchangeable defaults. They make different trade-offs, and picking wrong shows up later as either unnecessary complexity (gRPC for a simple public endpoint a browser needs to call directly) or a performance ceiling (REST/JSON for a hot internal path doing large payloads at high volume).
Why it matters
The cost of the wrong choice is different depending on direction:
- Choosing gRPC for a public API consumed directly by browsers means
fighting gRPC-Web, a proxy layer, and losing the ability for a consumer to
just
curlyour endpoint or read the response in devtools. - Choosing REST/JSON for a high-volume internal path means paying JSON's serialization and parsing cost and HTTP/1.1's per-request overhead on every call, and hand-rolling a contract that protobuf would have generated and versioned for you.
- Either choice made without streaming in mind means retrofitting long-lived connections (SSE, chunked responses, or a switch to gRPC streaming) after the fact, instead of designing for it from the start.
REST and gRPC in practice
REST over HTTP/JSON sends human-readable text over ordinary HTTP
semantics (verbs, status codes, headers). Every client that can make an HTTP
request can call it — a browser's fetch, curl, Postman, another
service's HTTP client — with no generated code required, though OpenAPI
specs make generated clients an option, not a requirement.
func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
order, err := h.service.GetOrder(r.Context(), id)
if err != nil {
if errors.Is(err, service.ErrNotFound) {
http.Error(w, "order not found", http.StatusNotFound)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(order)
}gRPC sends binary Protocol Buffers over HTTP/2, defined by a .proto
contract that generates strongly typed client and server code in every
supported language. The wire format is smaller and faster to (de)serialize
than JSON, and HTTP/2 multiplexing means many concurrent calls share one
connection without head-of-line blocking at the connection level.
service OrderService {
rpc GetOrder(GetOrderRequest) returns (Order);
rpc StreamOrderUpdates(StreamOrderUpdatesRequest) returns (stream OrderEvent);
}The generated Go server code looks like an interface implementation, not hand-wired routing:
func (s *orderServer) GetOrder(ctx context.Context, req *pb.GetOrderRequest) (*pb.Order, error) {
order, err := s.service.GetOrder(ctx, req.GetId())
if err != nil {
if errors.Is(err, service.ErrNotFound) {
return nil, status.Error(codes.NotFound, "order not found")
}
return nil, status.Error(codes.Internal, "internal error")
}
return toProtoOrder(order), nil
}The StreamOrderUpdates method above is the capability REST doesn't have a
first-class equivalent for: gRPC supports client streaming, server
streaming, and bidirectional streaming as part of the contract itself. REST's
closest equivalents — chunked transfer encoding or Server-Sent Events — work,
but they're conventions layered on top of request-response HTTP, not a typed
part of the API contract.
Decision matrix
| Dimension | REST / JSON | gRPC |
|---|---|---|
| Performance (serialization + wire size) | Adequate for most APIs; JSON parsing and text payloads cost more at high volume | Lower latency and smaller payloads via binary protobuf |
| Streaming | Workarounds only (SSE, chunked responses) | First-class: client, server, and bidirectional streaming |
| Debugging | curl, Postman, browser devtools work out of the box | Needs grpcurl, a generated client, or a proxy that decodes protobuf |
| Browser compatibility | Native — any fetch call works | Requires gRPC-Web plus a proxy (e.g. Envoy) to work from a browser |
| Public / third-party APIs | Well suited — self-descriptive, widely understood | Poorly suited without extra infrastructure |
| Internal service-to-service | Works, but pays JSON overhead at scale | Well suited — typed contracts, efficient at volume |
| Contract & codegen | Optional (OpenAPI can generate clients, but isn't required) | Required and built-in: .proto generates client and server code |
| Tooling maturity | Extremely mature, ubiquitous | Mature for backend services; weaker for browser-facing use |
Production considerations
Running both from the same Go service is common, not a compromise: expose
gRPC between internal services for its performance and typed contracts, and
put a thin REST/JSON gateway in front for anything the public or a browser
needs to call. Tools like grpc-gateway generate that REST layer directly
from the same .proto file, so the two surfaces don't drift into separate,
hand-maintained contracts.
Treat the .proto file as a versioned API contract: only add new fields with
new field numbers, never renumber or reuse a field number, and add new RPCs
rather than changing the signature of an existing one. Protobuf's wire
format tolerates unknown fields, which is what makes rolling deploys across
services with slightly different .proto versions safe — but only if you
follow those compatibility rules.
If you're introducing gRPC for the first time, start with the internal service-to-service calls that actually need it — a hot path doing high request volume, or one that benefits from streaming — rather than converting every internal call at once. REST between two low-traffic internal services is not a performance problem worth solving preemptively.
Common mistakes
- Putting gRPC directly in front of a browser without gRPC-Web and a compatible proxy — browsers cannot speak raw gRPC (they can't set the required HTTP/2 trailers), so this simply doesn't work without that extra layer.
- Switching an internal API to gRPC before measuring a real bottleneck. Protobuf and code generation add real complexity (build tooling, generated code review, versioning discipline) that isn't worth paying for a call that isn't on a hot path.
- Treating
.protochanges as free. Removing a field, changing its type, or reusing a field number breaks compatibility for any client still running the old contract during a rolling deploy. - Using REST for a use case that's fundamentally a stream — polling an endpoint repeatedly for updates that gRPC server-streaming or even SSE would deliver more efficiently and with lower latency.
Summary
REST/JSON and gRPC solve different problems well. REST wins on reachability — anything can call it, it's self-descriptive, and it's the right default for public APIs and anything a browser talks to directly. gRPC wins on performance and contract discipline for internal service-to-service calls, especially ones that need streaming. Most systems past a certain size end up using both: gRPC internally, REST/JSON at the edge — not picking one transport for the entire system.
funcRelated()[]Article
Circuit Breakers in Go: Preventing Cascading Failures
Implementing a circuit breaker in Go to stop cascading failures — closed/open/half-open states, and why combining it with retries incorrectly makes things worse.
Event-Driven Architecture with Go: Events, Consumers and Failure Handling
Designing event-driven Go services: commands vs events, consumer groups, ordering, and why every consumer must handle duplicate delivery.
Observability for Go Microservices: Logs, Metrics and Traces
The three pillars of observability for Go microservices — structured logs, Prometheus metrics, and distributed traces — and how they differ from monitoring.