Skip to content
Microservices

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.

GoBackend.dev10 min read
GogRPCREST APIMicroservices

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 curl your 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

DimensionREST / JSONgRPC
Performance (serialization + wire size)Adequate for most APIs; JSON parsing and text payloads cost more at high volumeLower latency and smaller payloads via binary protobuf
StreamingWorkarounds only (SSE, chunked responses)First-class: client, server, and bidirectional streaming
Debuggingcurl, Postman, browser devtools work out of the boxNeeds grpcurl, a generated client, or a proxy that decodes protobuf
Browser compatibilityNative — any fetch call worksRequires gRPC-Web plus a proxy (e.g. Envoy) to work from a browser
Public / third-party APIsWell suited — self-descriptive, widely understoodPoorly suited without extra infrastructure
Internal service-to-serviceWorks, but pays JSON overhead at scaleWell suited — typed contracts, efficient at volume
Contract & codegenOptional (OpenAPI can generate clients, but isn't required)Required and built-in: .proto generates client and server code
Tooling maturityExtremely mature, ubiquitousMature 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 .proto changes 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.