Building gRPC Services in Go
Learn how to build high-performance, type-safe APIs using gRPC and Protocol Buffers. gRPC uses HTTP/2 for efficient communication and supports streaming, making it ideal for microservices and real-time applications.
What is gRPC?
gRPC is a modern RPC framework built on HTTP/2 that enables efficient communication between services. Unlike REST APIs which use JSON over HTTP/1.1, gRPC uses Protocol Buffers (a binary format) over HTTP/2, resulting in smaller payloads and better performance.
gRPC advantages: Binary protocol (smaller payloads), HTTP/2 multiplexing,
built-in streaming, strongly typed with Protocol Buffers, better performance for high-throughput scenarios.
REST advantages: Simpler to understand, works with any HTTP client, better browser support,
easier debugging with text-based JSON.
Use gRPC for: Microservices, real-time applications, high-throughput APIs.
Use REST for: Public APIs, web applications, simple CRUD operations.
Protocol Buffers
Protocol Buffers (protobuf) is Google's language-neutral, platform-neutral mechanism
for serializing structured data. You define your data structures in a .proto file,
then compile it to generate Go code.
syntax = "proto3";
package todo;
option go_package = "github.com/example/todo-grpc/pb";
message Todo {
string id = 1;
string title = 2;
bool completed = 3;
int64 created_at = 4;
int64 updated_at = 5;
}
message CreateTodoRequest {
string title = 1;
}
message UpdateTodoRequest {
string id = 1;
string title = 2;
bool completed = 3;
}
message GetTodosRequest {}
message TodoList {
repeated Todo todos = 1;
}
Each field has a number (1, 2, 3, etc.) that identifies it in the binary format.
The repeated keyword indicates a list. Compile this with:
Service Definition
Define your RPC methods in the same .proto file. Each method specifies
its input and output message types.
service TodoService {
// Unary RPC: single request, single response
rpc CreateTodo(CreateTodoRequest) returns (Todo);
rpc GetTodos(GetTodosRequest) returns (TodoList);
rpc UpdateTodo(UpdateTodoRequest) returns (Todo);
// Server streaming: single request, stream of responses
rpc StreamTodos(GetTodosRequest) returns (stream Todo);
// Client streaming: stream of requests, single response
rpc CreateMultipleTodos(stream CreateTodoRequest) returns (TodoList);
// Bidirectional streaming: stream of requests, stream of responses
rpc SyncTodos(stream Todo) returns (stream Todo);
}
Building a Server
Implement the service interface generated by protoc. Each RPC method becomes a function that receives a context and the request message.
package main
import (
"context"
"log"
"net"
"google.golang.org/grpc"
pb "github.com/example/todo-grpc/pb"
)
type TodoServer struct {
pb.UnimplementedTodoServiceServer
todos map[string]*pb.Todo
}
func (s *TodoServer) CreateTodo(ctx context.Context, req *pb.CreateTodoRequest) (*pb.Todo, error) {
todo := &pb.Todo{
Id: "todo-1",
Title: req.Title,
Completed: false,
CreatedAt: int64(time.Now().Unix()),
UpdatedAt: int64(time.Now().Unix()),
}
s.todos[todo.Id] = todo
return todo, nil
}
func (s *TodoServer) GetTodos(ctx context.Context, req *pb.GetTodosRequest) (*pb.TodoList, error) {
var todos []*pb.Todo
for _, todo := range s.todos {
todos = append(todos, todo)
}
return &pb.TodoList{Todos: todos}, nil
}
func (s *TodoServer) StreamTodos(req *pb.GetTodosRequest, stream pb.TodoService_StreamTodosServer) error {
for _, todo := range s.todos {
if err := stream.Send(todo); err != nil {
return err
}
}
return nil
}
func main() {
listener, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("Failed to listen: %v", err)
}
grpcServer := grpc.NewServer()
pb.RegisterTodoServiceServer(grpcServer, &TodoServer{
todos: make(map[string]*pb.Todo),
})
log.Println("gRPC server listening on :50051")
if err := grpcServer.Serve(listener); err != nil {
log.Fatalf("Failed to serve: %v", err)
}
}
Building a Client
Create a client connection and call the RPC methods. The generated code handles all the serialization and network communication.
package main
import (
"context"
"log"
"google.golang.org/grpc"
pb "github.com/example/todo-grpc/pb"
)
func main() {
// Connect to server
conn, err := grpc.Dial(":50051", grpc.WithInsecure())
if err != nil {
log.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
client := pb.NewTodoServiceClient(conn)
ctx := context.Background()
// Create a todo
todo, err := client.CreateTodo(ctx, &pb.CreateTodoRequest{
Title: "Learn gRPC",
})
if err != nil {
log.Fatalf("CreateTodo failed: %v", err)
}
log.Printf("Created: %v", todo)
// Get all todos
list, err := client.GetTodos(ctx, &pb.GetTodosRequest{})
if err != nil {
log.Fatalf("GetTodos failed: %v", err)
}
log.Printf("Todos: %v", list.Todos)
}
Streaming
gRPC supports four types of streaming: unary (request-response), server streaming, client streaming, and bidirectional streaming. Streaming is useful for large datasets or real-time updates.
package main
import (
"context"
"log"
pb "github.com/example/todo-grpc/pb"
)
// Server streaming: receive multiple responses
func streamTodos(client pb.TodoServiceClient) {
stream, err := client.StreamTodos(context.Background(), &pb.GetTodosRequest{})
if err != nil {
log.Fatalf("StreamTodos failed: %v", err)
}
for {
todo, err := stream.Recv()
if err != nil {
break // stream ended
}
log.Printf("Received: %v", todo)
}
}
// Client streaming: send multiple requests
func createMultipleTodos(client pb.TodoServiceClient) {
stream, err := client.CreateMultipleTodos(context.Background())
if err != nil {
log.Fatalf("CreateMultipleTodos failed: %v", err)
}
for i := 0; i < 3; i++ {
stream.Send(&pb.CreateTodoRequest{
Title: "Todo \" + string(rune('A'+i)) + "\",
})
}
list, err := stream.CloseAndRecv()
if err != nil {
log.Fatalf("CloseAndRecv failed: %v", err)
}
log.Printf("Created todos: %v", list.Todos)
}
Server streaming: Large result sets, real-time updates, log streaming.
Client streaming: Bulk uploads, batch operations, log collection.
Bidirectional streaming: Chat applications, collaborative editing, real-time synchronization.
Interview Questions
Common interview questions about gRPC and Protocol Buffers in Go — organized by experience level. Click a tab to filter.
Q1. What is gRPC?
gRPC is an open-source remote procedure call (RPC) framework, originally built at Google, that lets a client call a method on a server as if it were a local function. It runs over HTTP/2, uses Protocol Buffers as its default serialization format, and generates strongly-typed client and server code from a service definition — instead of hand-writing HTTP routes and JSON parsing, you call generated methods directly.
Q2. What is Protocol Buffers?
Protocol Buffers (protobuf) is a language-neutral, binary serialization format developed by Google. You define message shapes in a .proto file, and a compiler generates code to encode and decode those messages efficiently in your target language. It is more compact and faster to parse than JSON, at the cost of not being human-readable on the wire.
Q3. What is a .proto file?
A .proto file is a schema file written in Protocol Buffers' interface definition language (IDL). It declares message types (the data structures) and service definitions (the RPC methods), each field tagged with a unique number and type. This file is the single source of truth from which client and server code is generated for every supported language.
Q4. What does protoc / protoc-gen-go do?
protoc is the Protocol Buffers compiler; it parses a .proto file and invokes language-specific plugins to generate code. protoc-gen-go is the Go plugin that generates Go structs for each message and marshal/unmarshal methods, while protoc-gen-go-grpc generates the client stub and server interface for each service block.
Q5. What is a service definition in a proto file?
A service block in a .proto file declares one or more RPC methods, each specifying a request message type and a response message type, for example rpc GetUser(GetUserRequest) returns (GetUserResponse). The code generator turns this into a Go interface the server must implement and a client struct with matching methods.
Q6. What's the basic difference between gRPC and REST?
REST is an architectural style typically layered over HTTP/1.1 with JSON payloads and resource-oriented URLs (GET /users/1), while gRPC is a strict RPC contract layered over HTTP/2 with binary protobuf payloads and generated method calls. gRPC also has native support for streaming, whereas plain REST over HTTP/1.1 does not.
Q7. What is a client stub in gRPC?
A client stub is generated code that exposes the RPC methods declared in a service block as regular Go function calls on a struct, for example client.GetUser(ctx, req). Internally the stub serializes the request to protobuf, sends it over an HTTP/2 connection, and deserializes the response — the caller never touches wire-level details.
Q8. What transport protocol does gRPC use?
gRPC is built on top of HTTP/2. It relies on HTTP/2 features like multiplexed streams over a single TCP connection, binary framing, and header compression to support concurrent requests and long-lived streaming without opening a new connection per call.
Q9. What are the four types of RPC that gRPC supports?
gRPC supports unary RPC (one request, one response), server-streaming RPC (one request, a stream of responses), client-streaming RPC (a stream of requests, one response), and bidirectional streaming RPC (both sides stream independently over the same call). All four are declared in the .proto service definition using the stream keyword on the relevant message type.
Q1. Why does gRPC use HTTP/2 instead of HTTP/1.1?
HTTP/2 supports multiplexing many concurrent requests over a single TCP connection, binary framing, and header compression, which HTTP/1.1's text-based, one-request-per-connection-at-a-time model can't do efficiently. This lets gRPC keep one persistent connection open and stream multiple RPCs (including long-lived streaming calls) over it without head-of-line blocking at the application layer.
Q2. Why must field numbers in a proto message never be reused or changed?
The field number, not the field name, is what's actually encoded on the wire in protobuf's binary format — the name only exists in generated code. If you reuse a number for a different field, an old binary reading new data (or vice versa) will decode bytes intended for one field as if they belonged to another, silently corrupting data instead of failing loudly.
Q3. Why is gRPC generally more efficient than a JSON-over-REST API?
Protobuf's binary encoding is smaller and faster to parse than JSON text because it avoids field names, quoting, and string-to-number conversion on the wire. Combined with HTTP/2 multiplexing and header compression, this reduces both payload size and connection overhead, which matters most at high request volume or for latency-sensitive internal service-to-service calls.
Q4. When would you use a server-streaming RPC instead of unary?
Server-streaming is a good fit when a single request logically produces a sequence of results over time, such as tailing logs, watching for resource changes, or returning a large result set in chunks. The client sends one request and keeps the same call open to receive multiple responses, avoiding repeated polling with separate unary calls.
Q5. What's the difference between using protoc directly and using buf?
protoc is the low-level compiler that requires manually managing plugin paths, include directories, and versions across a team. buf wraps protoc with dependency management, linting, breaking-change detection, and reproducible generation config (buf.gen.yaml), which is why most teams building gRPC services in Go now use buf generate instead of invoking protoc by hand.
Q6. If you add a new field to a proto message, is that backward compatible?
Yes — adding a new field with a fresh, unused field number is safe. Old clients that don't know about the field simply ignore it when decoding, and old servers reading a message from a new client will leave that field's default value at the code level, since protobuf is designed for both forward and backward compatibility as long as field numbers and types aren't repurposed.
Q7. Why does proto3 need an explicit optional keyword for presence?
In proto3, singular scalar fields (like int32 or string) don't track whether they were explicitly set — an unset field and one explicitly set to its zero value (0, "") are indistinguishable by default. Adding optional to a field generates a wrapper so generated Go code can check presence explicitly (via a generated getter/has-method or pointer field), which matters when "not provided" needs to be distinguished from "provided as zero."
Q8. What does grpc.NewServer do, and how do you wire up a service?
grpc.NewServer constructs an unstarted gRPC server instance, optionally configured with interceptors, TLS credentials, and other grpc.ServerOption values. You then call the generated Register<Service>Server function to attach your implementation of the generated server interface, and finally call Serve with a net.Listener to start accepting connections.
Q9. What's the difference between grpc.Dial and grpc.NewClient?
grpc.Dial is the older constructor that, despite its name, doesn't actually connect eagerly by default and is now deprecated in favor of grpc.NewClient. grpc.NewClient is the current recommended API for creating a client connection with clearer lazy-connect semantics and is the constructor generated client code and documentation now point to.
Q1. How does protobuf's wire format achieve backward/forward compatibility?
Protobuf encodes each field as a tag (field number + wire type) followed by its value, so a decoder can skip any tag it doesn't recognize without needing to understand the full schema. This tag-length/type-value structure is what allows old code to safely ignore new fields and new code to see sensible zero values for fields it doesn't find, as long as field numbers are never reassigned to a different type or meaning.
Q2. How do deadlines and cancellation propagate through a gRPC call chain?
A deadline set on a Go context.Context (via context.WithTimeout or WithDeadline) is transmitted with the RPC as HTTP/2 metadata (grpc-timeout), so the server derives its own context with the remaining budget. If that context is cancelled or its deadline expires at any point in a multi-hop call chain, gRPC surfaces a DeadlineExceeded or Canceled status and downstream calls made with the derived context are cancelled too, preventing wasted work after the caller has given up.
Q3. What are gRPC interceptors, and how do unary and stream interceptors differ?
Interceptors are gRPC's equivalent of HTTP middleware — they wrap RPC calls to add cross-cutting behavior like authentication, logging, metrics, or panic recovery without touching handler code. Unary interceptors wrap a single request/response call, while stream interceptors wrap the entire lifetime of a streaming call and must handle multiple messages over time; multiple interceptors are composed via chaining helpers so they run in a defined order around the actual handler.
Q4. Why doesn't traditional HTTP load balancing work well for gRPC?
Standard L4/L7 load balancers spread load by distributing individual TCP or HTTP/1.1 requests, but gRPC clients hold a single long-lived HTTP/2 connection over which many RPCs are multiplexed — a simple L4 balancer just pins that whole connection to one backend, starving others. gRPC-Go addresses this with client-side load balancing (a resolver plus a load-balancing policy like round_robin that opens subconnections to multiple backend addresses) or by routing through an L7 proxy that understands HTTP/2 framing, such as Envoy, that can load-balance individual streams rather than connections.
Q5. When would you choose gRPC over REST for a given service, and vice versa?
gRPC fits well for internal, high-throughput service-to-service communication where you control both ends, want strict typed contracts, code generation, and native streaming, and can tolerate binary payloads that aren't directly browser- or curl-friendly. REST/JSON remains the better choice for public-facing APIs, third-party integrations, or anywhere broad HTTP tooling, human-readability, and easy debugging in a browser matter more than raw efficiency.
Q6. What design patterns are common for bidirectional streaming RPCs?
Bidirectional streams are used for chat-like or event-driven workloads where both sides need to push messages independently, such as live collaboration, telemetry ingestion, or a control-plane watch loop. A common pattern is running the send and receive loops in separate goroutines against the same stream, using context cancellation to tear both down together, and applying backpressure or buffering deliberately since a slow reader on one side can otherwise stall the whole stream.
Q7. How does gRPC represent and propagate errors compared to plain RPC calls?
gRPC errors are represented as a status.Status, combining a well-defined status code (like NotFound, InvalidArgument, or Unavailable) with a message and optional structured error details, rather than relying on arbitrary HTTP status codes. In Go, handlers return errors created with status.Errorf, and clients extract the code via status.FromError to branch on failure type instead of parsing an HTTP status or response body.
Q8. What's the difference between client-side load balancing and proxy-based load balancing for gRPC?
Client-side load balancing has each gRPC client resolve a set of backend addresses itself (via a name resolver) and choose among them using a policy like round_robin, avoiding an extra network hop but requiring the client library to embed that logic. Proxy-based load balancing puts an HTTP/2-aware proxy such as Envoy or a service mesh sidecar in front of the backends, centralizing routing and failover decisions at the cost of an additional hop, which is often preferred in Kubernetes environments for consistency across languages.
Q9. How should a team manage proto schema evolution safely across multiple services?
Treat the .proto files as a versioned contract: never reuse or change field numbers, mark removed fields as reserved (both number and name) so they can never be reintroduced by mistake, and only add new optional fields rather than changing existing ones' types or semantics. Tooling like buf breaking is typically wired into CI to detect incompatible changes automatically, since manual review alone is unreliable once several teams share the same proto repository.