background-shape
Communicating Between Go Microservices, REST vs gRPC in 2022
January 14, 2022 · 6 min read · by Muhammad Amal programming

TL;DR — gRPC wins for service-to-service Go calls: ~3× throughput, generated clients, deadlines built in. REST wins for browser-facing APIs and public surfaces. We use both: gRPC inside the cluster, REST at the edge.

After drawing the billing service boundary the next question we faced was wire format. Go microservices in 2022 have two serious choices: HTTP+JSON (what most people call “REST”) or gRPC over HTTP/2 with protobuf. There’s a long tail of others — GraphQL, Thrift, Cap’n Proto — but for the boring middle of a backend, the conversation is REST or gRPC.

Both work. Both are well-supported in Go. The choice has real consequences for ergonomics, performance, and how easy it is to evolve your APIs over time. Here’s how the comparison shook out for us, with numbers from our actual workload.

Where each format wins

REST (HTTP+JSON) wins when:

  • The API is consumed by a browser. JavaScript can fetch() JSON natively; gRPC-Web needs a proxy.
  • The API is public-facing. Third parties expect JSON; protobuf is a foreign concept to most external integrators.
  • The endpoints are CRUD-shaped and map cleanly to URLs and HTTP verbs.
  • You want every browser, curl, Postman, and Insomnia user to hit the API directly.

gRPC wins when:

  • The traffic is service-to-service inside your own infrastructure.
  • You want generated clients in multiple languages (Go, Python, Node, Java) from a single source of truth.
  • Per-call latency matters and you’re CPU-bound on JSON parsing.
  • You want streaming, deadlines, and structured error metadata as first-class concerns.

Almost every backend system ends up needing both. The split that scales: REST at the edge (the browser, the public API gateway), gRPC for everything inside.

A real benchmark

This is the part where most “REST vs gRPC” posts hand-wave. Here are numbers from a tiny benchmark I ran against an idle laptop. Same handler logic — return a 1.2 KB user record from in-memory cache. Hit it from a Go client over loopback.

REST (chi router, encoding/json)
  Throughput:  18,400 req/s
  P50 latency: 2.1 ms
  P99 latency: 7.9 ms
  CPU:         180% (server + client)

gRPC (grpc-go, protobuf)
  Throughput:  54,700 req/s
  P50 latency: 0.7 ms
  P99 latency: 3.2 ms
  CPU:         110% (server + client)

About 3× throughput, lower latency, lower CPU. The exact ratio depends on payload size — gRPC’s win shrinks for very large payloads (network, not CPU, becomes the bottleneck) and grows for tiny payloads.

Two caveats. First: most production services are not bottlenecked by inter-service serialization, they’re bottlenecked by databases and external APIs. gRPC’s win is real but rarely the dominant cost. Second: this is loopback. Add real network latency and the relative speedup shrinks because the network round-trip dominates.

Why we picked gRPC for billing specifically

Three reasons that mattered more than throughput.

Generated clients. Our billing service is consumed by the monolith (PHP), the notifications service (Go), an analytics worker (Python), and a CRM bridge (Node). Writing four hand-rolled HTTP clients is a recipe for drift. Generating four clients from a single .proto file means everyone speaks the same dialect of billing, version-locked to a known schema.

syntax = "proto3";

package billing.v1;

option go_package = "github.com/yourorg/billing-proto/v1;billingv1";

import "google/protobuf/timestamp.proto";

service Billing {
  rpc GetActiveSubscription(GetActiveSubscriptionRequest) returns (Subscription);
  rpc IssueRefund(IssueRefundRequest) returns (CreditNote);
}

message GetActiveSubscriptionRequest {
  string customer_id = 1;
}

message Subscription {
  string id = 1;
  string customer_id = 2;
  string plan_id = 3;
  google.protobuf.Timestamp current_period_start = 4;
  google.protobuf.Timestamp current_period_end = 5;
  Status status = 6;

  enum Status {
    STATUS_UNSPECIFIED = 0;
    STATUS_ACTIVE = 1;
    STATUS_PAST_DUE = 2;
    STATUS_CANCELED = 3;
  }
}

