Skip to content

REST vs gRPC vs GraphQL: A Production Decision Guide

BackendBytes Engineering Team
BackendBytes Engineering Team
9 min read
REST vs gRPC vs GraphQL: A Production Decision Guide

Key Takeaways

  • REST is the default for external APIs and caching; gRPC is 2-10x faster for internal service-to-service calls via binary serialization and HTTP/2 multiplexing
  • GraphQL eliminates over-fetching on mobile by letting clients query only the fields they need; one round-trip fetch of user + orders + order items instead of three sequential REST calls
  • gRPC requires code generation and doesn't work in browsers; GraphQL works in browsers but needs N+1 prevention (DataLoader) to avoid query per-list-item
  • The team ran all three: gRPC for internal microservice calls (40 inventory calls per request → 2-3 gRPC calls), GraphQL for mobile (41 unused fields → exact fields needed), REST for partners (stable versioned contracts)

The team had three separate client problems: Internal checkout was calling inventory 40 times per request (P99 latency: 850ms). Mobile was over-fetching 41 unused fields per product. External partners depended on 12 stable REST endpoints.

One answer: use all three protocols. gRPC for internal service calls. GraphQL for mobile. REST for partners.

TL;DR

REST, gRPC, and GraphQL solve different problems. REST handles external APIs and caching. gRPC cuts latency for internal service-to-service communication by 60-80% via binary serialization and HTTP/2 multiplexing. GraphQL lets clients fetch exactly the fields they need, eliminating over-fetching on mobile. [GraphQL spec]

  • gRPC wins: Low latency (2-10x faster than JSON), strong type contracts, streaming
  • GraphQL wins: Flexible field selection, complex joins in one request, mobile bandwidth
  • REST wins: Browser compatibility, HTTP caching, external API stability [RFC 9110, 2022]

The quick start: REST vs gRPC vs GraphQL

[RFC 9110, 2022]
Use caseBest choiceWhy
External / public APIRESTCDN cacheable, no compiler needed, 30-year ecosystem
Internal microservicesgRPCBinary protocol saves 60-80% bandwidth, HTTP/2 multiplexing, type-safe clients
Mobile / diverse clientsGraphQLClients query only fields they need; joins in one round-trip
Stable versioned contractsRESTPath versioning (/api/v1/, /api/v2/) is explicit and cacheable
High-frequency callsgRPC1KB JSON payload → 250-400 bytes Protobuf; 40 calls/request × 100 req/s saves 4MB/s
Browser accessRESTNo compilation step; curl/fetch just work

As a decision flow:

graph TD
    Start{"Who calls<br/>this API?"} -->|browser /<br/>3rd parties| REST["REST"]
    Start -->|internal<br/>service| Q1{"Latency /<br/>bandwidth tight?"}
    Start -->|mobile app /<br/>diverse clients| Q2{"Over-fetching<br/>a real problem?"}
    Q1 -->|yes| gRPC["gRPC"]
    Q1 -->|no, prefer<br/>ergonomics| REST
    Q2 -->|yes, clients<br/>need field control| GraphQL["GraphQL"]
    Q2 -->|no| REST

Browser clients almost always get REST; internal RPC almost always gets gRPC when tuned for performance; GraphQL earns its keep specifically when the over-fetching or under-fetching problem outweighs its schema + caching complexity.

REST: HTTP Semantics as Your Contract

REST leverages HTTP semantics[RFC 9110, 2022]: status codes, caching headers, ETags, conditional requests. This means CDNs, proxies, and load balancers understand your API without custom logic. URLs name resources (/orders/123), HTTP verbs describe operations (GET, POST, PATCH).

REST code: Idempotency and ETags

The following Spring Boot example shows two production patterns:

@RestController
@RequestMapping("/api/v1")
public class OrderController {
    private final OrderService orderService;
 
