Okay so, gRPC. I want to walk you through it because it keeps coming up and I think a lot of the confusion comes from people treating it like "REST but faster." It's not. REST is built around resources and HTTP verbs. gRPC is built around calling functions. Those are genuinely different mental models, and picking the wrong one for your situation creates friction that compounds over time.

Also, and I say this upfront: if REST is working fine for you, you probably don't need this. REST is simple, it's everywhere, it's easy to debug. I've used gRPC on a few projects and it's genuinely useful in the right situation, but it's not something you should reach for just because it sounds cool. I've seen teams do that and regret it.


What gRPC Is

gRPC is a way to call a function on another server like it's a local function. You write a .proto file describing what the service does, run a code generator, and you get typed clients and server stubs in whatever language you're working in: Go, Java, Python, Swift, Kotlin, C++, Rust.

Two pieces make this work: Protocol Buffers and HTTP/2.

Protocol Buffers are the serialization format. You describe your data structures and service methods in a .proto file, and the protoc compiler generates the code. The generated stuff handles all the serialization and deserialization. You just call a method.

syntax = "proto3";

package order;

service OrderService {
  rpc CreateOrder (CreateOrderRequest) returns (CreateOrderResponse);
  rpc GetOrder (GetOrderRequest) returns (Order);
  rpc ListOrders (ListOrdersRequest) returns (stream Order);
}

message CreateOrderRequest {
  string user_id = 1;
  repeated OrderItem items = 2;
}

message Order {
  string id = 1;
  string user_id = 2;
  repeated OrderItem items = 3;
  OrderStatus status = 4;
  int64 created_at = 5;
}

enum OrderStatus {
  PENDING = 0;
  CONFIRMED = 1;
  SHIPPED = 2;
  DELIVERED = 3;
}

This .proto file becomes the contract. Both sides generate from it, so if the shape of your data changes in a way that breaks something, the build fails before it reaches production. I like that.

HTTP/2 handles the transport. Unlike HTTP/1.1, HTTP/2 can multiplex multiple requests over a single connection without them blocking each other. Headers get compressed, connections get reused. On high-frequency internal calls, it makes a real difference.


From .proto to Running Code

Once you run protoc on that file, you get generated types and a server interface to implement. In Go, a minimal server looks like this:

type orderServer struct {
    pb.UnimplementedOrderServiceServer
    db *sql.DB
}

func (s *orderServer) GetOrder(ctx context.Context, req *pb.GetOrderRequest) (*pb.Order, error) {
    row := s.db.QueryRowContext(ctx, "SELECT id, user_id, status FROM orders WHERE id = $1", req.Id)

    var o pb.Order
    if err := row.Scan(&o.Id, &o.UserId, &o.Status); err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, status.Errorf(codes.NotFound, "order %s not found", req.Id)
        }
        return nil, status.Error(codes.Internal, err.Error())
    }
    return &o, nil
}

func main() {
    lis, err := net.Listen("tcp", ":50051")
    if err != nil {
        log.Fatalf("listen: %v", err)
    }

    grpcServer := grpc.NewServer()
    pb.RegisterOrderServiceServer(grpcServer, &orderServer{db: connectDB()})
    grpcServer.Serve(lis)
}

The client side is pretty similar. Open one connection, create a typed client from it, call methods:

conn, err := grpc.NewClient(
    "orders-service:50051",
    grpc.WithTransportCredentials(insecure.NewCredentials()), // TLS in production
)
if err != nil {
    log.Fatalf("dial: %v", err)
}
defer conn.Close()

client := pb.NewOrderServiceClient(conn)

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

order, err := client.GetOrder(ctx, &pb.GetOrderRequest{Id: "ord_123abc"})
if err != nil {
    // err is a gRPC status error, not a generic error
    st := status.Convert(err)
    log.Printf("code=%s message=%s", st.Code(), st.Message())
}

The method call goes over the wire. The return type is exactly what the proto says it will be.


The Four Call Types

gRPC defines four patterns. Three of them are things REST can't really do cleanly without you assembling something custom.

Unary is the familiar one: one request, one response, same as REST:

