gRPC vs REST

When to Use Which in Go

Web
Use gRPC for high-performance internal microservices with strict contracts and REST for public APIs requiring broad compatibility and simplicity.

The traffic problem

A new backend service needs to talk to two different clients. One client is a mobile app that expects flexible JSON responses and needs to work behind corporate proxies that sometimes block non standard ports. The other client is a sibling microservice that lives in the same Kubernetes cluster, needs to send thousands of requests per second, and benefits from strict data contracts. The engineering team argues about which transport to pick. The answer rarely comes down to personal preference. It comes down to who is on the other end of the wire and what the network actually has to carry.

REST and gRPC solve the same fundamental problem: moving data between processes. They approach it with different tradeoffs. REST leans on HTTP and JSON. It is forgiving, human readable, and works with every tool on the planet. gRPC leans on HTTP/2 and Protocol Buffers. It is strict, binary, and optimized for machine to machine speed. Picking one over the other means accepting its constraints and leaning into its strengths.

How REST works in Go

REST in Go usually means the standard library net/http package combined with encoding/json. You define a URL path, write a handler function, and let the Go HTTP server route requests to it. The server reads the request body, you unmarshal JSON into a struct, you run your logic, and you marshal a response back.

package main

import (
	"encoding/json"
	"net/http"
)

