Build a Task Manager gRPC Service

Define a proto contract, generate Go code, and build a gRPC server and client.

Introduction

30 Second Summary

Every time you use an app that feels instant, there is a good chance two services are talking behind the scenes using something faster than REST. That something is gRPC, a high-performance framework that sends structured binary data instead of bulky JSON text.

In this project, you will build a Task Manager service in Go that defines its entire API in a .proto file, generates server and client code from it, and streams real-time task events over a persistent connection.

What You'll Build

You start the server in one terminal, fire up the client in another, and watch tasks appear in real time as you create them.

By the end of this project, you'll have:

  • A running gRPC server you can call from your client to create, retrieve, and list tasks stored in memory.
  • A Go client that calls the server like a local function, proving that the data crosses the network as binary Protocol Buffers instead of JSON.
  • A server-side streaming RPC where you subscribe in one terminal and watch new task events push through the moment another client creates them.
  • Secret Mission: Add a unary interceptor that logs every RPC call with its method name and duration.

Are there any prerequisites?

You should be comfortable writing Go (structs, interfaces, goroutines). This project assumes you already have Go and VS Code installed.

Step 1 walks you through installing the Protocol Buffer toolchain, so no prior gRPC experience is needed.

Before We Start

Before We Start...

Before we dive in, let's lock in what you're about to build and why it matters.

In this project, you will define an entire API contract in a .proto file, generate Go code from it, and implement both a gRPC server and client that communicate using binary Protocol Buffers over HTTP/2.

Install the Protobuf Toolchain

gRPC relies on Protocol Buffers to define service contracts in .proto files. Go alone cannot compile these files into working code.

You need three tools to bridge the gap: protoc (the Protocol Buffer compiler), protoc-gen-go (generates Go message types), and protoc-gen-go-grpc (generates Go server/client stubs). This step gets all three installed and on your PATH.

In this step, get ready to:
  • Install protoc (the Protocol Buffer compiler).
  • Install protoc-gen-go and protoc-gen-go-grpc (the Go code generation plugins).
  • Verify all three tools are accessible from your terminal.
Install the Protocol Buffer compiler

The protoc compiler reads .proto files and delegates code generation to language-specific plugins. Without it, your service definition stays as plain text.

  • Check whether protoc is already installed by running this command:
protoc --version

✔️ I see version 35.1 or higher

You already have a compatible version of protoc installed. Skip ahead to the next substep where you install the Go plugins.

ⓧ I see an older version

You have an outdated version of protoc that may not support the latest proto3 features. Install v35.1 to replace it.

macOS

Pick the right file for your chip
  • Apple Silicon (M1/M2/M3): protoc-35.1-osx-aarch_64.zip
  • Intel Mac: protoc-35.1-osx-x86_64.zip

Replace the filename in the commands below with the one matching your chip.

  • Download, extract, and add protoc to your PATH by running these commands:
# Download protoc v35.1 (Apple Silicon shown - swap filename if Intel)
curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v35.1/protoc-35.1-osx-aarch_64.zip

# Extract to $HOME/.local
unzip protoc-35.1-osx-aarch_64.zip -d $HOME/.local

# Add to PATH for this session
export PATH="$HOME/.local/bin:$PATH"
How do I make this permanent?

The export command only lasts for your current terminal session. To make it permanent, add the line export PATH="$HOME/.local/bin:$PATH" to your ~/.zshrc file.

Linux

  • Download, extract, and add protoc to your PATH by running these commands:
# Download protoc v35.1 for Linux x86_64
curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v35.1/protoc-35.1-linux-x86_64.zip

# Extract to $HOME/.local
unzip protoc-35.1-linux-x86_64.zip -d $HOME/.local

# Add to PATH for this session
export PATH="$HOME/.local/bin:$PATH"
How do I make this permanent?

The export command only lasts for your current terminal session. To make it permanent, add the line export PATH="$HOME/.local/bin:$PATH" to your ~/.bashrc file.

Windows

  • Download and extract protoc by running these commands in PowerShell:
# Download protoc v35.1 for Windows
Invoke-WebRequest -Uri "https://github.com/protocolbuffers/protobuf/releases/download/v35.1/protoc-35.1-win64.zip" -OutFile protoc.zip

# Extract to user local directory
Expand-Archive protoc.zip -DestinationPath "$env:USERPROFILE\.local"

# Clean up the zip file
Remove-Item protoc.zip
  • Add the protoc binary to your PATH permanently by running:
# Add protoc to your user PATH permanently
$currentPath = [Environment]::GetEnvironmentVariable('Path', 'User')
[Environment]::SetEnvironmentVariable('Path', "$currentPath;$env:USERPROFILE\.local\bin", 'User')

Close and reopen your terminal for the PATH change to take effect.

ⓧ Command not found

You do not have protoc installed yet. Follow the instructions below to install v35.1.

macOS

Pick the right file for your chip
  • Apple Silicon (M1/M2/M3): protoc-35.1-osx-aarch_64.zip
  • Intel Mac: protoc-35.1-osx-x86_64.zip