    @GetMapping("/orders/{id}")
    public ResponseEntity<OrderResponse> getOrder(
            @PathVariable String id,
            @RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch) {
 
        Order order = orderService.findById(id)
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
 
        String etag = "\"" + order.getVersion() + "\"";
 
        // Conditional GET: client already has this version — return 304
        if (etag.equals(ifNoneMatch)) {
            return ResponseEntity.status(HttpStatus.NOT_MODIFIED)
                .eTag(etag).build();
        }
 
        return ResponseEntity.ok()
            .eTag(etag)
            .cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).mustRevalidate())
            .body(OrderResponse.from(order));
    }
 
    @PostMapping("/orders")
    public ResponseEntity<OrderResponse> createOrder(
            @Valid @RequestBody CreateOrderRequest request,
            @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey) {
 
        // Idempotency: same key = return original response, never a duplicate
        if (idempotencyKey != null) {
            Optional<Order> existing = orderService.findByIdempotencyKey(idempotencyKey);
            if (existing.isPresent()) {
                return ResponseEntity.created(buildOrderUri(existing.get().getId()))
                    .body(OrderResponse.from(existing.get()));
            }
        }
 
        Order order = orderService.create(request, idempotencyKey);
        return ResponseEntity.created(buildOrderUri(order.getId()))
            .body(OrderResponse.from(order));
    }
 
    private URI buildOrderUri(String orderId) {
        return UriComponentsBuilder.fromPath("/api/v1/orders/{id}")
            .buildAndExpand(orderId).toUri();
    }
}

Two critical patterns: ETags enable cache revalidation (client sends If-None-Match, server returns 304 Not Modified). Idempotency keys prevent duplicate orders on network retries.

REST versioning and error responses

Version in the URL path: /api/v1/, /api/v2/. Avoid header-based versioning — it's invisible in logs and breaks caching.

// Version is explicit and cacheable
@RestController
@RequestMapping("/api/v1/products")
public class ProductControllerV1 { /* ... */ }
 
@RestController
@RequestMapping("/api/v2/products")
public class ProductControllerV2 { /* ... */ }

Standardize errors via RFC 9457 Problem Details: never return plain strings.

// Problem Detail: machine-parseable, standards-compliant
{
  "type": "https://api.example.com/errors/not-found",
  "title": "Not Found",
  "status": 404,
  "detail": "order 12345 not found"
}

When REST fails

Over-fetching: Mobile downloads 41 unused fields; desktop needs all 47. One REST endpoint can't serve both.

Under-fetching: Fetching a user profile + orders + order items requires 3+ round-trips.

No built-in streaming: WebSockets and Server-Sent Events are bolt-ons, not REST-native.

gRPC: Binary Protocol for Internal Services

[gRPC docs]

The wire format comparison — JSON-over-HTTP/1.1 vs Protobuf-over-HTTP/2 for the same GetOrder(id=123) call:

graph TB
    subgraph REST[REST + JSON over HTTP/1.1]
        R1[Client] -->|GET /orders/123<br/>headers + body 1.2 KB| R2[TCP connection 1]
        R1 -->|GET /orders/124<br/>parallel = new conn| R3[TCP connection 2]
        R1 -->|GET /orders/125| R4[TCP connection 3]
        R2 --> RBody[JSON body<br/>id, customer_id,<br/>total_cents, status<br/>field names sent<br/>every request]
    end
    subgraph GRPC[gRPC + Protobuf over HTTP/2]
        G1[Client] -->|stream 1: GetOrder 123| G2[Single TCP connection<br/>multiplexed]
        G1 -->|stream 3: GetOrder 124<br/>same conn| G2
        G1 -->|stream 5: GetOrder 125<br/>same conn| G2
        G2 --> GBody[Protobuf body<br/>field tags 1, 2, 3, 4<br/>varint compact ints<br/>~250-400 bytes]
    end
    style RBody fill:#fdd
    style GBody fill:#dfd
    style R2 fill:#fdd
    style R3 fill:#fdd
    style R4 fill:#fdd

The 60-80% bandwidth saving is the combination of compact field tags (numbers, not strings) AND HTTP/2 multiplexing eliminating the connection-per-request tax. [gRPC docs]

gRPC is an RPC framework built on HTTP/2 using Protocol Buffers[Protocol Buffers spec] as IDL[gRPC docs]. Three properties make it best for internal service calls:

  1. Binary serialization: 1.2KB JSON → 250-400 bytes Protobuf. Field names are numbers on the wire, not strings.
  2. HTTP/2 multiplexing: Multiple RPCs over one TCP connection. HTTP/1.1 requires one connection per request.
  3. Generated clients: protoc generates type-safe clients in Go, Java, Python from a single .proto file. No SDK drift.

gRPC service definition

syntax = "proto3";
package commerce.orders.v1;
 
service OrderService {
  rpc GetOrder(GetOrderRequest) returns (Order);
  rpc CreateOrder(CreateOrderRequest) returns (Order);
  rpc StreamOrderEvents(StreamOrderEventsRequest) returns (stream OrderEvent);
}
 
