The cross-language contract problem
You just finished a high-performance data processor in Go. Your team's legacy analytics dashboard runs on Python. The frontend team wants to call it from TypeScript. Each language has its own memory model, its own standard library, and its own way of handling errors. You could build a REST API and hope everyone agrees on JSON field names. You could write a custom TCP protocol and spend weeks debugging serialization bugs. Or you can hand them a contract that the compiler enforces.
gRPC solves the cross-language conversation problem by combining two ideas: Protocol Buffers for the data format, and HTTP/2 for the transport. Protocol Buffers, or protobufs, act like a strict shipping manifest. You define the exact shape of your data once in a .proto file. The protobuf compiler reads that manifest and generates native code for Go, Python, JavaScript, and dozens of other languages. The generated code knows exactly how to pack the data into a compact binary format and unpack it on the other side. No more guessing whether a field is a string or an integer. No more missing commas in JSON.
gRPC sits on top of that serialization layer. It handles the network plumbing. It multiplexes multiple requests over a single TCP connection, keeps connections alive, and manages bidirectional streaming. The result is a remote procedure call that feels like a local function call, even though the code lives on a different machine and runs in a different language.
Generating the boilerplate
Here is the simplest contract you can write. It defines a single service with one method that takes a name and returns a greeting.
// greeting.proto
// Defines the message shapes and service contract
syntax = "proto3";
package greeting;
// Tells the Go generator where to place the package
option go_package = "example.com/greeting";
// Request carries a single string field
message GreetRequest {
string name = 1;
}
// Response carries the generated greeting
message GreetResponse {
string message = 1;
}
// Service declares the available RPCs
service Greeter {
rpc SayHello (GreetRequest) returns (GreetResponse);
}
You run the protocol compiler with the Go plugins to generate the boilerplate.
# Generates the struct definitions and serialization helpers
protoc --go_out=. --go_opt=paths=source_relative greeting.proto
# Generates the server interface and client stub
protoc --go-grpc_out=. --go-grpc_opt=paths=source_relative greeting.proto
The compiler drops two files in your directory: greeting.pb.go and greeting_grpc.pb.go. You never edit those files by hand. They contain the generated structs, the serialization methods, and the interface that your server must implement. The paths=source_relative flag keeps the generated code in the same directory as your proto file, which matches standard Go module conventions.
Implementing the server
Here is how you satisfy that interface in Go.
// server.go
package main
import (
"context"
"log"
"net"
"google.golang.org/grpc"
pb "example.com/greeting"
)
// server implements the generated GreeterServer interface
type server struct {
pb.UnimplementedGreeterServer
}
// SayHello handles the incoming RPC request
func (s *server) SayHello(ctx context.Context, in *pb.GreetRequest) (*pb.GreetResponse, error) {
// Return the formatted string wrapped in the response struct
return &pb.GreetResponse{
Message: "Hello " + in.GetName(),
}, nil
}
func main() {
// Listen on localhost port 50051
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
// Create a gRPC server instance
s := grpc.NewServer()
// Register the concrete implementation with the generated helper
pb.RegisterGreeterServer(s, &server{})
// Start serving incoming connections
log.Println("server listening on :50051")
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
Notice the receiver name s. Go convention dictates one or two letters matching the type, not this or self. The SayHello method takes context.Context as its first parameter. That is a strict community convention for any function that might block or perform I/O. The context carries deadlines, cancellation signals, and request-scoped values. If the client cancels the request, the server receives the signal immediately and can stop processing.
The client side
The client code mirrors the server structure. The generated stub handles serialization, connection pooling, and response parsing.
// client.go
package main
import (
"context"
"log"
"time"
"google.golang.org/grpc"
pb "example.com/greeting"
)
func main() {
// Connect to the server with a 5-second dial timeout
conn, err := grpc.DialContext(context.Background(), "localhost:50051", grpc.WithInsecure())
if err != nil {
log.Fatalf("failed to connect: %v", err)
}
defer conn.Close()
// Create a typed client from the connection
c := pb.NewGreeterClient(conn)
// Set a deadline so the call fails fast if the server hangs
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Send the request and capture both response and error
resp, err := c.SayHello(ctx, &pb.GreetRequest{Name: "Alice"})
if err != nil {
log.Fatalf("RPC failed: %v", err)
}
// Print the deserialized message
log.Printf("Response: %s", resp.GetMessage())
}
The grpc.WithInsecure() option disables TLS for local development. Production services always use grpc.WithTransportCredentials with a certificate. The context deadline propagates through the call stack. If the server takes longer than five seconds, the client cancels the stream and returns a DeadlineExceeded error.
What happens on the wire
When you compile this program, the Go compiler treats the generated pb package like any other library. The pb.RegisterGreeterServer function binds your server struct to the gRPC framework. At runtime, the framework accepts TCP connections, upgrades them to HTTP/2, and routes incoming frames to your SayHello method.
HTTP/2 multiplexing is the hidden advantage here. Older HTTP/1.1 servers open a new TCP connection for each request, which wastes goroutines and OS file descriptors. HTTP/2 streams multiple requests over a single connection. Go's scheduler maps each stream to a lightweight goroutine. When a stream blocks on I/O, the goroutine parks, and the scheduler moves to the next ready stream. You get the simplicity of synchronous-looking code with the throughput of asynchronous event loops.
The binary format strips away field names and uses numeric tags. A message like {"name": "Alice"} becomes a few bytes of length-prefixed data. The client and server both read the same .proto definition, so they agree on tag numbers. Field numbers are permanent. You can add new fields or rename them, but you never reuse a deleted field number. The compiler rejects structural mismatches with field X has already been defined if you accidentally duplicate numbers in the same message.
Pitfalls and runtime behavior
Cross-language interop introduces a specific set of failure modes. The most common mistake is forgetting to register the server implementation. If you skip pb.RegisterGreeterServer, the framework starts listening but routes every request to the default unimplemented handler. The client receives an Unimplemented status code. The compiler will not catch this because registration happens at runtime.
Another frequent issue involves mismatched .proto definitions. If you update a field number or change a message name on the server side without regenerating the client code, the binary serialization breaks. The client will either fail to parse the response or silently drop unknown fields. Protocol buffers are forward and backward compatible if you only add optional fields or keep field numbers stable. Changing a field number or switching a type from string to int breaks compatibility.
Streaming endpoints introduce goroutine leaks if you do not manage the context correctly. A server-side stream sends multiple responses over a single call. If the client disconnects abruptly, the server goroutine keeps writing to a closed stream until the context cancels. Always check ctx.Err() between yields in a streaming loop. The runtime will panic with rpc error: code = Canceled desc = context canceled if you try to write after the stream closes, but checking the context first prevents the panic and cleans up resources gracefully.
Go's error handling philosophy applies directly here. You do not wrap gRPC errors with custom types unless you need to attach metadata. The status package already carries error details. Passing a *string to a generated method will fail at compile time with cannot use x (type *string) as string value in argument. The generated code expects concrete types or pointers to the generated structs. Trust the type system. If the signature asks for *pb.GreetRequest, pass exactly that.
The community accepts verbose error checking because it makes the unhappy path visible. You will see if err != nil { return nil, err } repeatedly. That repetition is intentional. It forces you to acknowledge failure at every boundary. gRPC follows the same pattern. Returning a plain error from the handler automatically converts it to an Unknown status code on the wire. Using status.Error gives the client a precise code like InvalidArgument or NotFound, which maps cleanly to HTTP 4xx equivalents.
When to reach for gRPC
Use gRPC when you control both the client and server and need high-performance binary serialization across services. Use gRPC when you want strict contract enforcement and automatic code generation for multiple languages. Use standard HTTP/JSON REST when you need broad compatibility with third-party tools, browser fetch APIs, or legacy systems that cannot handle HTTP/2 multiplexing. Use WebSockets when you require full-duplex, low-latency bidirectional communication for real-time dashboards or chat applications. Use direct TCP sockets when you are building a custom protocol and need absolute control over the wire format and connection lifecycle.
Context is plumbing. Run it through every long-lived call site.