buf generate produces Go, Python, Java, and TypeScript clients in CI. Every consumer pins to a tagged version of the proto repo.

Deadlines as a first-class concept. Every gRPC call propagates a deadline. If the billing API is slow, every upstream caller sees the timeout before their own deadline expires. With JSON over HTTP, you implement deadline propagation yourself in every client and forget half the time.

ctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
defer cancel()

sub, err := billing.GetActiveSubscription(ctx, &billingv1.GetActiveSubscriptionRequest{
    CustomerId: customerID,
})

The 200 ms deadline travels with the request all the way into the billing service’s downstream DB and payment-gateway calls. If any of them blow past it, every layer aborts cleanly.

Structured errors. gRPC’s google.rpc.Status lets you attach typed details to errors:

import (
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
    epb "google.golang.org/genproto/googleapis/rpc/errdetails"
)

st := status.New(codes.FailedPrecondition, "card was declined")
detail := &epb.ErrorInfo{
    Reason: "CARD_DECLINED",
    Domain: "billing.example.com",
    Metadata: map[string]string{
        "decline_code": "insufficient_funds",
    },
}
st, _ = st.WithDetails(detail)
return nil, st.Err()

Callers can pattern-match on Reason rather than parsing free-text error messages. Three months in, this turns out to be one of the most valuable parts.

A small Go gRPC server, end to end

Here’s the minimum viable billing server, sketched:

package main

import (
    "context"
    "log"
    "net"

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

    billingv1 "github.com/yourorg/billing-proto/v1"
)

type server struct {
    billingv1.UnimplementedBillingServer
    store *Store
}

func (s *server) GetActiveSubscription(ctx context.Context, req *billingv1.GetActiveSubscriptionRequest) (*billingv1.Subscription, error) {
    if req.CustomerId == "" {
        return nil, status.Error(codes.InvalidArgument, "customer_id is required")
    }
    sub, err := s.store.ActiveSubscription(ctx, req.CustomerId)
    if err == ErrNotFound {
        return nil, status.Error(codes.NotFound, "no active subscription")
    }
    if err != nil {
        return nil, status.Errorf(codes.Internal, "load subscription: %v", err)
    }
    return sub.ToProto(), nil
}

func main() {
    lis, err := net.Listen("tcp", ":9090")
    if err != nil {
        log.Fatalf("listen: %v", err)
    }
    s := grpc.NewServer()
    billingv1.RegisterBillingServer(s, &server{store: NewStore()})
    log.Println("billing gRPC on :9090")
    if err := s.Serve(lis); err != nil {
        log.Fatalf("serve: %v", err)
    }
}

The UnimplementedBillingServer embed is what protects you when you add a method to the proto and forget to implement it on the server — the compile fails instead of the runtime crashing.

Common Pitfalls

Skipping the proto schema repo. Inlining .proto files in the service that owns them seems fine until you have four consumers. Make the protos a separate repo from day one. Tag versions. Pin consumers.

Forgetting client-side keepalives. Long-lived gRPC connections die behind load balancers without keepalives. Set grpc.WithKeepaliveParams(keepalive.ClientParameters{Time: 30*time.Second}) or you’ll see mysterious “connection closed” errors after periods of idle.

Using interface{} in proto via google.protobuf.Any. Tempting for “extensible” payloads. Almost always a mistake — you’ve recreated JSON’s type ambiguity inside a typed system. If you need polymorphism, use oneof.

Confusing gRPC errors with HTTP errors. A codes.NotFound is not a 404. It’s a 404 for gRPC. The two systems have different status code semantics; don’t try to map them 1:1.

Exposing gRPC directly to browsers. Browsers can’t speak gRPC over HTTP/2 the way clients can. You either need gRPC-Web with a proxy (Envoy), or a thin REST/JSON edge that translates to gRPC inside.

Wrapping Up

For service-to-service traffic in Go, gRPC is the default in 2022. The performance is nice, the generated clients are nicer, and deadlines + structured errors save you from a lot of homemade infrastructure. Keep REST at the edge for browsers and public APIs. Next post in this series: Docker Compose for the polyglot local-dev stack that ties all these services together on a laptop.