gRPC client in Go

Create a gRPC-compatible HTTP/2 client in Go using http2.Transport or standard http.Client with TLS.

The remote call that feels local

You need to call a remote service that processes image thumbnails. REST feels too heavy for the constant back-and-forth. JSON parsing eats your CPU. You switch to gRPC because it uses Protocol Buffers and HTTP/2 multiplexing. Now you have to write the client.

gRPC stands for Google Remote Procedure Call. It lets you call a function on a remote machine like it's a local function. Under the hood, it serializes your data into a compact binary format, sends it over a persistent HTTP/2 connection, and deserializes the response. Think of it like a dedicated freight train instead of sending individual postal letters. The tracks are laid once, and cars share the same line without waiting for each other to clear the switch.

The client side handles three jobs. It manages the underlying TCP connection. It serializes your Go structs into bytes. It waits for the response and deserializes it back into Go values. You do not write the serialization code. You run a code generator once, and it gives you a client stub that looks like a normal Go struct.

The stub hides the network. You write business logic, not socket code.

Minimal example

// Package main demonstrates a basic gRPC client setup.
package main

import (
	"context"
	"log"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	pb "yourproject/gen/proto" // Generated protobuf package
)

// CallRemoteService creates a connection and executes a single RPC.
func CallRemoteService() {
	// Dial uses a lazy connection strategy. It does not open the TCP socket immediately.
	conn, err := grpc.Dial(
		"localhost:50051",
		grpc.WithTransportCredentials(insecure.NewCredentials()),
	)
	if err != nil {
		log.Fatalf("failed to dial: %v", err)
	}
	defer conn.Close() // Close releases the connection pool and background goroutines.

	// The stub is generated by protoc-gen-go-grpc. It holds a reference to the connection.
	client := pb.NewThumbnailServiceClient(conn)

	// Context carries deadlines and cancellation signals across the network boundary.
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	// Execute the RPC. The stub handles serialization and HTTP/2 framing automatically.
	resp, err := client.GenerateThumbnail(ctx, &pb.ThumbnailRequest{
		SourceUrl: "https://example.com/photo.jpg",
		Width:     200,
	})
	if err != nil {
		log.Fatalf("RPC failed: %v", err)
	}

	log.Printf("Generated thumbnail ID: %s", resp.ThumbnailId)
}

Under the hood: connection lifecycle and HTTP/2

When you call grpc.Dial, the library does not immediately connect to the server. It creates a ClientConn object that tracks connection state and starts a background goroutine to manage keepalives and reconnection logic. The actual TCP handshake happens the moment you make your first RPC call. This lazy approach saves resources when you create clients that might never be used.

The context.WithTimeout call sets a deadline. gRPC respects Go context conventions. The deadline travels over the wire as an HTTP/2 header. If the server takes too long, the client cancels the request and returns a context.DeadlineExceeded error. You do not need to write custom timeout logic. The framework handles it.

When client.GenerateThumbnail runs, the generated stub serializes the ThumbnailRequest struct into Protocol Buffer bytes. It opens a new HTTP/2 stream on the existing TCP connection. The server processes the request and streams the response back. The stub deserializes the bytes into a ThumbnailResponse struct. If the server returns a gRPC status code like Unavailable or Internal, the stub converts it into a Go error that implements grpc.Code().

HTTP/2 multiplexing is the real advantage. Traditional HTTP/1.1 opens a new TCP connection for each request or reuses one connection but processes requests sequentially. HTTP/2 splits a single TCP connection into multiple independent streams. Your client can send five requests simultaneously without head-of-line blocking. The framework assigns a stream ID to each call and reassembles the responses in the correct order.

Context flows everywhere. Deadlines prevent silent hangs.

Production-ready client wrapper

Production clients need retry logic, connection pooling, and proper error classification. You rarely dial inside a hot path. You create the connection once, reuse it across requests, and shut it down gracefully when the process exits.

// Package service shows a production-ready gRPC client wrapper.
package service

import (
	"context"
	"fmt"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/credentials"
	"google.golang.org/grpc/keepalive"
	"google.golang.org/grpc/status"
	pb "yourproject/gen/proto"
)

// ThumbnailClient wraps the generated stub with connection management.
type ThumbnailClient struct {
	conn   *grpc.ClientConn
	client pb.ThumbnailServiceClient
}

