The contract problem
You are building a system where Service A needs to ask Service B for data. You start with HTTP and JSON. It works. You define a JSON structure, send it, and parse the response. Then you add Service C, D, and E. The JSON payloads get heavy. You spend time debugging type mismatches because Service B changed a field name and Service A didn't notice until the request failed at runtime. You need a contract that the compiler enforces, and a transport that doesn't waste bytes on text.
gRPC solves this by combining two technologies: Protocol Buffers for the data contract and HTTP/2 for the transport. It turns remote calls into something that looks like local function calls, with the compiler checking types and the network layer handling efficiency.
What gRPC actually is
gRPC stands for Google Remote Procedure Call. It is a framework that lets you define a service interface once and generate client and server code in multiple languages. In Go, you define the interface in a .proto file. A code generator creates Go structs, interfaces, and serialization logic. You implement the server interface and call the client methods. The framework handles the network communication, serialization, and error mapping.
Think of JSON like writing a letter. You write the structure, the content, and hope the reader understands the format. If you change the format, the reader might get confused. gRPC is like a standardized form with numbered boxes. The structure is fixed by the form design. You just fill in the data. The transport layer is like a dedicated pipeline instead of sending separate envelopes for every request. The pipeline handles multiple requests at once and compresses the headers.
Protocol Buffers define the schema. They are binary, compact, and fast to parse. HTTP/2 provides multiplexing, header compression, and flow control. Together, they reduce latency and bandwidth usage compared to REST with JSON.
The schema definition
You start by writing a .proto file. This file defines the service interface and the message types. The syntax is strict. You specify the syntax version, the package name, and the imports. Then you define messages and services.
Here is a minimal proto file for a greeter service. It defines a request message, a response message, and a service with one RPC method.
syntax = "proto3";
package greeter;
option go_package = "example.com/greeter/pb";
// Request message contains the name to greet
message HelloRequest {
string name = 1;
}
// Response message contains the greeting string
message HelloResponse {
string message = 1;
}
// Service definition exposes one RPC method
service Greeter {
rpc SayHello (HelloRequest) returns (HelloResponse);
}
The option go_package tells the Go code generator where to put the generated code. The field numbers (= 1) are part of the binary encoding. They must stay stable if you evolve the schema. The service definition maps to a Go interface. The RPC method maps to a function signature.
Protobufs are the contract. Code generation is the enforcer.
Code generation and the Go interface
You run the protoc compiler with Go plugins to generate code. The output includes a Go package with the message structs and the service interface. You don't write the client or server boilerplate. The generator does it.
The generated code creates an interface for the server. You implement this interface in your Go code. The interface methods take a context.Context as the first argument. This is a Go convention for gRPC. The context carries deadlines, cancellation signals, and request-scoped values.
// GreeterServer is the interface that server implementations must embed.
// Generated by protoc-gen-go.
type GreeterServer interface {
// SayHello is called when the client invokes the RPC.
// Context carries cancellation and deadlines.
SayHello(context.Context, *HelloRequest) (*HelloResponse, error)
}
The receiver name in your implementation should be short, usually one or two letters matching the type. Use (s *server) not (this *server). The community expects this style.
Implementing the server
You create a struct that implements the generated interface. You add methods to handle the RPCs. Each method receives the context and the request message. You return the response message and an error. If the error is nil, gRPC sends an OK status. If the error is not nil, gRPC maps it to a status code.
Here is a server implementation. It implements the SayHello method. It checks the context for cancellation. It constructs the response.
package main
import (
"context"
"fmt"
"log"
"net"
"google.golang.org/grpc"
"example.com/greeter/pb"
)
// server implements the generated GreeterServer interface
type server struct {
pb.UnimplementedGreeterServer
}
// SayHello handles the RPC call.
// Context first param is required by the generated interface.
func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloResponse, error) {
// Check context for cancellation before doing work
if err := ctx.Err(); err != nil {
return nil, err
}
// Construct response using the request data
msg := fmt.Sprintf("Hello %s", in.GetName())
return &pb.HelloResponse{Message: msg}, nil
}
The UnimplementedGreeterServer embed is a convention. It ensures that if you add new methods to the proto file, the compiler warns you that you haven't implemented them yet. It prevents silent failures when the interface evolves.
You register the server with gRPC and start listening. The grpc.NewServer function creates the server instance. You register your implementation. Then you call Serve with a listener.
func main() {
// Listen on TCP port 50051
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
// Create gRPC server instance
s := grpc.NewServer()
// Register the service implementation
pb.RegisterGreeterServer(s, &server{})
// Start serving. Blocks until context cancellation or error.
log.Printf("server listening at %v", lis.Addr())
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
The Serve method blocks. It handles incoming connections and dispatches RPCs to your methods. The error handling follows the standard Go pattern. if err != nil is verbose by design. It makes the unhappy path visible. The community accepts the boilerplate because it prevents swallowed errors.
Implementing the client
The client side is simpler. You dial the server address. You create a client stub. You call the RPC method like a local function. The framework handles the network communication.
Here is a client example. It dials the server. It creates a client. It calls SayHello. It prints the response.
package main
import (
"context"
"log"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"example.com/greeter/pb"
)
func main() {
// Dial with insecure credentials for local development.
// Use credentials.NewTLS for production.
conn, err := grpc.Dial("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatalf("did not connect: %v", err)
}
// Defer close to prevent connection leaks
defer conn.Close()
// Create client stub from connection
client := pb.NewGreeterClient(conn)
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
// Call RPC method. Looks like a local function call.
resp, err := client.SayHello(ctx, &pb.HelloRequest{Name: "World"})
if err != nil {
log.Fatalf("could not greet: %v", err)
}
log.Printf("Greeting: %s", resp.GetMessage())
}
The context.WithTimeout is important. It prevents the client from hanging if the server is slow or unreachable. The context deadline propagates to the server. The server can check ctx.Err() to see if the client cancelled the request.
Context is plumbing. Pass it through every long-lived call site.
Under the hood: HTTP/2 and Protobufs
gRPC uses HTTP/2 as the transport. HTTP/2 multiplexes streams over a single TCP connection. You can send multiple RPCs concurrently without head-of-line blocking. The framework handles stream management. You don't see the streams. You just see concurrent goroutines handling RPCs.
Protocol Buffers serialize data to binary. The binary format is smaller than JSON. It parses faster. The schema defines the field types. The encoder writes the field number and value. The decoder reads the field number and value. If a field is missing, it uses the default value. If a field is unknown, it skips it. This allows forward and backward compatibility.
The combination of HTTP/2 and Protobufs gives you high performance. You get low latency and high throughput. You get strong typing. You get streaming support.
Pitfalls and errors
gRPC has a few pitfalls. The setup requires protoc and plugins. The toolchain can be fragile if versions mismatch. The error messages can be verbose.
If you forget to import the generated package, the compiler rejects the program with undefined: pb. If you forget to register the service, the server starts but returns rpc error: code = Unimplemented desc = method not found. If the server is down, the client gets rpc error: code = Unavailable desc = connection error.
Context deadlines are easy to miss. If you don't set a timeout on the client, the request can hang forever. If you don't check the context on the server, you might do work after the client cancelled. The compiler complains with context deadline exceeded if the timeout fires.
Versioning is another issue. Protobufs are version-sensitive. If you change a field number, the binary encoding breaks. If you remove a field, old clients might crash. You must evolve the schema carefully. Add new fields with new numbers. Never change field numbers. Never remove fields. Mark fields as reserved if you remove them.
The worst gRPC bug is the one that never logs. Always handle errors. Always check context. Always set timeouts.
Decision matrix
Use gRPC when you control both client and server and need high performance or strong typing. Use gRPC when you need bidirectional streaming for real-time data. Use gRPC when you have a polyglot system and want a single schema definition for multiple languages.
Use REST with JSON when you need broad compatibility with browsers or third-party tools that don't support HTTP/2 well. Use REST when the service is a simple public API consumed by unknown clients. Use plain HTTP when the overhead of gRPC setup is not justified by the performance gains.
Use WebSockets when you need a persistent connection for bidirectional communication in a browser environment. Use gRPC-Web when you need gRPC features in a browser but HTTP/2 is not available.
gRPC is a tool for distributed systems. It adds complexity. Use it when the benefits outweigh the cost.