message Order {
  string id = 1;
  string customer_id = 2;
  repeated LineItem items = 3;
  OrderStatus status = 4;
  double total_usd = 5;
  int64 created_at_ms = 6;
}
 
message LineItem {
  string product_id = 1;
  int32 quantity = 2;
  double unit_price_usd = 3;
}
 
enum OrderStatus {
  PENDING = 0;
  CONFIRMED = 1;
  SHIPPED = 2;
  DELIVERED = 3;
}
 
message GetOrderRequest { string id = 1; }
message CreateOrderRequest {
  string customer_id = 1;
  repeated LineItem items = 2;
  string idempotency_key = 3;
}

gRPC server in Go

func (s *OrderServer) GetOrder(ctx context.Context, req *pb.GetOrderRequest) (*pb.Order, error) {
    if req.GetId() == "" {
        return nil, status.Error(codes.InvalidArgument, "order id required")
    }
 
    row := s.db.QueryRow(ctx,
        `SELECT id, customer_id, status, total_usd, created_at
         FROM orders WHERE id = $1`, req.GetId())
 
    var o pb.Order
    var createdAt time.Time
    var statusStr string
    err := row.Scan(&o.Id, &o.CustomerId, &statusStr, &o.TotalUsd, &createdAt)
    if errors.Is(err, pgx.ErrNoRows) {
        return nil, status.Errorf(codes.NotFound, "order %q not found", req.GetId())
    }
    if err != nil {
        return nil, status.Error(codes.Internal, "fetch failed")
    }
 
    o.Status = pb.OrderStatus(pb.OrderStatus_value[statusStr])
    o.CreatedAtMs = createdAt.UnixMilli()
 
    // Fetch line items
    rows, _ := s.db.Query(ctx,
        `SELECT product_id, quantity, unit_price_usd FROM order_items WHERE order_id = $1`,
        o.Id)
    defer rows.Close()
 
    for rows.Next() {
        var item pb.LineItem
        rows.Scan(&item.ProductId, &item.Quantity, &item.UnitPriceUsd)
        o.Items = append(o.Items, &item)
    }
 
    return &o, nil
}
 
func (s *OrderServer) StreamOrderEvents(
    req *pb.StreamOrderEventsRequest,
    stream pb.OrderService_StreamOrderEventsServer,
) error {
    ch := s.events.Subscribe(req.GetOrderId())
    for event := range ch {
        if err := stream.Send(event); err != nil {
            return err
        }
    }
    return nil
}

Key production patterns: GetOrder validates input, returns gRPC status codes (not HTTP). StreamOrderEvents uses bidirectional streaming — not possible with REST.

Proto versioning rules

The one unbreakable rule for gRPC: never break backwards compatibility.

// SAFE: Add new fields — old clients ignore them
message Order {
  string id = 1;
  double total = 2;
  string tracking_id = 4;    // ✅ New field
}
 
// DANGEROUS: Never reuse field numbers
message Order {
  string id = 1;
  // double total = 2;        // ❌ Deleted
  reserved 2;                 // ✅ Prevent reuse
  string currency_total = 5;
}
 
// SAFE: Add new enum values (at the end only)
enum OrderStatus {
  PENDING = 0;
  SHIPPED = 1;
  RETURNED = 2;              // ✅ New value
}

Enforce in CI with buf breaking --against '.git#branch=main' — fails the build on any breaking change.

func main() {
    lis, err := net.Listen("tcp", ":50051")
    if err != nil {
        log.Fatalf("listen: %v", err)
    }
 
    creds, err := credentials.NewServerTLSFromFile("server.crt", "server.key")
    if err != nil {
        log.Fatalf("TLS: %v", err)
    }
 
    srv := grpc.NewServer(
        grpc.Creds(creds),
        grpc.ChainUnaryInterceptor(
            tracingInterceptor(),
            loggingInterceptor(logger),
            authInterceptor(tokenVerifier),
            recoveryInterceptor(), // catch panics, return codes.Internal
        ),
    )
 
    orderSrv := &OrderServer{db: pool, events: eventBus}
    pb.RegisterOrderServiceServer(srv, orderSrv)
 
    // Health checking — required for Kubernetes readiness probes
    healthSrv := health.NewServer()
    grpc_health_v1.RegisterHealthServer(srv, healthSrv)
    healthSrv.SetServingStatus(
        "commerce.orders.v1.OrderService",
        grpc_health_v1.HealthCheckResponse_SERVING,
    )
 
    // Graceful shutdown: mark NOT_SERVING before terminating
    stop := make(chan os.Signal, 1)
    signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT)
    go func() {
        <-stop
        healthSrv.SetServingStatus(
            "commerce.orders.v1.OrderService",
            grpc_health_v1.HealthCheckResponse_NOT_SERVING,
        )
        time.Sleep(5 * time.Second) // drain in-flight RPCs from load balancer
        srv.GracefulStop()
    }()
 
    srv.Serve(lis)
}