Replace the filename in the commands below with the one matching your chip.

  • Download, extract, and add protoc to your PATH by running these commands:
# Download protoc v35.1 (Apple Silicon shown - swap filename if Intel)
curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v35.1/protoc-35.1-osx-aarch_64.zip

# Extract to $HOME/.local
unzip protoc-35.1-osx-aarch_64.zip -d $HOME/.local

# Add to PATH for this session
export PATH="$HOME/.local/bin:$PATH"
How do I make this permanent?

The export command only lasts for your current terminal session. To make it permanent, add the line export PATH="$HOME/.local/bin:$PATH" to your ~/.zshrc file.

Linux

  • Download, extract, and add protoc to your PATH by running these commands:
# Download protoc v35.1 for Linux x86_64
curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v35.1/protoc-35.1-linux-x86_64.zip

# Extract to $HOME/.local
unzip protoc-35.1-linux-x86_64.zip -d $HOME/.local

# Add to PATH for this session
export PATH="$HOME/.local/bin:$PATH"
How do I make this permanent?

The export command only lasts for your current terminal session. To make it permanent, add the line export PATH="$HOME/.local/bin:$PATH" to your ~/.bashrc file.

Windows

  • Download and extract protoc by running these commands in PowerShell:
# Download protoc v35.1 for Windows
Invoke-WebRequest -Uri "https://github.com/protocolbuffers/protobuf/releases/download/v35.1/protoc-35.1-win64.zip" -OutFile protoc.zip

# Extract to user local directory
Expand-Archive protoc.zip -DestinationPath "$env:USERPROFILE\.local"

# Clean up the zip file
Remove-Item protoc.zip
  • Add the protoc binary to your PATH permanently by running:
# Add protoc to your user PATH permanently
$currentPath = [Environment]::GetEnvironmentVariable('Path', 'User')
[Environment]::SetEnvironmentVariable('Path', "$currentPath;$env:USERPROFILE\.local\bin", 'User')

Close and reopen your terminal for the PATH change to take effect.

Install the Go code generation plugins

The protoc compiler handles parsing, but it relies on plugins to produce language-specific output. For Go, you need two plugins: one for message types and one for gRPC service stubs.

  • Install both Go plugins by running these commands:
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

What do these plugins do?

protoc-gen-go generates Go structs from your .proto message definitions. protoc-gen-go-grpc generates the server interface and client stubs for each RPC you define.

When you run protoc, it looks for these plugins on your PATH by name. If it cannot find them, code generation fails.

  • Make the plugins discoverable by adding Go's bin directory to your PATH:
export PATH="$PATH:$(go env GOPATH)/bin"

Make this permanent too

Add export PATH="$PATH:$(go env GOPATH)/bin" to your shell config file (~/.zshrc on macOS, ~/.bashrc on Linux). On Windows, add the output of go env GOPATH plus \bin to your user PATH using the same method as above.

Verify the full toolchain

All three tools should now be installed and on your PATH. Before you run the check, what do you expect each command to print?

  • Verify all three tools are accessible by running these commands:
protoc --version
protoc-gen-go --version
protoc-gen-go-grpc --version

You should see libprotoc 35.1 from the first command, followed by version strings from each plugin confirming they are installed.

Seeing command not found?

  • If protoc is not found, confirm $HOME/.local/bin is in your PATH. Run echo $PATH and look for it in the output.
  • If protoc-gen-go or protoc-gen-go-grpc is not found, check that $(go env GOPATH)/bin is in your PATH. Run ls $(go env GOPATH)/bin to verify the binaries exist there.
  • On Windows, make sure you restarted your terminal after updating the PATH environment variable.

help me troubleshoot protoc or the Go plugins not being found on my PATH

Your protobuf toolchain is ready. Next up, you will define your Task Manager service in a .proto file and generate Go code from it.

Define Your Service in a Proto File

Your protobuf toolchain is ready. Now it is time to use it for what gRPC does differently from REST: defining your entire API in a single contract file before writing any implementation code.

The .proto file is the single source of truth that both your server and client will be generated from. You describe what your service can do, what data it accepts, and what it returns. Then a compiler turns that description into working Go code.

In this step, get ready to:
  • Create the project structure and initialize a Go module.
  • Write a .proto file defining your task service contract.
  • Generate Go server and client code using protoc.
Create the project structure

Every gRPC project starts with a directory, a Go module, and a proto/ folder to hold your service definitions.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder, initialize a Go module, and create the proto directory by running these commands:
mkdir task-manager-grpc
cd task-manager-grpc
go mod init task-manager-grpc
mkdir proto

You should see go: creating new go.mod: module task-manager-grpc in the output.

  • Confirm your project structure by running:
ls

You should see go.mod and proto listed in the output.

Don't see go.mod?

Make sure you ran go mod init task-manager-grpc from inside the task-manager-grpc directory, not from your Desktop.

Help me troubleshoot my go mod init

  • Open the project in VS Code by running:
