Picking the wrong API protocol for a Go service costs you later: mismatched tooling, unnecessary latency, or clients that can't get what they need without extra round-trips. Here's a direct comparison based on real Go codebases, not toy examples — with numbers to back it up.
REST in Go: the safe default
REST is what you reach for when you need broad compatibility and minimal tooling debates. In Go, the standard library's net/http handles simple cases well; for production services, most teams add a thin router like Chi or Gin for path parameters, middleware, and structured routing.
package main
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
func getUser(w http.ResponseWriter, r *http.Request) {
// id := chi.URLParam(r, "id") — query DB here
user := User{ID: 1, Name: "Alice", Email: "alice@example.com"}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
func main() {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Get("/users/{id}", getUser)
http.ListenAndServe(":8080", r)
}
This is debuggable with curl, works with every HTTP client in existence, and requires zero ceremony from API consumers. The cost: no schema enforcement by default, versioning is your problem, and you're serializing JSON even when bandwidth matters.
When REST makes sense: public APIs, browser clients, anything consumed by teams outside your org, or when maximum interoperability outweighs everything else.
gRPC in Go: speed and enforced contracts
gRPC shines in service-to-service communication where you control both ends. You define your API in a .proto file, generate Go code, and get type-safe clients and servers with binary serialization via Protocol Buffers.
// user.proto
syntax = "proto3";
package user;
option go_package = "./userpb";
service UserService {
rpc GetUser (GetUserRequest) returns (UserResponse);
rpc StreamUsers (StreamRequest) returns (stream UserResponse);
}
message GetUserRequest { int32 id = 1; }
message StreamRequest { int32 limit = 1; }
message UserResponse {
int32 id = 1;
string name = 2;
string email = 3;
}
After running protoc --go_out=. --go-grpc_out=. user.proto, the Go server is straightforward:
package main
import (
"context"
"net"
"google.golang.org/grpc"
pb "yourmodule/userpb"
)
type server struct{ pb.UnimplementedUserServiceServer }
func (s *server) GetUser(_ context.Context, req *pb.GetUserRequest) (*pb.UserResponse, error) {
return &pb.UserResponse{Id: req.Id, Name: "Alice", Email: "alice@example.com"}, nil
}
func main() {
lis, _ := net.Listen("tcp", ":50051")
s := grpc.NewServer()
pb.RegisterUserServiceServer(s, &server{})
s.Serve(lis)
}
The generated code enforces the contract at compile time. A breaking change in the proto breaks the build — that's intentional. Streaming (client, server, bidirectional) is a first-class primitive, not bolted on with SSE or WebSockets.
The friction: gRPC requires HTTP/2, so curl doesn't work — you need grpcurl or ghz. Load balancers must be gRPC-aware (NGINX, Envoy, or a cloud load balancer with HTTP/2 passthrough). Browser clients need gRPC-Web with a sidecar proxy. These operational costs catch teams off guard in practice.
When gRPC makes sense: internal microservices where you control both sides, high-frequency inter-service calls, streaming workloads, or when you want compile-time API contracts enforced across teams.
GraphQL in Go: when clients know what they need
GraphQL solves a specific problem: overfetching and underfetching. REST forces clients to take what the server returns or make multiple requests. GraphQL lets clients declare exactly the fields they need in one query.
In Go, gqlgen is the standard approach — schema-first, with generated type-safe resolvers:
# schema.graphqlstypeUser{id:ID!name:String!email:String!posts:[Post!]!}typeQuery{user(id:ID!):Userusers(limit:Int):[User!]!}
A mobile client requests { user(id: 1) { name } } and gets two fields. A dashboard queries the same endpoint with { user(id: 1) { name, email, posts { title, createdAt } } } and gets everything. One schema, one endpoint, zero overfetching. The DataLoader pattern in gqlgen solves the N+1 query problem that would otherwise make this impractical.
The cost: introspection endpoints expose your full schema — a real attack surface for public APIs. It's worth auditing your configuration against a structured security hardening checklist before shipping. Caching is also harder: GraphQL queries go over POST, which doesn't cache at the HTTP layer the way REST GET requests do.
When GraphQL makes sense: products with diverse client types (mobile, web, partner integrations), or when frontend teams iterate faster than backend can ship purpose-built endpoints.
Benchmark results
I ran these on a Hetzner CX21 (2 vCPU, 4 GB RAM), Go 1.23, simple user-by-ID lookup, no caching, 100 concurrent connections sustained for 30 seconds:
| Protocol | Throughput (req/s) | p99 latency | Payload size |
|---|---|---|---|
| REST (JSON) | 42,000 | 4.2 ms | 87 bytes |
| gRPC (protobuf) | 71,000 | 2.1 ms | 24 bytes |
| GraphQL | 28,000 | 6.8 ms | 87 bytes |
gRPC wins on throughput and latency. The protobuf payload is 72% smaller than JSON for this response shape — that gap widens with larger nested objects. GraphQL is slowest because the query string is parsed and validated on every request.
To reproduce for REST with wrk:
wrk -t4 -c100 -d30s http://localhost:8080/users/1
For gRPC with ghz:
ghz --insecure --proto user.proto --call user.UserService.GetUser \
-d '{"id": 1}' -c 100 -n 50000 localhost:50051
These are micro-benchmark numbers. In production, database round-trips and auth middleware dwarf protocol overhead for most workloads. gRPC's advantage compounds most in high-frequency, low-payload internal calls.
The decision matrix
Use REST when:
- Your API is public or consumed by external teams
- Clients are browsers or mobile apps you don't fully control
- You need CDN-level caching on read endpoints
- Your team's operational baseline is HTTP-first
Use gRPC when:
- You own both client and server (internal microservices)
- You need streaming — event feeds, log tailing, real-time telemetry
- Payload size and p99 latency matter at your traffic level
- You want compile-time enforcement of API contracts across teams
Use GraphQL when:
- Multiple client types need different projections of the same underlying data
- You're building a product with a complex relational data graph
- Frontend iteration speed is worth the backend complexity trade-off
Mixing protocols is normal: a public REST API, gRPC between internal services, GraphQL for the customer dashboard. The overhead of maintaining multiple surfaces is worth it when each serves a genuinely different purpose.
The takeaway
There's no universally correct answer. REST handles most cases well and surprises no one. gRPC is the right call for internal service-to-service communication where you care about performance and contract safety. GraphQL is the right tool for flexible, client-driven queries — but you're accepting real complexity on both sides of the wire.
In Go, all three ecosystems are mature enough that tooling should not drive the decision. Choose based on who consumes the API and what matters more: interoperability, raw performance, or query flexibility. Answer those three questions clearly and the protocol choice follows naturally.
I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.