// GetStatus returns a simple health check payload.
func GetStatus(w http.ResponseWriter, r *http.Request) {
	// Set content type so clients know how to parse the response.
	w.Header().Set("Content-Type", "application/json")
	// Marshal directly to the response writer to avoid extra allocations.
	json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

func main() {
	// Register the handler for the /api/status path.
	http.HandleFunc("/api/status", GetStatus)
	// Start listening on port 8080 with the default mux.
	http.ListenAndServe(":8080", nil)
}

The net/http package is deliberately simple. It gives you a http.Request and a http.ResponseWriter. You control the headers, the status code, and the body. There is no hidden magic. If you want request validation, you write it. If you want authentication, you add a middleware. The flexibility is the point. You can return a 200 with a JSON error, a 400 with a plain text message, or a 204 with an empty body. The client decides how to interpret it.

REST handlers run sequentially by default. Each incoming request gets its own goroutine managed by the Go runtime. The standard library handles connection pooling, TLS, and keep alive headers automatically. You just focus on the business logic.

REST is a contract written in prose. The server says it returns JSON, and the client trusts it. If the server changes a field name, the client might still compile but fail at runtime. That flexibility is useful for public APIs where you need to evolve quickly, but it requires discipline to keep from becoming a maintenance burden.

How gRPC works in Go

gRPC replaces the manual JSON dance with a code generation step. You write a .proto file that defines your service methods and message types. You run the protoc compiler with the Go plugin, and it generates Go structs, serialization methods, and server stubs. The generated code enforces the contract at compile time.

// service.proto
// syntax = "proto3";
// package api;
// service UserService {
//   rpc GetUser (UserRequest) returns (UserResponse);
// }
// message UserRequest { string id = 1; }
// message UserResponse { string name = 1; }

After running the compiler, you get a Go file with a UserServiceServer interface. You implement that interface, register it with grpc.NewServer(), and start listening. The framework handles HTTP/2 framing, binary serialization, and multiplexing. You only write the business logic inside the method.

package main

import (
	"context"
	"log"
	"net"

	pb "example.com/api/gen"
	"google.golang.org/grpc"
)

// userServer implements the generated UserServiceServer interface.
type userServer struct {
	pb.UnimplementedUserServiceServer
}

// GetUser fetches a user by ID and returns the generated response.
func (s *userServer) GetUser(ctx context.Context, req *pb.UserRequest) (*pb.UserResponse, error) {
	// Extract the ID from the strictly typed request struct.
	id := req.GetId()
	// Return a compiled response. Missing fields default to empty strings.
	return &pb.UserResponse{Name: "Alice"}, nil
}

func main() {
	// Create a TCP listener on port 50051.
	lis, err := net.Listen("tcp", ":50051")
	if err != nil {
		log.Fatal(err)
	}
	// Initialize the gRPC server with default settings.
	s := grpc.NewServer()
	// Register the implemented server with the generated registration function.
	pb.RegisterUserServiceServer(s, &userServer{})
	// Block until the process receives a termination signal.
	s.Serve(lis)
}

The .proto file is the source of truth. You cannot accidentally return a field that the client does not expect. You cannot miss a required field without the compiler complaining. The generated code handles all the binary encoding. When you call GetUser, the framework serializes the request, sends it over an HTTP/2 stream, deserializes the response, and hands you a Go struct.

gRPC also supports streaming out of the box. You can define a method that returns a server stream, a client stream, or a bidirectional stream. The generated code gives you a stream interface that you read from and write to. This is where gRPC shines for real time data feeds or large file transfers.

What happens under the hood

REST over HTTP/1.1 opens a new connection for each request, or reuses a connection with keep alive. The request travels as plain text. The server parses the headers, reads the body, and writes a plain text response. JSON parsing happens in memory. If the payload is large, you allocate buffers, unmarshal, and then marshal again for the response. The overhead is noticeable when you scale to tens of thousands of requests per second.

gRPC runs exclusively on HTTP/2. HTTP/2 multiplexes multiple streams over a single TCP connection. Headers are compressed with HPACK. The body is binary. Protocol Buffers pack your data into a compact format that skips whitespace and type metadata. The Go gRPC library manages stream lifecycle, flow control, and backpressure automatically. When you send a request, the framework writes frames to the underlying connection. The server reads frames, reassembles the message, and calls your handler.

Context propagation works differently in each model. In net/http, you extract the context from the request with r.Context(). You must pass it manually to every downstream call. If you forget, cancellation and deadlines stop working. In gRPC, the context is the first parameter of every generated method. The framework injects it automatically. You still need to pass it to database calls or external HTTP requests, but the contract forces you to acknowledge it.

Error handling follows Go conventions in both cases. You return an error value. The framework translates it into the appropriate transport response. REST handlers write a status code and a JSON body. gRPC maps Go errors to gRPC status codes using the google.golang.org/grpc/status package. If you return a plain error, gRPC wraps it in an Unknown status code. You usually want to map validation failures to InvalidArgument or not found cases to NotFound.

Real world patterns

Production services rarely serve raw handlers. They wrap them with logging, metrics, authentication, and request validation. The pattern looks similar in both transports, but the boilerplate differs.

package main

import (
	"context"
	"encoding/json"
	"net/http"
)

// ValidateUserRequest checks required fields before processing.
func ValidateUserRequest(r *http.Request) error {
	// Decode JSON into a temporary struct to avoid partial writes.
	var body struct {
		ID   string `json:"id"`
		Name string `json:"name"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		return err
	}
	// Enforce business rules early to fail fast.
	if body.ID == "" {
		return fmt.Errorf("missing required field: id")
	}
	return nil
}

// HandleUserUpdate processes a validated request and returns a response.
func HandleUserUpdate(w http.ResponseWriter, r *http.Request) {
	// Extract context for cancellation and deadline propagation.
	ctx := r.Context()
	// Validate before touching expensive resources.
	if err := ValidateUserRequest(r); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	// Proceed with database or cache operations using ctx.
	// ...
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]string{"result": "updated"})
}

The if err != nil { return err } pattern appears everywhere in Go. It is verbose by design. The community accepts the repetition because it makes the unhappy path visible. You cannot accidentally swallow an error behind a silent return. When you wrap handlers, you keep the same pattern. You check validation, you check database calls, you check external service responses. Each step returns early on failure.

gRPC handlers follow the same rhythm. You validate the request struct, you call downstream services with the context, you return the response or an error. The generated code handles the HTTP/2 framing. You focus on the data flow.

Convention matters more than syntax in Go. The receiver name is usually one or two letters matching the type. You write (s *userServer) GetUser(...) not (this *userServer). Public names start with a capital letter. Private names start lowercase. There are no access modifiers. You export what needs to be visible and leave the rest unexported. The gofmt tool enforces indentation and spacing. Most editors run it on save. You do not argue about formatting. You argue about logic.

Where things break

REST breaks when the contract drifts. You rename a JSON field on the server. The client still compiles. It fails at runtime with a missing key. You add a new endpoint. The client does not know about it until someone documents it. You return a 200 with an error message in the body. The client treats it as success because it only checks the status code. These issues compound over time. You mitigate them with OpenAPI specs, contract testing, and strict CI pipelines.

gRPC breaks when the protobuf definition changes. You add a field to a message. The generated Go code updates. The old client still works because protobuf handles missing fields gracefully. You remove a field. The old client sends data that the new server ignores. You change a field number. The client and server suddenly deserialize garbage. Field numbers are permanent. You never reuse them. You document versioning strategy and enforce it with linters.

Compiler errors catch many mistakes early. If you forget to run the protobuf generator, the compiler rejects the program with undefined: pb. If you pass a string where a struct is expected, you get cannot use x (type string) as type *pb.UserRequest in argument. If you forget to import a package, you get undefined: pkg. If you import it and never use it, you get imported and not used. The compiler is strict. It forces you to acknowledge every dependency.

Runtime panics happen when you dereference a nil pointer or send on a closed channel. In REST handlers, you might panic if you assume a JSON field exists and it does not. You prevent this by checking json.Unmarshal errors and using pointer types for optional fields. In gRPC, you might panic if you ignore context cancellation and block forever on a database call. You prevent this by passing ctx to every blocking operation and using select with ctx.Done().

The worst transport bug is the one that silently drops data. REST drops data when you ignore json.Unmarshal errors and proceed with zero values. gRPC drops data when you ignore stream errors and assume the client will retry. You log failures. You set deadlines. You test the unhappy path.

Pick the right transport

Use REST when you are building a public API that needs to work with browsers, mobile apps, and third party integrations. Use REST when you need human readable payloads, standard HTTP status codes, and flexible evolution without recompiling clients. Use REST when your team prefers standard tooling over code generation.

Use gRPC when you are connecting internal microservices that share a codebase or a deployment pipeline. Use gRPC when you need strict contracts enforced at compile time. Use gRPC when you require bidirectional streaming or server push capabilities. Use gRPC when raw throughput and low latency matter more than human readability.

Use a gateway when you need both. Expose REST to the public internet. Translate it to gRPC for internal service communication. The gateway handles authentication, rate limiting, and protocol conversion. Your internal services stay fast and strictly typed. Your public API stays flexible and widely compatible.

Use plain sequential code when you do not need concurrency. The simplest thing that works is usually the right thing. Add complexity only when the metrics demand it.

Where to go next