code .
Write the proto file

Protocol Buffers use proto3 syntax to define messages (your data structures) and services (your API endpoints). This is the contract-first approach: you decide what the API looks like before writing any logic.

  • Create a new file called task.proto inside the proto/ folder in VS Code.
  • Paste the following code into proto/task.proto:
syntax = "proto3";

package task;

option go_package = "task-manager-grpc/proto";

message Task {
  string id = 1;
  string title = 2;
  string description = 3;
  bool done = 4;
}

message CreateTaskRequest {
  string title = 1;
  string description = 2;
}

message CreateTaskResponse {
  Task task = 1;
}

message GetTaskRequest {
  string id = 1;
}

message GetTaskResponse {
  Task task = 1;
}

What does this code do?

  • syntax = "proto3" declares you are using the latest Protocol Buffers syntax.
  • option go_package tells protoc where to place generated Go code relative to your module.
  • Task is the core data structure with an ID, title, description, and done flag. Each field has a unique number used for binary encoding.
  • The request and response messages define the input and output shapes for your RPCs. Each RPC gets its own pair.
  • Add the following code below the GetTaskResponse message in the same file:
message ListTasksRequest {}

message ListTasksResponse {
  repeated Task tasks = 1;
}

message WatchTasksRequest {}

message TaskEvent {
  Task task = 1;
}

service TaskService {
  rpc CreateTask(CreateTaskRequest) returns (CreateTaskResponse);
  rpc GetTask(GetTaskRequest) returns (GetTaskResponse);
  rpc ListTasks(ListTasksRequest) returns (ListTasksResponse);
  rpc WatchTasks(WatchTasksRequest) returns (stream TaskEvent);
}

What does this code do?

  • ListTasksRequest is empty because listing all tasks requires no input.
  • repeated Task tasks means the response contains a list of Task objects.
  • TaskEvent wraps a Task for the streaming RPC. When a new task is created, the server pushes a TaskEvent to any watching client.
  • The service TaskService block defines four RPCs. Three are unary (request in, response out). WatchTasks uses the stream keyword to keep pushing events to the client.

Why define WatchTasks now?

You will implement the streaming logic later in Step 5. Defining it now means protoc generates the full interface up front, so you only run the code generator once.

  • Save proto/task.proto.

✔️ Awesome, I've got everything!

Great. Double check you have saved proto/task.proto before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm your file contains both chunks in order:

  • The header (syntax, package, option) followed by Task, CreateTaskRequest, CreateTaskResponse, GetTaskRequest, and GetTaskResponse messages.
  • ListTasksRequest, ListTasksResponse, WatchTasksRequest, TaskEvent messages followed by the service TaskService block with four RPCs.
Generate Go code from the proto file

The protoc compiler reads your .proto file and outputs Go structs and interfaces you can import directly. This is the code generation step that makes gRPC different from hand-writing HTTP handlers.

  • In your terminal, make sure you are in the task-manager-grpc directory.
  • Generate the Go code by running:
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative proto/task.proto

What does this command do?

  • --go_out=. tells protoc to generate message structs in the current directory tree.
  • --go-grpc_out=. tells protoc to generate the gRPC service code (server interface and client stub).
  • paths=source_relative places the generated .go files next to the .proto source.
  • Pull in the dependencies that the generated code needs by running:
go mod tidy

Before you check the results, what two new files do you expect to see inside the proto/ directory?

  • Verify the generated files by running:
ls proto/

You should see three files: task.pb.go, task.proto, and task_grpc.pb.go.

What got generated?

  • task.pb.go contains Go structs for every message you defined (Task, CreateTaskRequest, etc.) plus serialization methods.
  • task_grpc.pb.go contains the TaskServiceServer interface you will implement, the TaskServiceClient interface your client will use, UnimplementedTaskServiceServer for forward compatibility, and RegisterTaskServiceServer to wire it all together.

Seeing a "not found" or "program not found" error?

  • If protoc cannot find the plugins, make sure $(go env GOPATH)/bin is on your PATH. Re-run the export from Step 1.
  • If you see a file-not-found error for proto/task.proto, confirm you are running the command from the task-manager-grpc root (not from inside proto/).

Help me fix a protoc error when generating Go code

Your service contract is locked in and the generated code is ready. Next up, you will implement the server that brings these definitions to life.

Implement and Run the gRPC Server

In the previous step, you defined your service contract in a .proto file and used protoc to generate Go code from it. That generated code gives you a Go interface to implement, but the actual server logic is yours to write.

This step connects the proto contract to real behavior. You will store tasks in memory and serve them to clients over gRPC.

In this step, get ready to:
  • Set up the server struct with the required interface embedding.
  • Implement the four RPC methods: CreateTask, GetTask, ListTasks, and WatchTasks.
  • Wire up the main function to listen on port 50051 and serve requests.
Create the server scaffold

The generated file task_grpc.pb.go defines a TaskServiceServer interface. Your job is to create a struct that satisfies this interface by embedding the generated UnimplementedTaskServiceServer and then adding your own method implementations on top.

  • Create a server directory inside your task-manager-grpc folder by running:
mkdir server
  • Create server/main.go and add the package declaration, imports, server struct, and constructor by pasting the following code:
package main

import (
	"context"
	"fmt"
	"log"
	"net"
	"sync"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"

	pb "task-manager-grpc/proto"
)

type taskServer struct {
	pb.UnimplementedTaskServiceServer
	mu       sync.Mutex
	tasks    map[string]*pb.Task
	nextID   int
	watchers []chan *pb.TaskEvent
}

func newTaskServer() *taskServer {
	return &taskServer{
		tasks: make(map[string]*pb.Task),
	}
}

What does this code do?

This sets up the foundation for your gRPC server implementation.

  • pb.UnimplementedTaskServiceServer is embedded by value (not pointer) so your struct satisfies the generated interface. Any RPC you haven't implemented yet returns an "unimplemented" error instead of crashing.
  • mu sync.Mutex protects the task map from concurrent access. Multiple clients can call the server at the same time.
  • tasks is an in-memory map that stores tasks by ID.
  • watchers holds channels for streaming subscribers. You will use this when implementing WatchTasks later in this step.
Add the main function and start the server

Your struct satisfies the interface thanks to the embedded UnimplementedTaskServiceServer. That means you can already wire up a main() function and run the server before implementing any RPCs.

  • Add the main() function below newTaskServer() in server/main.go:
func main() {
	lis, err := net.Listen("tcp", ":50051")
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}

	grpcServer := grpc.NewServer()
	pb.RegisterTaskServiceServer(grpcServer, newTaskServer())

	log.Println("gRPC server listening on :50051")
	if err := grpcServer.Serve(lis); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}

What does this code do?

  • net.Listen("tcp", ":50051") opens a TCP port for the server to accept connections on.
  • grpc.NewServer() creates a new gRPC server instance.
  • RegisterTaskServiceServer() is the generated function that binds your implementation to the server. It tells gRPC which struct handles incoming TaskService calls.
  • Serve(lis) blocks and starts accepting connections. The server stays running until you stop it.
  • Save server/main.go.
  • Download the module dependencies by running:
go mod tidy
  • Before you run the server, what do you think you will see in the terminal?
  • Start the server by running:
go run server/main.go

You should see this output:

gRPC server listening on :50051

The server is now running and waiting for connections. It will stay in the foreground and hold your terminal open.

Seeing an error instead?

  • If you see cannot find module providing package, make sure you ran go mod tidy first. This downloads the gRPC and protobuf packages.
  • If you see failed to listen, another process may already be using port 50051. Close any other servers running on that port and try again.

Help me debug this error.

Implement CreateTask and GetTask

The server runs, but every RPC currently returns an "unimplemented" error because you are relying on the embedded default. Time to add real logic.

  • Stop the running server by pressing Ctrl+C in the terminal.
  • In server/main.go, add the CreateTask method between newTaskServer() and main():
func (s *taskServer) CreateTask(ctx context.Context, req *pb.CreateTaskRequest) (*pb.CreateTaskResponse, error) {
	s.mu.Lock()
	s.nextID++
	id := fmt.Sprintf("task-%d", s.nextID)
	task := &pb.Task{
		Id:          id,
		Title:       req.GetTitle(),
		Description: req.GetDescription(),
		Done:        false,
	}
	s.tasks[id] = task

	event := &pb.TaskEvent{Task: task}
	for _, ch := range s.watchers {
		select {
		case ch <- event:
		default:
		}
	}
	s.mu.Unlock()

	return &pb.CreateTaskResponse{Task: task}, nil
}

What does this code do?

  • Locks the mutex, increments nextID, and creates a new Task with a unique ID like task-1.
  • Stores the task in the tasks map so it can be retrieved later.
  • Notifies any connected streaming watchers by sending the new task event to their channels. The select with default ensures a slow watcher does not block task creation.
  • Add the GetTask method directly below CreateTask:
func (s *taskServer) GetTask(ctx context.Context, req *pb.GetTaskRequest) (*pb.GetTaskResponse, error) {
	s.mu.Lock()
	task, ok := s.tasks[req.GetId()]
	s.mu.Unlock()

	if !ok {
		return nil, status.Errorf(codes.NotFound, "task %s not found", req.GetId())
	}
	return &pb.GetTaskResponse{Task: task}, nil
}

What does this code do?

  • Looks up the requested task ID in the map.
  • If the ID does not exist, returns a gRPC NotFound status error. This is the gRPC equivalent of an HTTP 404.
  • If found, wraps the task in a GetTaskResponse and returns it.
  • Save server/main.go.
  • Confirm your code compiles by running the server again:
go run server/main.go

You should see gRPC server listening on :50051 again, confirming the new methods compiled without errors.

Seeing a compilation error?

  • Check that the method signatures match exactly. The receiver must be (s *taskServer) and the return types must match the generated interface.
  • Make sure CreateTask and GetTask are placed between newTaskServer() and main(), not inside another function.