Go Client with Retry Policy

const serviceConfig = `{
  "methodConfig": [{
    "name": [{"service": "commerce.orders.v1.OrderService"}],
    "retryPolicy": {
      "maxAttempts": 3,
      "initialBackoff": "0.1s",
      "maxBackoff": "1s",
      "backoffMultiplier": 2.0,
      "retryableStatusCodes": ["UNAVAILABLE"]
    },
    "timeout": "5s"
  }]
}`
 
func NewOrderClient(addr string, tlsCfg *tls.Config) (*OrderClient, error) {
    conn, err := grpc.NewClient(addr,
        grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)),
        grpc.WithDefaultServiceConfig(serviceConfig),
    )
    if err != nil {
        return nil, fmt.Errorf("dial %s: %w", addr, err)
    }
    return &OrderClient{client: pb.NewOrderServiceClient(conn)}, nil
}
 
func (c *OrderClient) GetOrder(ctx context.Context, id string) (*pb.Order, error) {
    // Derive timeout from incoming context — never use context.Background() here
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()
 
    return c.client.GetOrder(ctx, &pb.GetOrderRequest{Id: id})
}

gRPC challenges

  • Browser clients: No HTTP/2 trailer support in Fetch API. Need grpc-web proxy (Envoy). Adds a network hop.
  • L7 load balancing required: Standard L4 load balancers pin all RPCs from a client to one backend. Use Envoy or client-side DNS round-robin to distribute traffic.
  • Debugging: Can't curl it. Use grpcurl instead.

GraphQL: Client-Driven Queries

GraphQL[GraphQL spec] lets clients query exactly the fields they need. One endpoint replaces dozens of REST resources. Mobile queries 6 fields; desktop queries 30. Both get one response, no over-fetching.

GraphQL schema and queries

type Query {
  order(id: ID!): Order
  products(first: Int = 20, after: String): ProductConnection
}
 
type Mutation {
  createOrder(input: CreateOrderInput!): CreateOrderPayload!
}
 
type Order {
  id: ID!
  status: OrderStatus!
  items: [LineItem!]!
  customer: Customer!
  total: Money!
}
 
type LineItem {
  product: Product!
  quantity: Int!
  unitPrice: Money!
}
 
type Product {
  id: ID!
  name: String!
  price: Money!
  thumbnailUrl: String!
  inStock: Boolean!
  relatedProducts(first: Int): [Product!]!
}

GraphQL queries and the N+1 problem

# Mobile: exactly 6 fields
query CheckoutProducts {
  products(first: 20) {
    name
    price
    thumbnailUrl
    inStock
  }
}
 
# Desktop: all fields including expensive relatedProducts
query DetailPage($id: ID!) {
  product(id: $id) {
    name
    price
    description
    relatedProducts(first: 5) {
      name
      price
    }
  }
}

The N+1 problem: Fetching 20 products without DataLoader triggers 1 query for products + 20 queries for related products = 21 total. With DataLoader, it's 1 + 1 = 2 queries. DataLoader batches lookups into a single SELECT ... WHERE id IN query.

Go resolver with DataLoader

package graph
 
type Resolver struct {
    productRepo ProductRepository
    loaders     *DataLoaders
}
 
type DataLoaders struct {
    ProductByID *dataloader.Loader[string, *model.Product]
}
 
// Field resolver: runs once per object. DataLoader batches these.
func (r *lineItemResolver) Product(
    ctx context.Context, 
    obj *model.LineItem,
) (*model.Product, error) {
    return r.loaders.ProductByID.Load(ctx, obj.ProductID)()
}
 
