gRPC vs REST: a practical Go guide for working engineers
We've all stood at the whiteboard deciding which protocol to use for a new service. Both gRPC and REST work in Go, but they solve different problems, and choosing wrong costs you in latency, complexity, or operational overhead later. This gRPC vs REST: a practical Go guide for working engineers walks through when each shines and how to avoid the most common pitfalls we've encountered in production.
When gRPC makes sense: internal, low latency, streaming
gRPC is purpose-built for service-to-service communication. If your architecture has microservices talking to each other across your network, and you care about latency or throughput, gRPC is often the right choice. It uses HTTP/2 multiplexing, binary Protocol Buffers, and connection pooling to keep overhead minimal.
The real magic appears when you need streaming. With gRPC, you can push multiple messages over a single connection without the request-response ceremony of REST. This matters for event streams, log aggregates, metric collection, or any scenario where a service needs to continuously send data to many consumers.
Consider a real-time analytics platform. A mobile app sends location events to a backend service. That service needs to fan them out to multiple internal consumers: a notification engine, a analytics aggregator, and a fraud detector. With REST, each consumer polls or waits for webhook retries. With gRPC server-to-server streams, all three receive events with minimal latency and connection overhead.
Here's a minimal example. First, define your service in Protocol Buffers:
syntax = "proto3";
package events;
service EventStream {
rpc Subscribe(SubscribeRequest) returns (stream LocationEvent);
}
message SubscribeRequest {
string user_id = 1;
string filter = 2;
}
message LocationEvent {
string event_id = 1;
double latitude = 2;
double longitude = 3;
int64 timestamp = 4;
}Now implement the server:
func (s *Server) Subscribe(req *events.SubscribeRequest, stream events.EventStream_SubscribeServer) error {
ctx := stream.Context()
events := s.eventChan // assuming event source exists
for {
select {
case evt := <-events:
if evt.UserID == req.UserId {
if err := stream.Send(evt); err != nil {
log.Printf("send error: %v", err)
return err
}
}
case <-ctx.Done():
return ctx.Err()
}
}
}
The client connects once and receives events as they arrive. No polling, no reconnection overhead. For internal services handling thousands of events per second, this difference compounds.
REST: public APIs and browsers
REST remains the standard for public-facing APIs and anywhere a browser, mobile app, or third-party developer needs to call your service. It's HTTP, so firewalls already permit it. It's human-readable, easy to test with curl, and every platform has libraries. Caching layers, CDNs, and load balancers all understand HTTP semantics.
If you're building an API for external consumption, REST is almost always the better default. You might have millions of third-party integrations hitting your endpoints, and forcing them to adopt gRPC and Protocol Buffers is a non-starter.
REST also wins for simple, CRUD-heavy services. If your API is mostly "get user by ID" or "list orders", REST's resource-oriented design maps naturally. You gain standardization: status codes, headers, content negotiation, and caching all work out of the box.
A practical example: a SaaS product exposes a REST API so customers can integrate. Stripe, Twilio, and AWS all chose REST for public APIs for a reason. Here's a minimal Go endpoint:
func handleGetUser(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "id")
user, err := db.GetUser(r.Context(), userID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
router := chi.NewRouter()
router.Get("/users/{id}", handleGetUser)Simple, cacheable, and every integration partner understands it. You can add OpenAPI docs, webhooks, pagination, and filtering without introducing new concepts.
gRPC-Gateway: REST facade on gRPC internals
Here's where things get practical. You don't have to choose. Use gRPC internally for low-latency service-to-service calls, then expose a REST API to the outside world via gRPC-Gateway.
gRPC-Gateway generates a reverse proxy that translates HTTP/JSON requests into gRPC calls. Your team maintains one API surface in Protocol Buffers, but external consumers see standard REST with JSON payloads. This approach gives you the best of both worlds: internal performance and external compatibility.
Setup is straightforward. Annotate your Protocol Buffer service with HTTP mappings:
syntax = "proto3";
package api;
import "google/api/annotations.proto";
service UserService {
rpc GetUser(GetUserRequest) returns (User) {
option (google.api.http) = {
get: "/v1/users/{id}"
};
}
rpc CreateUser(CreateUserRequest) returns (User) {
option (google.api.http) = {
post: "/v1/users"
body: "*"
};
}
}
message GetUserRequest {
string id = 1;
}
message CreateUserRequest {
string name = 1;
string email = 2;
}
message User {
string id = 1;
string name = 2;
string email = 3;
}Generate the gateway with protoc:
protoc --go_out=. --go-grpc_out=. --grpc-gateway_out=. api.protoThen wire both servers in your main:
func main() {
grpcServer := grpc.NewServer()
api.RegisterUserServiceServer(grpcServer, &userServiceImpl{})
go func() {
listener, _ := net.Listen("tcp", ":5000")
grpcServer.Serve(listener)
}()
ctx := context.Background()
mux := runtime.NewServeMux()
api.RegisterUserServiceHandlerServer(ctx, mux, &userServiceImpl{})
http.ListenAndServe(":8080", mux)
}Now external clients hit http://localhost:8080/v1/users/123 (REST) while internal services call localhost:5000 (gRPC). One implementation, two interfaces.
Many systems use both
The reality is that mature production systems rarely pick a single protocol. Your architecture likely needs both. Consider a typical e-commerce platform:
- Public API: REST for mobile apps, web frontend, and partner integrations.
- Order service to payment service: gRPC for low-latency charge requests.
- Order service to notification service: gRPC streams for order events that trigger emails and SMS.
- Analytics ingestion: gRPC for throughput; REST webhooks from third-party data providers.
This mixed approach is standard. You're not choosing "gRPC or REST"—you're choosing the right tool for each connection. Teams that treat this as a multi-protocol architecture from the start avoid painful rewrites later.
Common mistakes
Forcing gRPC for everything: We've seen teams adopt gRPC for all APIs, including public ones, then spend months building REST wrappers because customers couldn't integrate. Start with REST for external APIs.
Ignoring gRPC-Gateway until too late: Teams build a gRPC service, later realize they need REST for a partner, and retrofit it messily. Plan for both from the start if you anticipate external consumers.
Underestimating Protocol Buffer learning curve: Your team needs to understand proto syntax, versioning, and code generation. If they've only seen JSON, this adds friction. Invest in onboarding.
Not measuring latency impact: gRPC's binary format and multiplexing are fast, but they don't automatically make your system faster if your bottleneck is database queries or business logic. Profile before and after.
Treating streaming as a free lunch: gRPC streams keep connections open. If you have thousands of concurrent streams, memory and file descriptor usage climb. You'll need backpressure handling and connection limits.
Key takeaways
- Use gRPC for internal service-to-service calls, especially where latency and streaming matter.
- Use REST for public APIs, third-party integrations, and browser-based clients.
- Combine both via gRPC-Gateway: one API definition in Protocol Buffers, REST facade on top.
- Measure performance gains; don't assume binary protocols are always faster.
- Plan for multiple protocols from the start; retrofitting is expensive.
- Invest in Protocol Buffer tooling and team training if adopting gRPC at scale.






Comments
Loading comments…