How does gRPC actually work under the hood — Protocol Buffers, HTTP/2 streaming, and why is it faster than REST+JSON?
gRPC's reputation for speed is really three separate mechanisms stacked together — pull them apart and you have a genuinely senior answer instead of a benchmark you memorized.
LEARN BY DOINGPut your intuition to work3 steps
How does gRPC actually work under the hood — Protocol Buffers, HTTP/2 streaming, and why is it faster than REST+JSON?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Three separate mechanisms, stacked
"gRPC is faster than REST+JSON" is true often enough to be the expected interview answer, but a senior answer separates why into three independent layers that happen to be bundled together: Protocol Buffers as the wire format, HTTP/2 as the transport, and a code-generation-first contract instead of a hand-maintained client. Each one buys something on its own, and disaggregating them is what tells an interviewer you understand the mechanism rather than repeating a benchmark you once read.
#Protocol Buffers: a schema decides the bytes, not the field names
A .proto file defines both the message shapes and the RPC contract. protoc (or a language-specific plugin) turns it into typed classes and client/server stubs at build time, so there's no runtime reflection over field names and no hand-written serialization code to get wrong.
syntax = "proto3";package orders.v1;service OrderService {rpc GetOrder (GetOrderRequest) returns (Order);rpc ListOrders (ListOrdersRequest) returns (stream Order);rpc UploadOrders (stream Order) returns (UploadSummary);rpc StreamOrderUpdates (stream OrderUpdate) returns (stream Order);}message GetOrderRequest {string order_id = 1;}message ListOrdersRequest {string customer_id = 1;int32 page_size = 2;}message Order {string id = 1;string customer_id = 2;int64 total_cents = 3;OrderStatus status = 4;repeated LineItem items = 5;}message LineItem {string sku = 1;int32 quantity = 2;int64 unit_price_cents = 3;}message OrderUpdate {string order_id = 1;OrderStatus status = 2;}message UploadSummary {int32 accepted = 1;int32 rejected = 2;}enum OrderStatus {ORDER_STATUS_UNSPECIFIED = 0;ORDER_STATUS_PENDING = 1;ORDER_STATUS_PAID = 2;ORDER_STATUS_SHIPPED = 3;ORDER_STATUS_CANCELLED = 4;}
That single file already shows all four RPC shapes gRPC supports: GetOrder is unary (one request, one response, the REST-like case), ListOrders is server streaming (one request, a stream of responses), UploadOrders is client streaming (a stream of requests, one response), and StreamOrderUpdates is bidirectional streaming (both sides stream independently over the same call).
On the wire, each field is encoded as a tag — (field_number << 3) | wire_type — followed by its value. Wire type 0 is a varint (integers, using as few bytes as the value needs), 2 is length-delimited (strings, bytes, nested messages, repeated fields), and there are dedicated types for fixed 32- and 64-bit values. Compare that to JSON, where {"customer_id": "cus_481"} sends the literal field name as text on every single message; protobuf sends field number 2 as one or two bytes and the value in binary, with no quotes, braces, or whitespace, and no string parsing on the way back out.
That also explains a subtlety interviewers like to probe: field numbers, not names, are the real identity on the wire. Removing field 3 and later reusing 3 for an unrelated type isn't just messy versioning — it's silent data corruption, because an old binary still decodes those bytes under the old field's assumed type.
#HTTP/2 underneath: one connection, many concurrent calls
gRPC maps every RPC call onto one HTTP/2 stream, and HTTP/2 multiplexes many streams over a single TCP connection, each identified by a stream ID. A request becomes a HEADERS frame (:method: POST, :path: /orders.v1.OrderService/GetOrder, content-type: application/grpc) followed by DATA frames carrying length-prefixed protobuf payloads; a streaming RPC just keeps sending more DATA frames on that same stream over time, and a trailing HEADERS frame carries the final grpc-status and grpc-message once the call finishes.
That's why gRPC needs no connection pool the way a REST client juggling many simultaneous HTTP/1.1 calls does — three concurrent RPCs above are three streams on one connection, not three sockets. HPACK header compression means repeated headers (method, content-type, auth metadata) across many calls on that connection cost close to nothing after the first. None of that is gRPC's own invention — it's what HTTP/2 gives anything built on it — but gRPC's actual advantage is baking always-on HTTP/2 and streaming into the API contract itself, so ListOrders returning a stream of Order messages doesn't cost the client N request/response round trips the way REST pagination does.
#Sketching the stub (Go)
// server.gotype orderServer struct {pb.UnimplementedOrderServiceServerstore OrderStore}func (s *orderServer) GetOrder(ctx context.Context, req *pb.GetOrderRequest) (*pb.Order, error) {order, err := s.store.Find(req.GetOrderId())if err != nil {return nil, status.Errorf(codes.NotFound, "order %s not found", req.GetOrderId())}return order.ToProto(), nil}func (s *orderServer) ListOrders(req *pb.ListOrdersRequest, stream pb.OrderService_ListOrdersServer) error {orders, err := s.store.ListByCustomer(req.GetCustomerId(), int(req.GetPageSize()))if err != nil {return status.Errorf(codes.Internal, "list failed: %v", err)}for _, o := range orders {if err := stream.Send(o.ToProto()); err != nil {return err // client disconnected, or backpressure signaled an error}}return nil}func main() {lis, _ := net.Listen("tcp", ":50051")grpcServer := grpc.NewServer()pb.RegisterOrderServiceServer(grpcServer, &orderServer{store: NewOrderStore()})grpcServer.Serve(lis)}
// client.goconn, err := grpc.Dial("orders.internal:50051", grpc.WithTransportCredentials(creds))if err != nil {log.Fatalf("dial failed: %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{OrderId: "ord_123"})if err != nil {st, _ := status.FromError(err)log.Fatalf("GetOrder failed: %s: %s", st.Code(), st.Message())}stream, err := client.ListOrders(ctx, &pb.ListOrdersRequest{CustomerId: "cus_1", PageSize: 50})for {o, err := stream.Recv()if err == io.EOF {break}if err != nil {log.Fatalf("stream error: %v", err)}fmt.Println(o.GetId(), o.GetTotalCents())}
The context.WithTimeout on the client isn't just a local guard — gRPC serializes it into a grpc-timeout header, the server enforces it, and if the server forwards the same context to a downstream call, the deadline propagates automatically. That's a real operational advantage over REST, where timeout propagation across a call chain is a convention every team has to invent for itself.
#Where it wins, and where it doesn't
The speed gRPC is known for is genuinely three separable wins stacked on top of each other: binary encoding shrinks payloads and skips text parsing, HTTP/2 multiplexing avoids opening a connection per concurrent call and makes real streaming possible, and generated stubs remove a whole class of hand-written client and serialization bugs while giving compile-time contract checking. None of that makes it a universal replacement for REST: browsers can't speak raw gRPC (they can't read trailers or control HTTP/2 frames directly), so browser clients need grpc-web plus a proxy translating between the two; payloads aren't human-readable, so curl-and-eyeball debugging is gone; and the .proto contract demands discipline — never repurpose a field number, only add new optional fields, and be careful with enums, since proto3 silently defaults any unset numeric field to 0, which is exactly why 0 should always be a deliberate "unspecified" value rather than a meaningful one.