gRPC vs REST Tradeoff Quiz
A 4-question reference set comparing gRPC and REST on the dimensions that matter at interview time: latency overhead, call types, schema evolution, and observability. Pick the right tool for the workload.
By CodeSnatch
April 8, 2026
·
Updated May 18, 2026
473 views
12
4.3 (12)
gRPC uses HTTP/2 with binary protobuf framing; REST commonly uses HTTP/1.1 with JSON. List the latency-relevant differences and pick the right pick for a request/reply API with strict p99 budgets.
Examples
Example 1:
Input: Service-to-service call with 5KB request, 5KB response, 200 RPS, p99 budget 30ms
Output: gRPC fits well; protobuf encode+decode + HPACK header compression saves 5-15ms vs JSON over HTTP/1.1
Explanation: Binary framing, header compression, and persistent multiplexed streams reduce overhead per call.Example 2:
Input: Public web API consumed by browsers and curl scripts
Output: REST + JSON wins on debuggability and zero-tooling clients, even at slightly higher latency
Explanation: gRPC-Web exists but adds proxy complexity; REST has near-universal client support out of the box.package main
import (
"context"
"fmt"
"time"
)
// ClientParameters mirrors google.golang.org/grpc/keepalive.ClientParameters.
// We re-declare it here so the snippet is self-contained.
type ClientParameters struct {
Time time.Duration
Timeout time.Duration
PermitWithoutStream bool
}
type ClientConn struct {
Target string
Keepalive ClientParameters
}
func newClient(target string) (*ClientConn, error) {
return &ClientConn{
Target: target,
Keepalive: ClientParameters{
Time: 30 * time.Second,
Timeout: 5 * time.Second,
PermitWithoutStream: true,
},
}, nil
}
func call(ctx context.Context, conn *ClientConn) error {
ctx, cancel := context.WithTimeout(ctx, 30*time.Millisecond)
defer cancel()
_ = ctx
_ = conn
return nil
}
func main() {
conn, err := newClient("svc.example.com:443")
if err != nil {
fmt.Println("dial error:", err)
return
}
fmt.Printf("client ready: target=%s keepalive=%v\n", conn.Target, conn.Keepalive.Time)
if err := call(context.Background(), conn); err != nil {
fmt.Println("call error:", err)
return
}
fmt.Println("call ok")
}gRPC has four call types: unary, server streaming, client streaming, and bidirectional streaming. Match each to a real workload and explain which one REST cannot replicate cleanly.
Examples
Example 1:
Input: Server pushes 100MB of analytics events to client as they happen
Output: Server streaming. REST equivalent is Server-Sent Events; works but lacks gRPC's flow control.
Explanation: gRPC reuses HTTP/2 flow control so a slow consumer naturally backpressures the producer.Example 2:
Input: Two services chat back and forth (e.g. chat or game state sync) over a long-lived connection
Output: Bidirectional streaming. REST has no clean equivalent without WebSockets or polling.
Explanation: Bidi streaming is the gRPC pattern that has no direct REST analog; it is the strongest argument for gRPC.package main
import (
"context"
"fmt"
"io"
)
// Self-contained stand-in for a generated gRPC bidi stream client.
type Chat_BidiClient interface {
Send(*ChatMessage) error
Recv() (*ChatMessage, error)
CloseSend() error
}
type ChatMessage struct {
Body string
}
// fakeStream echoes one message and then EOFs.
type fakeStream struct {
incoming chan *ChatMessage
closed bool
}
func newFakeStream() *fakeStream {
return &fakeStream{incoming: make(chan *ChatMessage, 4)}
}
func (f *fakeStream) Send(m *ChatMessage) error {
f.incoming <- &ChatMessage{Body: "echo:" + m.Body}
close(f.incoming)
return nil
}
func (f *fakeStream) Recv() (*ChatMessage, error) {
m, ok := <-f.incoming
if !ok {
return nil, io.EOF
}
return m, nil
}
func (f *fakeStream) CloseSend() error { return nil }
func chat(ctx context.Context, stream Chat_BidiClient) error {
errCh := make(chan error, 2)
go func() {
for {
msg, err := stream.Recv()
if err == io.EOF {
errCh <- nil
return
}
if err != nil {
errCh <- err
return
}
fmt.Println("recv:", msg.Body)
}
}()
if err := stream.Send(&ChatMessage{Body: "hello"}); err != nil {
return err
}
return <-errCh
}
func main() {
if err := chat(context.Background(), newFakeStream()); err != nil {
fmt.Println("chat error:", err)
return
}
fmt.Println("chat completed cleanly")
}Protobuf is schema-first; JSON is schema-by-convention. Walk through what happens when a server adds a new optional field and a client built against the old schema receives the new payload.
Examples
Example 1:
Input: Server adds field 7 'created_by' (optional string) to User message
Old client parses response with field 7 present
Output: Old client silently ignores field 7; existing fields decode normally
Explanation: Protobuf wire format is tag-length-value; unknown tags are skipped by the decoder.Example 2:
Input: Server removes field 3 'email' and reuses tag 3 for a new field 'phone'
Output: Old client decodes 'phone' bytes as 'email' bytes -> garbage data
Explanation: Tag numbers are the contract; reusing one breaks backward compatibility.package main
// user.proto
//
// syntax = "proto3";
// message User {
// int64 id = 1;
// string name = 2;
// string email = 3; // present in v1
// string created_by = 7; // added in v2 (safe)
// reserved 3, "email"; // proper way to remove a field
// }
import (
"encoding/json"
"fmt"
)
type User struct {
Id int64
Name string
Email string
CreatedBy string
}
// decode is a stand-in for protoc-generated proto.Unmarshal. The playground
// has only stdlib available, so we use encoding/json as the wire format.
func decode(data []byte) (*User, error) {
u := &User{}
if err := json.Unmarshal(data, u); err != nil {
return nil, err
}
return u, nil
}
func main() {
wire := []byte(`{"Id":42,"Name":"Ada","Email":"[email protected]"}`)
u, err := decode(wire)
if err != nil {
fmt.Println("decode error:", err)
return
}
fmt.Printf("user: id=%d name=%s email=%s\n", u.Id, u.Name, u.Email)
}REST sometimes wins on observability because every call has a stable URL path. What is the gRPC equivalent for path-based monitoring, and where does it fall short of HTTP-method-and-path slicing?
Examples
Example 1:
Input: gRPC server, want per-RPC latency histogram in Prometheus
Output: Use grpc_server_handled_total{grpc_method,grpc_service} and grpc_server_handling_seconds histogram
Explanation: Service+method is the gRPC equivalent of HTTP method+path; both are stable cardinality.Example 2:
Input: REST URL contains a path parameter (GET /users/{id}/orders/{order_id})
Output: Cardinality explosion in metrics unless you normalize the path before recording
Explanation: gRPC has no path params (the parameters are in the protobuf body), so this class of mistake is gone.package main
import (
"context"
"fmt"
"sync"
"time"
)
// Self-contained stand-ins for the gRPC + Prometheus types. The playground
// only ships the stdlib, so we re-declare just enough surface to make the
// interceptor pattern compile and demonstrate.
type UnaryServerInfo struct {
FullMethod string
}
type UnaryHandler func(ctx context.Context, req interface{}) (interface{}, error)
type histogram struct {
mu sync.Mutex
labels map[string][]float64
}
func newHistogram() *histogram { return &histogram{labels: map[string][]float64{}} }
func (h *histogram) Observe(service, method, code string, secs float64) {
key := service + "|" + method + "|" + code
h.mu.Lock()
defer h.mu.Unlock()
h.labels[key] = append(h.labels[key], secs)
}
var rpcLatency = newHistogram()
func metricsInterceptor(ctx context.Context, req interface{}, info *UnaryServerInfo, handler UnaryHandler) (interface{}, error) {
start := time.Now()
resp, err := handler(ctx, req)
code := "OK"
if err != nil {
code = "Internal"
}
service, method := splitFullMethod(info.FullMethod)
rpcLatency.Observe(service, method, code, time.Since(start).Seconds())
return resp, err
}
func splitFullMethod(fullMethod string) (service, method string) {
for i := len(fullMethod) - 1; i >= 0; i-- {
if fullMethod[i] == '/' {
return fullMethod[1:i], fullMethod[i+1:]
}
}
return "", fullMethod
}
func main() {
info := &UnaryServerInfo{FullMethod: "/users.UserService/GetUser"}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
time.Sleep(2 * time.Millisecond)
return "user42", nil
}
resp, err := metricsInterceptor(context.Background(), nil, info, handler)
fmt.Printf("resp=%v err=%v observations=%d\n", resp, err, len(rpcLatency.labels))
}