// Mutation: validate, create, return errors in payload (not top-level)
func (r *mutationResolver) CreateOrder(
    ctx context.Context, 
    input model.CreateOrderInput,
) (*model.CreateOrderPayload, error) {
    if len(input.Items) == 0 {
        return &model.CreateOrderPayload{
            Errors: []model.UserError{{Message: "order must contain at least one item"}},
        }, nil
    }
 
    order, err := r.orderRepo.Create(ctx, input)
    if err != nil {
        return &model.CreateOrderPayload{
            Errors: []model.UserError{{Message: "creation failed"}},
        }, nil
    }
 
    return &model.CreateOrderPayload{Order: order}, nil
}

Critical pattern: Return errors in the payload object (not top-level), so clients distinguish validation errors from failures.

GraphQL challenges

  • Error semantics: Returns HTTP 200 even on failures. Need GraphQL-specific monitoring (error rate by operation name, not HTTP status).
  • N+1 queries: DataLoader is mandatory. Without it, one query triggers N subqueries.
  • Schema evolution: Breaking changes aren't caught without graphql-inspector in CI. Removing a field silently breaks clients.
  • Depth limits required: Protect against exponential queries: query { products { relatedProducts { relatedProducts { ... } } } }. Use complexity budgets.

Production checklist

When you've chosen your protocol(s), verify:

  • REST: Version in URL paths (/api/v1/, /api/v2/). Use idempotency keys for POST. Standardize errors with RFC 9457 Problem Details.
  • gRPC: Lock proto evolution rules in CI with buf breaking. Use L7 load balancing (Envoy) to avoid uneven distribution. Enable reflection for debugging with grpcurl.
  • GraphQL: Add DataLoader batching. Set complexity limits. Return errors in payload objects. Use schema registry and graphql-inspector for breaking change detection.
  • Multi-protocol: Plan for 3x auth, 3x observability, 3x client generators. Worth it only if access patterns truly differ. [gRPC docs]

Federation patterns: gateway, BFF, GraphQL federation, service mesh

Once you run more than a handful of services, the question stops being "which protocol" and starts being "how do clients reach all of them coherently". Four common patterns, each with sharp edges:

API gateway sits in front of every backend, handles auth, rate limiting, and request shaping. Kong, Envoy, and AWS API Gateway dominate here. The gateway terminates TLS, validates JWTs once, and forwards trusted requests with internal headers. The trap: gateways become god-objects when teams push business logic into them. Keep them stateless and protocol-agnostic; never let them call databases.

Backend for Frontend (BFF) gives each client surface its own shaping layer. The mobile BFF stitches gRPC calls into a slim payload; the web BFF returns a denormalised view tuned for the browser. BFFs solve the chatty-mobile problem without forcing GraphQL on every team. Cost: more services to operate, and a real risk of duplicated business logic if BFFs start owning rules instead of orchestrating them.

GraphQL federation (Apollo Federation, Hot Chocolate) composes a single supergraph from multiple subgraphs owned by different teams. Each team owns the types they understand best: the catalog team owns Product, the order team owns Order, the supergraph stitches them. This works when teams agree on entity keys and a schema registry blocks breaking changes. It collapses when ownership is fuzzy or when one team's slow resolver poisons every query.

Service mesh (gRPC + Istio/Linkerd) pushes cross-cutting concerns into sidecars: mTLS, retries, traffic shifting, observability. Application code stays clean; the mesh handles the network. Worth it once you have ~20+ services. Below that, the operational tax of running a mesh exceeds the benefit, and a smarter gRPC client config covers most needs.

A typical large-system blueprint: API gateway at the edge, mobile and web BFFs behind it, a GraphQL supergraph for product surfaces that need flexible composition, and a service mesh wrapping all internal gRPC traffic.

A real migration: REST to gRPC for internal, REST kept external

The team had a public REST API used by 200+ partners and an internal monolith that talked to itself over JSON. Migrating internal calls to gRPC took two quarters and shaved P99 latency from 480ms to 140ms. The trick was keeping the external REST contract untouched while gradually moving internal hops to gRPC behind it. The bridge looked like this:

// Edge handler: still serves REST to partners, calls gRPC internally
func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()
 
    id := chi.URLParam(r, "id")
 
    // Internal call: now gRPC. External shape: still JSON.
    order, err := h.grpcClient.GetOrder(ctx, &pb.GetOrderRequest{Id: id})
    if err != nil {
        switch status.Code(err) {
        case codes.NotFound:
            writeProblemDetail(w, http.StatusNotFound, "order not found", id)
        case codes.DeadlineExceeded:
            writeProblemDetail(w, http.StatusGatewayTimeout, "upstream timeout", id)
        default:
            writeProblemDetail(w, http.StatusBadGateway, "upstream error", id)
        }
        return
    }
 
    // Translate proto → versioned JSON contract partners depend on
    resp := OrderResponseV1{
        ID:         order.Id,
        CustomerID: order.CustomerId,
        Status:     strings.ToLower(order.Status.String()),
        TotalUSD:   order.TotalUsd,
        CreatedAt:  time.UnixMilli(order.CreatedAtMs).UTC().Format(time.RFC3339),
    }
 
    w.Header().Set("Content-Type", "application/json")
    w.Header().Set("Cache-Control", "private, max-age=30")
    json.NewEncoder(w).Encode(resp)
}

Two rules made the migration safe. First, the gRPC status code to HTTP status code mapping was centralised — partners never saw a codes.NotFound leak as a 500. Second, the proto schema was treated as an internal contract; the public JSON shape lived in a separate Go struct, so changes to one never silently rippled into the other. Without that boundary, every proto rename would have been a partner-breaking change.

Rollout used a feature flag per route: 1% gRPC traffic, then 10%, then 50%, then 100%, watching error rates and latency at each step. Two RPCs were rolled back mid-migration when retry storms surfaced under load — the gRPC default service config was retrying UNAVAILABLE aggressively, and a flapping pod amplified the load instead of shedding it. [gRPC docs]

Versioning that survives multi-year evolution

Most APIs die from versioning, not performance. Three strategies that have aged well:

Path versioning for REST (/api/v1/, /api/v2/) is explicit, cacheable, and visible in every log line. Run versions in parallel for at least 12 months after deprecation. Publish a sunset header (Sunset: Sat, 01 Nov 2027 00:00:00 GMT) the day deprecation is announced — partners need machine-readable signals, not blog posts.

Additive-only Protobuf evolution for gRPC. Never reuse field numbers; reserve them when fields are deleted. Never change a field's type. Never renumber an enum. CI must enforce this — buf breaking --against '.git#branch=main' fails the build on any wire-incompatible change. After three years of additive changes, a commerce.orders.v1 package is still readable by clients that compiled against the original proto. A typical evolution after two years looks like this:

message Order {
  string id = 1;
  string customer_id = 2;
  repeated LineItem items = 3;
  OrderStatus status = 4;
 
  // Year 1: total in dollars (deprecated, kept for old clients)
  double total_usd = 5 [deprecated = true];
  reserved 6;                           // never reuse: was a removed field
  reserved "legacy_promo_code";         // also block the old name
 
  // Year 2: replaced with structured Money to support multi-currency
  Money total_money = 7;
 
  // Year 2: optional shipping info — old clients ignore unknown fields
  ShippingDetails shipping = 8;
}

GraphQL field deprecation, never field deletion. Mark fields @deprecated(reason: "use totalMoney") and watch usage in your schema registry. Only delete a field when traffic to it has been zero for 90 days. Adding a non-null field to an input type is a breaking change; adding nullable fields is safe.

The common thread: every protocol has a "safe" subset of changes and a "breaking" subset. Make the break point loud (CI failure, schema registry alert) rather than discovering it from a partner Slack at 2am.

Frequently Asked Questions

When should I use gRPC instead of REST?

Use gRPC for internal service-to-service communication where low latency and strict contracts matter. gRPC uses HTTP/2, Protocol Buffers, and code generation to achieve 2-10x lower latency than JSON/REST. REST is better for public APIs where broad client compatibility and human readability are priorities. [gRPC docs]

Can I use REST, gRPC, and GraphQL in the same system?

Yes, and many production systems do. A common pattern is gRPC for internal microservice communication, GraphQL for mobile/frontend-facing APIs that need flexible field selection, and REST for external partner APIs that need stable versioned contracts.

What is the N+1 problem in GraphQL and how do I solve it?

The N+1 problem occurs when resolving a list of N items triggers N additional database queries for related data. Solve it with DataLoader, which batches and deduplicates queries within a single request tick, turning N+1 queries into 2 batched queries.

Keep Reading

BackendBytes Engineering Team
BackendBytes Engineering Team

Engineering Team

A multidisciplinary team of backend engineers, architects, and DevOps practitioners shipping deep dives into distributed systems and production infrastructure.

Read Next