Help me fix this compilation error.

Implement ListTasks and WatchTasks

Two more methods to go. ListTasks returns all stored tasks, and WatchTasks is a server-side streaming RPC that pushes real-time events to connected clients.

  • Stop the running server with Ctrl+C.
  • Add the ListTasks method below GetTask:
func (s *taskServer) ListTasks(ctx context.Context, req *pb.ListTasksRequest) (*pb.ListTasksResponse, error) {
	s.mu.Lock()
	tasks := make([]*pb.Task, 0, len(s.tasks))
	for _, t := range s.tasks {
		tasks = append(tasks, t)
	}
	s.mu.Unlock()

	return &pb.ListTasksResponse{Tasks: tasks}, nil
}

What does this code do?

  • Creates a slice, iterates over every task in the map, and appends each one.
  • Returns all tasks wrapped in a ListTasksResponse. The mutex ensures a consistent snapshot even if another goroutine is creating tasks concurrently.
  • Add the WatchTasks method below ListTasks:
func (s *taskServer) WatchTasks(req *pb.WatchTasksRequest, stream pb.TaskService_WatchTasksServer) error {
	ch := make(chan *pb.TaskEvent, 10)

	s.mu.Lock()
	s.watchers = append(s.watchers, ch)
	s.mu.Unlock()

	defer func() {
		s.mu.Lock()
		for i, w := range s.watchers {
			if w == ch {
				s.watchers = append(s.watchers[:i], s.watchers[i+1:]...)
				break
			}
		}
		s.mu.Unlock()
	}()

	for {
		select {
		case <-stream.Context().Done():
			return nil
		case event := <-ch:
			if err := stream.Send(event); err != nil {
				return err
			}
		}
	}
}

What does this code do?

This is a server-side streaming RPC. Instead of returning a single response, it keeps the connection open and pushes events as they happen.

  • Creates a buffered channel and registers it in the watchers slice. When CreateTask runs, it sends events to all registered channels.
  • The defer block removes the channel from the watchers list when the client disconnects.
  • The infinite for/select loop waits for either a new event to send via stream.Send(), or for the client to disconnect (stream.Context().Done()).
  • Save server/main.go.
  • Before you restart the server, do you expect it to compile cleanly with all four methods added?
  • Start the server one final time by running:
go run server/main.go

You should see gRPC server listening on :50051 printed to the terminal. The server is now running with all four RPCs fully implemented and ready to accept client connections.

Server won't start?

  • If you see a type mismatch error on WatchTasks, check the method signature carefully. The first parameter is the request (*pb.WatchTasksRequest), and the second is the stream (pb.TaskService_WatchTasksServer). This is different from unary RPCs where context.Context comes first.
  • If you see address already in use, your previous server run may still be alive. Press Ctrl+C in the original terminal first.

Help me fix my gRPC server.

Why embed UnimplementedTaskServiceServer?

The generated gRPC code requires your server struct to satisfy every method in the TaskServiceServer interface. Embedding UnimplementedTaskServiceServer by value provides default implementations that return an "unimplemented" error.

This means if a new RPC is added to the proto in the future, your server still compiles. You only override the methods you are ready to implement.

✔️ Awesome, I've got everything!

Great. Make sure you have saved server/main.go and that the server is running with the gRPC server listening on :50051 message visible in your terminal.

ⓧ I'd like to double check the full code

Your server/main.go should contain these sections in order. Scroll up and confirm each one is present:

  • Package declaration and imports (context, fmt, log, net, sync, grpc, codes, status, pb).
  • taskServer struct with UnimplementedTaskServiceServer embedding, mu, tasks, nextID, and watchers fields.
  • newTaskServer() constructor that initializes the map.
  • CreateTask method that increments the ID, stores the task, and notifies watchers.
  • GetTask method that looks up by ID and returns NotFound if missing.
  • ListTasks method that returns all tasks.
  • WatchTasks method with a channel, deferred cleanup, and for/select loop.
  • main() function with net.Listen, grpc.NewServer(), RegisterTaskServiceServer, and Serve().

Your gRPC server is running and ready to accept connections on port 50051. Next up, you will build a Go client that talks to this server using the generated stubs.

Build the Client and Hit the Wall

Your server is running and ready to handle requests. With a REST API, you would open a new terminal and fire off a quick curl to test it. gRPC does not work that way.

gRPC sends data as binary Protocol Buffers over HTTP/2. Regular curl cannot decode this. You need a proper client that uses the generated stubs to speak the same binary language as the server. In this step, you will build that client and call your server like a local function.

In this step, get ready to:
  • Experience the gRPC wall by trying curl against your running server.
  • Create a Go client that connects to the server using generated stubs.
  • Call CreateTask, GetTask, and ListTasks and see real responses.
Hit the wall with curl

With a REST API, testing is as simple as sending an HTTP request with curl. What do you think happens if you try curl against your gRPC server?

  • Open a second terminal window and try hitting your server by running:
curl localhost:50051