// NewThumbnailClient establishes a persistent connection to the gRPC server.
func NewThumbnailClient(addr string) (*ThumbnailClient, error) {
	// Load TLS credentials for encrypted transport.
	creds, err := credentials.NewClientTLSFromFile("server.pem", "")
	if err != nil {
		return nil, fmt.Errorf("failed to load TLS cert: %w", err)
	}

	// Dial with explicit keepalive settings to detect dead connections.
	conn, err := grpc.Dial(
		addr,
		grpc.WithTransportCredentials(creds),
		grpc.WithKeepaliveParams(keepalive.ClientParameters{
			Time:                10 * time.Second,
			Timeout:             5 * time.Second,
			PermitWithoutStream: true,
		}),
	)
	if err != nil {
		return nil, fmt.Errorf("failed to create client connection: %w", err)
	}

	return &ThumbnailClient{
		conn:   conn,
		client: pb.NewThumbnailServiceClient(conn),
	}, nil
}

// ProcessImage calls the remote service with a strict deadline and classifies errors.
func (c *ThumbnailClient) ProcessImage(ctx context.Context, url string) (string, error) {
	// Derive a child context with a deadline for this specific RPC.
	callCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
	defer cancel()

	resp, err := c.client.GenerateThumbnail(callCtx, &pb.ThumbnailRequest{
		SourceUrl: url,
		Width:     300,
	})

	// Classify the error to decide if a retry makes sense.
	if err != nil {
		st := status.Convert(err)
		switch st.Code() {
		case codes.Unavailable, codes.DeadlineExceeded:
			// Transient error. The caller can retry with exponential backoff.
			return "", fmt.Errorf("service unavailable: %w", err)
		case codes.InvalidArgument:
			// Permanent error. Do not retry.
			return "", fmt.Errorf("bad request: %w", err)
		default:
			return "", fmt.Errorf("unknown RPC error: %w", err)
		}
	}

	return resp.ThumbnailId, nil
}

// Close gracefully shuts down the connection and waits for in-flight RPCs to finish.
func (c *ThumbnailClient) Close() error {
	return c.conn.Close()
}

Go conventions shape how you write this code. context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. The receiver name is usually one or two letters matching the type: (c *ThumbnailClient) ProcessImage(...), not (this *ThumbnailClient). Error handling uses if err != nil { return fmt.Errorf("...: %w", err) }. The verbosity is intentional. It makes the unhappy path visible and forces you to decide what happens when things break. gofmt handles indentation and spacing automatically. Run it on save. Argue logic, not formatting.

Dial once. Context everywhere. Close explicitly.

Pitfalls and compiler guardrails

The most common mistake is dialing inside a request handler. Every HTTP request creates a new ClientConn, which spawns background goroutines for keepalives and connection management. Those goroutines leak when the handler returns. Dial once at startup. Share the ClientConn across all handlers.

Another trap is ignoring connection state. grpc.Dial returns immediately. The connection might still be in CONNECTING state when you make your first call. gRPC handles this transparently by queuing the RPC until the connection is ready. You do not need to poll for READY state. Just make the call.

Context cancellation is strict. If you pass a parent context that gets cancelled, the RPC aborts immediately. The server might still process the request, but the client drops the response. Always use context.WithTimeout or context.WithCancel for individual calls. Never pass a bare context.Background() in a long-running service.

The compiler catches type mismatches early. If you pass a *string where a string is expected, you get cannot use x (type *string) as string in argument. If you forget to import the generated protobuf package, the compiler rejects the file with undefined: pb. If you try to call a method that does not exist on the stub, you get client.GenerateThumbnail undefined (type pb.ThumbnailServiceClient has no field or method GenerateThumbnail). Trust the type system. It prevents entire classes of runtime panics.

Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. The worst goroutine bug is the one that never logs.

Decision matrix

Use gRPC when you control both the client and server and need high-performance binary serialization. Use gRPC when you want built-in streaming for large payloads or continuous data feeds. Use standard net/http with JSON when you need browser compatibility or public API interoperability. Use raw HTTP/2 via golang.org/x/net/http2 when you need fine-grained control over multiplexed streams without the gRPC framework overhead. Use WebSockets when you require full-duplex text communication for real-time chat or collaborative editing.

Where to go next