rpc GetUser (GetUserRequest) returns (User);

Server streaming is where the client sends one request, and the server sends back a stream. Good for feeds, log tailing, anything where there's more data than fits in one response:

rpc WatchOrders (WatchOrdersRequest) returns (stream Order);

On the server side you call stream.Send() in a loop:

func (s *orderServer) WatchOrders(req *pb.WatchOrdersRequest, stream pb.OrderService_WatchOrdersServer) error {
    for {
        select {
        case <-stream.Context().Done():
            return stream.Context().Err() // client disconnected or deadline exceeded
        case order := <-s.orderEvents(req.UserId):
            if err := stream.Send(order); err != nil {
                return err
            }
        }
    }
}

The client just iterates until it closes:

stream, err := client.WatchOrders(ctx, &pb.WatchOrdersRequest{UserId: "user_42"})
for {
    order, err := stream.Recv()
    if err == io.EOF {
        break
    }
    if err != nil {
        log.Printf("stream error: %v", err)
        break
    }
    fmt.Printf("order %s changed to %s\n", order.Id, order.Status)
}

Client streaming flips it: the client streams messages and the server responds once at the end. Upload flows, batching sensor data, that kind of thing:

rpc UploadReadings (stream SensorReading) returns (UploadResult);

Bidirectional streaming is both sides streaming at the same time. Real-time chat, live collaboration, anything where messages flow both ways without a request-response structure:

rpc Chat (stream ChatMessage) returns (stream ChatMessage);

You can kind of do some of this in REST with WebSockets or Server-Sent Events, but you're piecing it together yourself. With gRPC you get the whole model with generated code and a contract on both ends.


What You Get

Smaller payloads. Protobuf is binary. A JSON object like {"status": "confirmed", "id": "ord_123abc"} repeats the field names on every single message. Protobuf uses field numbers instead, so the encoded output is typically 30–60% lighter than equivalent JSON for a message with a handful of fields. The gap grows with repeated fields and nested messages. It narrows when your JSON is mostly numbers.

Generated clients. Write the .proto file, run protoc, both sides have type-safe code. You don't maintain a separate OpenAPI spec or hand-write API clients and hope they stay in sync. The contract is the code.

Backward compatibility that's a bit more structured. Field numbering is how protobuf handles adding and removing fields. When you add something, give it a new number. Old clients safely ignore fields they don't recognize. Old servers return zero values for fields they don't know about. To remove a field, you mark it reserved so nobody accidentally reuses the number:

message Order {
  string id = 1;
  string user_id = 2;
  reserved 3;           // was: string legacy_field. never reuse this number
  OrderStatus status = 4;
  int64 created_at = 5;
  string tracking_id = 6; // added later; old clients safely ignore this
}

It's a bit more ceremony than a REST API where field changes are mostly convention, but I've found it saves the "wait, when did that field get renamed?" conversation.

Deadlines baked in. Every gRPC call can carry a deadline: the client sets a timeout, the server can check whether that deadline has already passed before doing expensive work. If a client cancels, cancellation can propagate down the call chain. REST doesn't have a standard way to do this that works across different languages without custom headers and middleware.


Error Codes

gRPC has its own status codes, separate from HTTP. There are around 16, but nine come up all the time.

NOT_FOUND is for missing resources: an order that doesn't exist, a user that was never created. INVALID_ARGUMENT is bad input from the caller: a malformed ID, a missing required field, a value out of range. ALREADY_EXISTS is for create conflicts. PERMISSION_DENIED means the caller is authenticated but not allowed (wrong role, wrong account). UNAUTHENTICATED means no valid credentials.

DEADLINE_EXCEEDED fires when the client's deadline runs out mid-call. UNAVAILABLE means the server is temporarily down and the request can be retried. INTERNAL is the catch-all for unhandled server errors. OK is success.

You return them with status.Errorf:

return nil, status.Errorf(codes.NotFound, "order %s not found", req.Id)
return nil, status.Errorf(codes.InvalidArgument, "user_id is required")
return nil, status.Errorf(codes.Internal, "database error: %v", err)

On the client, you unwrap with status.Convert:

order, err := client.GetOrder(ctx, req)
if err != nil {
    st := status.Convert(err)
    switch st.Code() {
    case codes.NotFound:
        // show 404 page
    case codes.DeadlineExceeded:
        // retry with backoff
    default:
        // log and surface as 500
    }
}

Nicer than parsing HTTP status codes out of an error string, and it works the same way regardless of which language the server is in.


Interceptors

Think of interceptors as middleware. They wrap every call on the server or client side: logging, auth, metrics, retry logic all go here.

Here's a simple server-side logging one:

func loggingInterceptor(
    ctx context.Context,
    req interface{},
    info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler,
) (interface{}, error) {
    start := time.Now()
    resp, err := handler(ctx, req)
    log.Printf(
        "method=%s duration=%v err=%v",
        info.FullMethod,
        time.Since(start),
        err,
    )
    return resp, err
}

// Register it when creating the server:
grpc.NewServer(grpc.UnaryInterceptor(loggingInterceptor))

If you need to chain more than one, the standard library only accepts a single interceptor, so you'd reach for go-grpc-middleware. Most setups chain at least three: auth, logging, and panic recovery. The same pattern applies to streaming calls via StreamInterceptor.


The Rough Parts

I want to be honest about the parts that are annoying, because they are real.

Browser support is awkward. Browsers can't make raw HTTP/2 requests with the gRPC framing. There's a spec called gRPC-Web that works around this with a proxy layer, but that's another thing to run. If your API is mainly browser-facing, REST or GraphQL is genuinely less hassle.

You can't just curl it. This is the one that gets people. A binary protobuf payload is not readable. The standard tool is grpcurl. With server reflection turned on in development, it's actually pretty comfortable:

# List available services
grpcurl -plaintext localhost:50051 list

# Describe a service
grpcurl -plaintext localhost:50051 describe order.OrderService

# Call a method
grpcurl -plaintext \
  -d '{"id": "ord_123abc"}' \
  localhost:50051 \
  order.OrderService/GetOrder

Server reflection exposes your full API schema to anyone who can reach the port, though, so disable it in production. For production debugging, pass the proto files directly with -proto order.proto or use Buf Studio.

Load balancers can be tricky. gRPC uses long-lived HTTP/2 connections. A lot of layer-4 load balancers distribute connections, not individual requests, so all traffic from one client can land on the same backend. Layer-7 load balancers like Nginx (grpc_pass), Envoy, and AWS ALB handle this correctly. A basic TCP load balancer won't.

Schema changes take a bit of coordination. The .proto file is shared. Changing a field type or removing something means updating both sides in order. The protobuf numbering rules protect you from the worst of it, but there's more overhead than just editing a JSON field in a REST endpoint.


Should You Use It?

Honestly, if most of these are true for your situation, it's probably a good fit:

  • Service-to-service calls between servers you control: not a public API, not a browser client.
  • Multiple languages: a Go service calling a Java service. Generated clients from one proto file, no manual syncing.
  • High call volume: when the JSON overhead and connection overhead actually show up in profiling.
  • Streaming: server push, bidirectional streams, or client upload flows.
  • Teams that keep accidentally breaking each other's APIs: the build will catch the mismatch.

And it's probably not worth it if:

  • Your API is public-facing or browser-consumed.
  • Your team is small and you don't want the operational overhead.
  • Your services call each other rarely enough that the wire format doesn't matter.
  • You want to curl your API and read the output without extra tooling.

REST is a fine choice for most things. I'm not trying to talk you out of it.


One More Thing: Connect

If you like the idea of protobuf contracts but find pure gRPC a bit heavy, take a look at Connect. It's from Buf, it's compatible with gRPC and gRPC-Web, and it also speaks JSON over HTTP/1.1 out of the box. The same server handles a plain curl request and a native gRPC client without a proxy in between. If the debugging story is what's putting you off, Connect makes that part much easier.


Anyway. gRPC is a real tool with real tradeoffs. It shines when you've got high-frequency internal service calls, multiple languages, or streaming requirements that REST doesn't model well. Outside of that, the extra setup probably isn't paying for itself. Use what fits the problem.