You will see garbled binary output, a connection error, or an empty response. This is the wall. gRPC servers use binary protobuf over HTTP/2, which makes them impossible to interact with using regular curl. You need a client that speaks the same generated protobuf language.

Why can't you curl a gRPC server?

REST APIs send human-readable JSON over HTTP/1.1. gRPC sends binary-encoded Protocol Buffers over HTTP/2. Without a client that knows how to serialize and deserialize these binary messages using the generated code, the data is unreadable.

This is the trade-off for gRPC's speed and type safety. You lose quick-and-dirty curl testing, but you gain a strongly typed contract that both client and server share.

Create the client and connect to the server

The solution is to build a proper gRPC client using the same generated stubs your server implements.

  • Create the client directory and file by running:
mkdir -p client && touch client/main.go
  • Confirm the file exists by running:
ls client/

You should see main.go in the output.

  • Open client/main.go in VS Code and paste the following connection setup:
package main

import (
	"context"
	"flag"
	"fmt"
	"io"
	"log"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"

	pb "task-manager-grpc/proto"
)

func main() {
	// Parse command-line flags
	watch := flag.Bool("watch", false, "watch for new task events")
	flag.Parse()

	// Connect to the gRPC server
	conn, err := grpc.NewClient("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		log.Fatalf("failed to connect: %v", err)
	}
	defer conn.Close()

	// Create a typed client stub from the generated code
	client := pb.NewTaskServiceClient(conn)
	ctx := context.Background()

What does this code do?

  • grpc.NewClient("localhost:50051", ...) creates a connection to your running server. The insecure.NewCredentials() option skips TLS since you are running locally.
  • pb.NewTaskServiceClient(conn) wraps the raw connection in a typed client. This is generated code from your .proto file, giving you methods like CreateTask and GetTask that feel like local function calls.
  • The --watch flag is registered for later use. You will wire it up to server-side streaming in the next step.
  • Below the ctx := context.Background() line, add the watch handler block:
	if *watch {
		fmt.Println("Watching for new tasks... (press Ctrl+C to stop)")
		stream, err := client.WatchTasks(ctx, &pb.WatchTasksRequest{})
		if err != nil {
			log.Fatalf("failed to watch: %v", err)
		}
		for {
			event, err := stream.Recv()
			if err == io.EOF {
				break
			}
			if err != nil {
				log.Fatalf("error receiving event: %v", err)
			}
			fmt.Printf("[NEW TASK] ID: %s | Title: %s | Description: %s\n",
				event.GetTask().GetId(),
				event.GetTask().GetTitle(),
				event.GetTask().GetDescription())
		}
		return
	}

What does the watch block do?

When the --watch flag is set, the client opens a streaming connection to the server's WatchTasks RPC. It loops on stream.Recv(), blocking until the server pushes a new event or the stream ends. You will test this streaming path in the next step.

Call the server's RPCs

Now add the unary RPC calls that create tasks, retrieve them, and list everything stored on the server.

  • Below the watch block's closing brace, add the following RPC calls. This code goes right before the final closing } of the main function:
	// Create a task
	createResp, err := client.CreateTask(ctx, &pb.CreateTaskRequest{
		Title:       "Learn gRPC",
		Description: "Build a task manager service with Go and protobuf",
	})
	if err != nil {
		log.Fatalf("failed to create task: %v", err)
	}
	fmt.Printf("Created task: %s\n", createResp.GetTask().GetId())

	// Get the task by ID
	getResp, err := client.GetTask(ctx, &pb.GetTaskRequest{Id: createResp.GetTask().GetId()})
	if err != nil {
		log.Fatalf("failed to get task: %v", err)
	}
	fmt.Printf("Got task: %+v\n", getResp.GetTask())

	// Create another task
	_, err = client.CreateTask(ctx, &pb.CreateTaskRequest{
		Title:       "Deploy service",
		Description: "Containerize and deploy the gRPC service",
	})
	if err != nil {
		log.Fatalf("failed to create second task: %v", err)
	}

	// List all tasks
	time.Sleep(100 * time.Millisecond)
	listResp, err := client.ListTasks(ctx, &pb.ListTasksRequest{})
	if err != nil {
		log.Fatalf("failed to list tasks: %v", err)
	}
	fmt.Printf("\nAll tasks (%d):\n", len(listResp.GetTasks()))
	for _, t := range listResp.GetTasks() {
		fmt.Printf("  - [%s] %s: %s (done: %v)\n", t.GetId(), t.GetTitle(), t.GetDescription(), t.GetDone())
	}
}

What does this code do?

  • client.CreateTask(...) sends a CreateTaskRequest to the server and receives a CreateTaskResponse with the new task's ID. This feels like calling a local function, but the data crosses the network as binary protobuf.
  • client.GetTask(...) retrieves the task you just created by its ID, proving the server stored it.
  • client.ListTasks(...) returns every task in the server's in-memory map. The short time.Sleep ensures the second task is fully stored before listing.
  • Save client/main.go.
  • Run go mod tidy to resolve dependencies:
go mod tidy

Before you run the client, predict what you expect to see. Will the connection succeed? How many tasks should appear in the list?

  • Run the client in your second terminal (make sure the server is still running in the first):
go run client/main.go

You should see output showing the created task ID, the retrieved task details, and a list of two tasks:

Created task: task-1
Got task: id:"task-1" title:"Learn gRPC" description:"Build a task manager service with Go and protobuf"

All tasks (2):
  - [task-1] Learn gRPC: Build a task manager service with Go and protobuf (done: false)
  - [task-2] Deploy service: Containerize and deploy the gRPC service (done: false)

That is your gRPC client talking to the server over binary protobuf. No JSON, no REST, no curl needed.

Seeing connection refused?

  • Make sure your server is still running in the first terminal. You should see the gRPC server listening on :50051 log line there.
  • Check that you are running the client from the task-manager-grpc project directory, not from client/ directly.
  • If you see import errors, run go mod tidy again and confirm it completes without errors.

help me debug my gRPC client connection

✔️ Awesome, I've got everything!

Great. Double check you have saved client/main.go and that go mod tidy ran cleanly.

ⓧ I'd like to double check the full code

Your client/main.go should contain these three chunks in order:

  • The imports, connection setup, and ctx declaration (the first code block you pasted).
  • The if *watch { ... } block (the second code block).
  • The unary RPC calls ending with the ListTasks loop and the closing } (the third code block).

Scroll up and confirm each block is present in that order. The file should have one package main declaration and one func main() function.

Your client is calling the server like a local function, but the data travels across the network as binary protobuf. Next up, you will unlock the real power of gRPC: server-side streaming that pushes new task events to your client in real time.

Add Server-Side Streaming

So far, your RPCs follow a request-response pattern: the client asks, the server answers, and the connection closes. This is fine for CRUD operations, but it forces the client to poll if it wants updates.

Streaming is where gRPC shows its real power. With server-side streaming, the server keeps the connection open and pushes data to the client as events happen. No polling, no WebSocket setup. Just a single streaming RPC defined in your proto file.

In this step, get ready to:
  • Understand how the streaming RPC is defined in your proto file and implemented on the server.
  • Verify real-time event streaming by running two client terminals simultaneously.
Review how streaming works in your code

Your proto file, server, and client already include everything needed for streaming. Let's walk through the three layers so you understand the pattern before testing it live.

In proto/task.proto, find the WatchTasks RPC at the bottom of the service definition:

rpc WatchTasks(WatchTasksRequest) returns (stream TaskEvent);

What does the stream keyword do?

The stream keyword before TaskEvent in the return type tells protoc to generate a streaming interface instead of a simple response. The server can call Send() multiple times on the same connection, and the client loops on Recv() to receive each message as it arrives.

On the server side, open server/main.go and find the WatchTasks method:

func (s *taskServer) WatchTasks(req *pb.WatchTasksRequest, stream pb.TaskService_WatchTasksServer) error {
	ch := make(chan *pb.TaskEvent, 10)

	s.mu.Lock()
	s.watchers = append(s.watchers, ch)
	s.mu.Unlock()

	defer func() {
		s.mu.Lock()
		for i, w := range s.watchers {
			if w == ch {
				s.watchers = append(s.watchers[:i], s.watchers[i+1:]...)
				break
			}
		}
		s.mu.Unlock()
	}()

What does this code do?

  • When a client connects to WatchTasks, the server creates a buffered channel and adds it to the watchers slice.
  • The defer block removes the channel when the client disconnects, preventing memory leaks.
  • The mutex protects the slice from concurrent access since multiple clients can connect and disconnect at the same time.

The second half of WatchTasks is the event loop that keeps the connection alive:

	for {
		select {
		case <-stream.Context().Done():
			return nil
		case event := <-ch:
			if err := stream.Send(event); err != nil {
				return err
			}
		}
	}
}

How does the event loop work?

  • The select statement blocks until either the client disconnects (stream.Context().Done()) or a new event arrives on the channel.
  • When CreateTask is called, it pushes a TaskEvent to every watcher's channel. This loop picks it up and calls stream.Send(event) to push it to the client.

On the client side, find the --watch branch in client/main.go:

	if *watch {
		fmt.Println("Watching for new tasks... (press Ctrl+C to stop)")
		stream, err := client.WatchTasks(ctx, &pb.WatchTasksRequest{})
		if err != nil {
			log.Fatalf("failed to watch: %v", err)
		}
		for {
			event, err := stream.Recv()
			if err == io.EOF {
				break
			}
			if err != nil {
				log.Fatalf("error receiving event: %v", err)
			}
			fmt.Printf("[NEW TASK] ID: %s | Title: %s | Description: %s\n",
				event.GetTask().GetId(),
				event.GetTask().GetTitle(),
				event.GetTask().GetDescription())
		}
		return
	}

What does the client's watch mode do?

