RPC and gRPC for APIs
So far, almost every API in this part of the book has spoken the same dialect: resources, HTTP verbs, and JSON. That dialect is REST, and it earns its popularity because a browser, a curl command, and a stranger's Postman collection can all speak it without prior coordination. But REST is not the only way to build an API, and it is not always the best one. This chapter looks at RPC (Remote Procedure Call) as a different API style, and at gRPC, its most successful modern implementation, from the angle of building APIs rather than the general networking angle already covered in Chapter 4.1, gRPC and Protocol Buffers in Go.
RPC as an API Style
REST models an API as a set of resources you manipulate with a small,
fixed vocabulary of verbs: GET /orders/42, POST /orders, DELETE /orders/42. RPC models an API the opposite way: as a set of functions
you call remotely, as if they were local. Instead of "fetch the resource at
this URL," an RPC call reads as "run this named operation, with these
arguments, and give me back a result." A REST client thinks in nouns and
HTTP methods; an RPC client thinks in verbs and function signatures.
Concretely, where a REST API exposes POST /orders/42/cancel, an RPC API
exposes a callable method named CancelOrder(orderID). Neither is more
"correct" -- they are different mental models for the same job, and the gap
between them matters most when you generate client code. A REST client
still has to know, out of band, that cancelling means POST to a specific
path with a specific body shape. An RPC client calls a generated function
whose name, arguments, and return type are all part of a schema the compiler
checks. That difference is the entire reason teams choose one style over
the other.
When Each Style Is the Better Fit
Neither REST nor RPC-style APIs are strictly better; they optimize for different constraints.
- Reach for REST when the API is public, or when you don't control the
client. Every language has an HTTP client, every browser speaks HTTP
natively, JSON payloads are readable in a debugger without extra tooling,
and caching proxies understand
GETsemantics for free. Public APIs, third-party integrations, and anything a human might inspect in DevTools all favor REST. - Reach for gRPC when you control both ends of the call, typically internal service-to-service traffic. You get a compiler-checked contract instead of a hand-maintained convention, binary payloads that are smaller and faster to parse than JSON, native streaming instead of a bolted-on workaround, and connection multiplexing over HTTP/2 so many concurrent calls share one TCP connection.
A useful rule of thumb: if the client is a browser or an unknown third party, write REST. If the client is another service you also maintain, and performance or streaming matters, gRPC usually wins.
REST optimizes for who can call you; RPC optimizes for how fast and how strictly.
A Minimal Service Definition
gRPC uses Protocol Buffers (protobuf) as its schema language -- the mechanics of protobuf encoding, field numbers, and the four RPC shapes (unary, server streaming, client streaming, bidirectional streaming) are covered in depth in Chapter 4.1. Here we focus on using that same machinery to build an API, with a small inventory service as the running example:
syntax = "proto3";
package inventory;
option go_package = "example.com/inventory/pb";
service Inventory {
rpc GetItem (ItemRequest) returns (ItemReply);
rpc WatchStock (ItemRequest) returns (stream StockUpdate);
}
message ItemRequest {
string sku = 1;
}
message ItemReply {
string sku = 1;
int32 quantity = 2;
}
message StockUpdate {
string sku = 1;
int32 quantity = 2;
string source = 3;
}
GetItem is a plain unary call: one request, one response, functionally
equivalent to GET /items/{sku} in REST terms. WatchStock is server
streaming: one request opens a channel over which the server can push
multiple StockUpdate messages as inventory changes, without the client
polling or opening a WebSocket by hand.
Generating the Go bindings uses the same two protoc plugins as before:
protoc --go_out=. --go-grpc_out=. inventory.proto
Implementing the Unary RPC
package main
import (
"context"
"log"
"net"
"google.golang.org/grpc"
pb "example.com/inventory/pb"
)
type inventoryServer struct {
pb.UnimplementedInventoryServer
stock map[string]int32
}
func (s *inventoryServer) GetItem(
ctx context.Context, req *pb.ItemRequest,
) (*pb.ItemReply, error) {
qty, ok := s.stock[req.GetSku()]
if !ok {
qty = 0
}
return &pb.ItemReply{Sku: req.GetSku(), Quantity: qty}, nil
}
func main() {
lis, err := net.Listen("tcp", ":50052")
if err != nil {
log.Fatalf("listen: %v", err)
}
srv := grpc.NewServer()
pb.RegisterInventoryServer(srv, &inventoryServer{
stock: map[string]int32{"sku-1": 42, "sku-2": 7},
})
log.Println("inventory service listening on :50052")
if err := srv.Serve(lis); err != nil {
log.Fatalf("serve: %v", err)
}
}
This should feel familiar: it is a normal Go function, taking a context and a request struct, returning a response struct and an error. The generated code hides the wire format entirely -- you never touch a byte buffer.
Errors and Status Codes
REST leans on HTTP status codes to describe what went wrong: 404 for a
missing resource, 409 for a conflict, 500 for a server fault. gRPC has
its own, smaller vocabulary of status codes, defined in the
google.golang.org/grpc/codes package, and every RPC returns one of them
alongside an optional error message via google.golang.org/grpc/status.
Returning a plain Go error from a handler is enough to make the call fail,
but it maps to the generic codes.Unknown on the wire -- for anything an
API client should branch on, attach a real status:
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (s *inventoryServer) GetItem(
ctx context.Context, req *pb.ItemRequest,
) (*pb.ItemReply, error) {
qty, ok := s.stock[req.GetSku()]
if !ok {
return nil, status.Errorf(codes.NotFound,
"sku %q not found", req.GetSku())
}
return &pb.ItemReply{Sku: req.GetSku(), Quantity: qty}, nil
}
On the client, status.FromError(err) recovers the code and message, so
callers can branch on codes.NotFound the same way a REST client would
branch on a 404. The two systems solve the same problem with a smaller,
strongly typed enum instead of an open-ended set of numbers borrowed from
HTTP.
Implementing the Streaming RPC
Server streaming trades a single return value for a stream object with a
Send method. Add this method to the same inventoryServer:
func (s *inventoryServer) WatchStock(
req *pb.ItemRequest, stream pb.Inventory_WatchStockServer,
) error {
sku := req.GetSku()
updates := []int32{s.stock[sku], s.stock[sku] - 3, s.stock[sku] - 5}
for _, qty := range updates {
update := &pb.StockUpdate{
Sku: sku, Quantity: qty, Source: "warehouse",
}
if err := stream.Send(update); err != nil {
return err
}
}
return nil
}
Each stream.Send writes one message onto the same HTTP/2 stream; the RPC
ends when the method returns. The client side reads the stream with Recv
in a loop until it sees io.EOF:
package main
import (
"context"
"io"
"log"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pb "example.com/inventory/pb"
)
func main() {
conn, err := grpc.NewClient("localhost:50052",
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatalf("connect: %v", err)
}
defer conn.Close()
client := pb.NewInventoryClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
stream, err := client.WatchStock(ctx, &pb.ItemRequest{Sku: "sku-1"})
if err != nil {
log.Fatalf("open stream: %v", err)
}
for {
update, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
log.Fatalf("recv: %v", err)
}
log.Printf("sku=%s qty=%d source=%s",
update.GetSku(), update.GetQuantity(), update.GetSource())
}
}
Notice how little of this is gRPC-specific ceremony: the request/response
shapes are generated types, and the streaming loop is just a for loop
around Recv. This is the appeal of RPC-style APIs -- the network
disappears behind function calls your editor can autocomplete.
Bridging REST and gRPC
Some teams want gRPC's strict contracts internally, but still need to expose
a plain JSON/REST surface to browsers or external partners. The common
solution is grpc-gateway (github.com/grpc-ecosystem/grpc-gateway), a
protoc plugin that reads extra annotations in your .proto file and
generates a reverse-proxy HTTP server that translates incoming REST/JSON
requests into gRPC calls against your existing service:
import "google/api/annotations.proto";
service Inventory {
rpc GetItem (ItemRequest) returns (ItemReply) {
option (google.api.http) = {
get: "/v1/items/{sku}"
};
}
}
The generated gateway runs as a normal net/http server, translates
GET /v1/items/sku-1 into a GetItem gRPC call, and marshals the
ItemReply back to JSON -- your handler code never changes. This lets a
service keep one schema and one implementation while serving two audiences:
internal gRPC clients that want speed and type safety, and external HTTP
clients that just want JSON.
Comparing the Three Styles
| Dimension | REST (JSON/HTTP) | gRPC (protobuf/HTTP2) | JSON-RPC |
|---|---|---|---|
| Payload format | Human-readable JSON text | Compact binary protobuf | JSON text |
| Browser support | Native (fetch, XHR) |
Needs grpc-web + a proxy | Native (fetch, XHR) |
| Streaming | Bolted on (SSE, WebSockets) | Native, all four RPC shapes | Not part of the spec |
| Tooling | OpenAPI/Swagger, curl, Postman | protoc, generated stubs |
Minimal, mostly hand-rolled |
| Typical use case | Public APIs, browser clients | Internal microservices | Simple internal RPC, some blockchain nodes |
JSON-RPC is worth knowing by name even though it rarely appears in this
book: it is a thin convention (a JSON envelope with method, params, and
id fields) for RPC-style calls over any transport, with none of gRPC's
code generation, streaming, or binary efficiency. It shows up in places
that want RPC semantics without adopting a full toolchain -- some
blockchain node APIs and simple internal tools still use it -- but for new
Go services, the real choice in practice is between REST and gRPC.
Frequently Asked Questions
If gRPC is faster and stricter, why doesn't every internal service just switch to it?
Speed and strictness aren't free -- a gRPC contract has to be written in protobuf, compiled with protoc, and regenerated whenever a field changes, which is real friction compared to a REST handler you can edit and redeploy in one step. Teams that adopt gRPC internally are trading that upfront ceremony for a compiler-checked contract and native streaming, which is a good trade for service-to-service traffic but overkill for, say, a small internal admin tool with one caller.
My gRPC handler returns a plain Go error instead of a status.Errorf -- is that actually broken?
It won't crash anything, but it quietly throws away information the client needs. A bare error maps to the generic codes.Unknown on the wire, so a caller trying to branch on codes.NotFound the way it would branch on an HTTP 404 has nothing to check. Whenever a failure is something an API client is expected to react to differently, wrap it with status.Errorf and a real code, as the GetItem example does for a missing SKU.
Do I have to give up REST entirely if I build my internal services with gRPC?
No -- that's exactly the gap grpc-gateway is built to close. It reads extra annotations in the same .proto file and generates a reverse-proxy HTTP server that translates incoming REST/JSON requests into gRPC calls against your existing service, so one schema and one implementation can serve both an internal gRPC client and an external browser client without maintaining two codepaths.
What actually happens to a context deadline when service A calls B which calls C?
Because every generated gRPC method takes a context.Context as its first argument, a deadline set once at the edge -- say, a 2-second timeout at A -- travels automatically through B's call into C, shrinking as time passes rather than resetting at each hop. This is the behavior the DeepDive on deadline propagation calls out, and it's something you'd have to wire up by hand with headers and timers in a plain REST client chain.
Is JSON-RPC worth learning if this chapter mostly talks about REST and gRPC?
Mostly by name rather than by practice for new Go services -- it's a thin JSON envelope (method, params, id) for RPC-style calls without any of gRPC's code generation, streaming, or binary efficiency. It's worth recognizing because it still shows up in specific niches, like some blockchain node APIs and simple internal tools, but for a new backend the real decision in this chapter remains REST versus gRPC.
Key Takeaways
- RPC-style APIs model a remote call as invoking a named function with typed arguments; REST models it as manipulating a resource with a fixed HTTP verb vocabulary. Both are legitimate API styles, not a right-versus-wrong choice.
- gRPC is the practical default for internal, service-to-service RPC: it gives you a compiler-checked schema, binary encoding, and native streaming over HTTP/2.
- REST remains the practical default for anything a browser or an unfamiliar third party needs to call, because tooling and client support are universal.
- grpc-gateway lets a single gRPC service also expose a REST/JSON facade, so you don't have to choose only one audience.
- JSON-RPC is a lightweight alternative worth recognizing by name, but it lacks the code generation and streaming that make gRPC compelling for modern Go services.