The client calls WatchTasks and gets back a stream object. It then loops on stream.Recv() which blocks until the server sends an event. Each received event gets printed immediately. The loop runs until the server closes the stream (io.EOF) or an error occurs.

Why server-side streaming instead of polling?

With polling, the client would call ListTasks every few seconds and compare results. That wastes bandwidth when nothing has changed and adds latency when something has.

With streaming, the server pushes the event the instant it happens. Zero wasted calls, zero delay. This is the same pattern that powers real-time features in production systems.

Verify real-time streaming across terminals

Now you will prove that streaming actually works. You need three terminal windows: one for the server, one for a watching client, and one for a client that creates tasks.

  • In the terminal where your server is running, press Ctrl+C to stop it.
  • Restart the server by running:
go run server/main.go

You should see gRPC server listening on :50051 confirming the server is ready.

  • Open a second terminal window. Navigate to your task-manager-grpc folder.
  • Start the watching client by running:
go run client/main.go --watch

You should see Watching for new tasks... (press Ctrl+C to stop) and the terminal will hang, waiting for events.

  • Open a third terminal window. Navigate to your task-manager-grpc folder.

Before you run the next command, what do you think will happen in the watching terminal when you create a task from this third terminal?

  • Create tasks by running the client normally:
go run client/main.go

In the third terminal you will see the familiar output: created task, got task, all tasks listed. But switch to your second terminal (the watching one).

You should see two new lines appear in real time:

[NEW TASK] ID: task-1 | Title: Learn gRPC | Description: Build a task manager service with Go and protobuf
[NEW TASK] ID: task-2 | Title: Deploy service | Description: Containerize and deploy the gRPC service

What just happened?

The watching client held an open stream connection to the server. When the other client called CreateTask, the server pushed a TaskEvent to all watcher channels. The watching client received each event instantly through its open Recv() loop.

No HTTP request was made for each update. No polling interval. The data arrived the instant it was created on the server.

Not seeing events in the watching terminal?

  • Make sure you started the watching client BEFORE creating tasks. Events are only pushed to clients that are already connected.
  • Confirm the server was restarted. If the server was still running from a previous step without the streaming code, it would not support WatchTasks.
  • Check that all three terminals are navigated to the same task-manager-grpc directory.

Why aren't my streaming events arriving in the watching client?

  • When you are done testing, press Ctrl+C in the watching terminal to disconnect the stream.

Your gRPC service now supports real-time push notifications. You have experienced the full gRPC development cycle: proto definition, code generation, server implementation, client consumption, and streaming. Next up, you will tackle a bonus challenge that adds production-grade middleware to every RPC call.

Secret mission

Add a Logging Interceptor

In production, every gRPC service needs observability. Your mission: add a unary server interceptor that logs the method name and duration of every RPC call, giving you the foundation for production monitoring.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources for future gRPC experiments, pause the running server, or delete everything entirely.

Resources you used:

  • The task-manager-grpc project directory on your Desktop.
  • The protoc binary and include files installed to $HOME/.local.
  • The protoc-gen-go and protoc-gen-go-grpc plugins installed to $(go env GOPATH)/bin.

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the service with more RPCs, a database, or additional interceptors.

  • Your server may still be running in a terminal. Leave it running if you want to keep experimenting with the client.
  • The protoc toolchain stays installed and ready for future proto file compilations.
  • Your project directory stays on your Desktop for easy access.

Pause - I'll come back to this later

Stop the running server process to free up the terminal and port, but keep all files in place so you can pick up where you left off.

  • Switch to the terminal running your gRPC server and press Ctrl+C to stop it.
  • If you have a client running with --watch, press Ctrl+C in that terminal too.
  • Your project files and installed tools remain untouched. To resume, just run go run server/main.go again from the project directory.

Delete - I don't want to use this again

Remove the project directory and optionally the protobuf tools you installed.

  • Stop any running server or client processes with Ctrl+C.
  • Delete the project directory by running:
cd ~/Desktop
rm -rf task-manager-grpc
  • Remove the protoc binary and include files by running:
rm -f $HOME/.local/bin/protoc
rm -rf $HOME/.local/include/google
  • Remove the Go gRPC plugins by running:
rm -f $(go env GOPATH)/bin/protoc-gen-go
rm -f $(go env GOPATH)/bin/protoc-gen-go-grpc
  • Confirm everything is gone by running:
ls ~/Desktop/task-manager-grpc

You should see a "No such file or directory" message, confirming the project has been removed.

That's a Wrap!

That's a Wrap!

Nice work! You just built a complete gRPC service in Go from scratch, covering the full development cycle from proto definition to streaming client.

You've learned how to:

  • Define a service contract in a .proto file using proto3 syntax and generate type-safe Go code from it with protoc and its Go plugins.
  • Implement a gRPC server with unary RPCs (create, get, list) and a Go client that calls them using generated stubs.
  • Add server-side streaming to push real-time task events to connected clients without polling or WebSockets.
  • Secret Mission: Wire a unary interceptor into the server that logs every RPC call with its method name and duration.

Ready to quiz yourself?