Choose per boundary, not per company

Most API-style debates go wrong because they treat the choice as a company standard. The real unit of decision is the boundary: a specific pair of producer and consumers with its own constraints. One organisation usually has several kinds of boundary, and they pull in different directions.

Three questions separate them:

  • Who writes the clients? Your own team, another internal team, or strangers you will never meet.
  • How often do clients upgrade? In lockstep with the server, on a release train, or whenever they feel like it (possibly never).
  • Who controls the network between them? You own both ends and every proxy in the middle, or traffic crosses the public internet, corporate firewalls and browsers.
Boundary Who writes clients Upgrade cadence Network control
Internal service-to-service Your engineering org Coordinated, often via shared generated code You own it end to end
Public or partner API External developers Slow, uneven, out of your control None; arbitrary proxies and HTTP tooling
First-party UI backend Your frontend and mobile teams Web ships often; mobile apps linger in the field Partial; browsers and mobile networks
Streaming or event-like flows Mixed Varies Needs long-lived connections to survive

A single company-wide style forces a poor fit somewhere. Mandating gRPC everywhere hands partners a binary protocol that their HTTP tooling and proxies may handle poorly. Mandating REST everywhere means internal teams hand-maintain clients that a schema compiler could generate. Mandating GraphQL everywhere puts a query planner in front of simple service calls that never needed one. The sections that follow evaluate each style against these boundaries.

How gRPC works and when it fits internal APIs

In gRPC, you define a service in a .proto file. Each RPC method names a request message and a response message. protoc reads that file and generates client stubs and server interfaces for each supported language. Because both sides are generated from one definition, a Go client can call a Java server as if it were calling a local method, and the types match on both ends. The gRPC introduction names Protocol Buffers as the default IDL and wire format. It also says other formats, such as JSON, can be used, but most of the tooling assumes protobuf.

The binary encoding identifies each field by its number, not its name, so the field numbers are the real contract. Adding a field with a new number is generally safe. Older readers skip numbers they don't recognize, and newer readers get the default value when the field is missing. Changing a field's number, or giving an old number to a new field, breaks clients: old binaries decode the bytes into the wrong field, often without any error. When you delete a field, mark its number and its name reserved so the compiler rejects any later reuse. The example that follows shows a removed field reserved this way.

These guarantees depend on coordination, and that has a cost. Teams usually keep protos in a shared repository where every interface change gets reviewed. Every consumer also has to stay on matching protoc and plugin versions, or the generated code drifts between services. The overhead is easiest to justify when one organization owns both ends of the call, which is why gRPC fits internal APIs best.

// user.proto: the service contract both client and server are generated from
syntax = "proto3";

package users.v1;

option go_package = "example.com/gen/users/v1;usersv1";

service UserService {
  // Each RPC names one request message and one response message.
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
}

message GetUserRequest {
  string user_id = 1;
}

message GetUserResponse {
  // Field 3 ("email") was removed. Reserving both the number and the name
  // makes protoc reject any future field that tries to reuse either one.
  reserved 3;
  reserved "email";

  string user_id = 1;
  string display_name = 2;

  // Added later with a new number. Old clients skip it, and new clients
  // reading old messages see the default (empty string).
  string primary_contact = 4;
}

// Generate Go code from the repo root (requires protoc-gen-go and
// protoc-gen-go-grpc on PATH, pinned to the same versions across teams):
//   protoc --go_out=. --go_opt=paths=source_relative \
//          --go-grpc_out=. --go-grpc_opt=paths=source_relative \
//          users/v1/user.proto

A proto3 service definition in which a deleted field's number and name are marked reserved and a new field is added under a fresh number, followed by the protoc command that generates Go stubs from it.

When to avoid gRPC

Once a call crosses a boundary you don't fully control, the same properties that make gRPC efficient start to create friction.

Browsers

Browser JavaScript cannot speak native gRPC. Fetch and XHR don't give scripts the low-level control over HTTP/2 framing and trailers that gRPC depends on. To reach a web client you need gRPC-Web plus a proxy that translates it, or a gateway that exposes a JSON/HTTP facade. Either way, you now run and version an extra component, and the browser never sees your actual service contract.

Intermediaries

Every proxy, load balancer, and API gateway in the path must handle HTTP/2 end to end, including trailers, which is where gRPC carries its status. A hop that downgrades to HTTP/1.1 or strips trailers breaks calls in ways that are hard to diagnose.

Long-lived connections cause a second problem. A gRPC client multiplexes many requests over one HTTP/2 connection and keeps it open. An L4 load balancer only balances connections, so it picks a backend once and sends all of that client's traffic there. New replicas then sit idle while old ones stay saturated. You need L7 balancing that distributes individual requests, or client-side balancing that is aware of every backend.

Debugging and performance

Protobuf on the wire is unreadable without the schema. You can't inspect it with a plain HTTP client or read it in packet captures without tooling, such as server reflection or a local copy of the .proto files.

Binary encoding is not automatically faster in a way that matters to you. The gain depends on your payload shapes, compression, and network. This article quotes no numbers, so benchmark your own payloads before you count speed as the reason to adopt gRPC.

REST for public and partner APIs

At the public edge, the deciding question is who your callers are, and you usually do not know. REST fits that situation because it uses semantics every HTTP client already understands. Methods carry intent. GET is safe and PUT is idempotent, so proxies and retry logic can act on them without knowing your domain. Status codes give coarse outcomes that any tool can branch on. A partner can call you with curl, a browser, or whatever HTTP library their stack already uses. They need no generated stub and no schema compiler.

HTTP also includes caching. In the example below, the server returns an ETag and Cache-Control: max-age=60. A later request sends If-None-Match with that tag and gets 304 Not Modified with no body if nothing has changed. CDNs and browsers handle this without custom code. Each resource has its own URL, which makes it easy to document, monitor, and rate-limit per endpoint.

These benefits come with costs:

  • Inconsistent resource design. Naming, pagination, and filtering drift between teams unless someone reviews them.
  • Over-fetching and under-fetching. Fixed representations return too much data or force extra calls.
  • Chatty clients. A single screen may need several round trips.
  • Versioning sprawl. /v1, /v2, and header-based schemes tend to pile up.

REST does not define versioning or deprecation. Your team has to choose a scheme, publish sunset dates, and enforce them in review and at the gateway.

# 1) First request: client has no cached copy
GET /v1/orders/8812 HTTP/1.1
Host: api.example.com
Accept: application/json

HTTP/1.1 200 OK
Content-Type: application/json
# Opaque version tag for this representation
ETag: "a3f9c1"
# Any cache may reuse this response for 60 seconds without asking
Cache-Control: public, max-age=60

{"id": 8812, "status": "shipped", "total": "42.50"}

# 2) After max-age expires: revalidate instead of refetching
GET /v1/orders/8812 HTTP/1.1
Host: api.example.com
Accept: application/json
# Send back the tag we have; server compares it to the current version
If-None-Match: "a3f9c1"

# Unchanged: no body is sent, client keeps using its cached copy
HTTP/1.1 304 Not Modified
ETag: "a3f9c1"
Cache-Control: public, max-age=60

A conditional GET where the server issues an ETag with a 60-second cache lifetime, and a later revalidation using If-None-Match returns 304 Not Modified with no body because the resource has not changed.

GraphQL for first-party UI backends and its operational cost

GraphQL takes the opposite approach to versioning. As graphql.org describes it, a GraphQL API is a single strongly typed schema. Clients select exactly the fields they need, write through mutations, receive real-time updates through subscriptions, and discover the schema through introspection. The schema evolves in place: you add fields freely and mark old ones with @deprecated(reason: "...") instead of cutting a /v2. For example, User.fullName might be deprecated in favor of User.displayName. A client query such as { user(id: "42") { displayName avatarUrl } } never touches the old field, and introspection reports isDeprecated: true to tooling.

The best fit is a backend-for-frontend. When a web app, a mobile app, and an admin console each need a different shape of the same graph, field selection replaces three bespoke endpoints.

That flexibility moves work onto the server:

  • N+1 resolvers. A list of 50 users that each resolve orders triggers 51 lookups. A per-request data loader batches those lookups into one call.
  • Depth and cost limits. Clients write the queries, so a nested query can be arbitrarily expensive. Reject queries above a computed cost before you execute them.
  • Field-level authorization. Each sensitive field needs its own authorization check, because a single endpoint gives you no URL to guard.
  • Introspection. Many teams disable it in production for anonymous callers. This reduces exposure but is not a security control on its own.
  • Caching. Everything goes through one POST endpoint, so HTTP caches see no distinct URLs to key on. Persisted queries fix this: clients send a hash of an approved query, which makes requests cacheable and restricts execution to queries you have reviewed.

If you expose the schema publicly, deprecation needs real discipline. Track usage per field and per client, and remove a deprecated field only after its usage reaches zero or its announced deadline passes.

# schema.graphql: one typed schema that changes in place instead of getting a /v2
type Query {
  user(id: ID!): User
  users(first: Int = 20): [User!]!
}

type User {
  id: ID!
  displayName: String!
  # Kept so existing clients still work. Introspection reports
  # isDeprecated: true and this reason to tooling.
  fullName: String @deprecated(reason: "Use displayName.")
  avatarUrl: String
  # Resolving this for every user in a list is the N+1 case.
  # Batch the lookups with a per-request data loader.
  orders(first: Int = 10): [Order!]!
}

type Order {
  id: ID!
  totalCents: Int!
  placedAt: String!
}

# --- client query (e.g. the mobile app's profile screen) ---
# Selects only the fields this screen needs and never touches fullName.
# The web app or admin console can ask for a different shape of the same graph.
query ProfileHeader($id: ID!) {
  user(id: $id) {
    displayName
    avatarUrl
    orders(first: 3) {
      id
      totalCents
    }
  }
}

A small GraphQL schema that marks fullName with @deprecated in favor of displayName, plus a client query that selects only the fields it needs and never touches the deprecated field.

Error models and observability across the three styles

Each style reports failure in a different place, so a dashboard built for one style will miss failures in the other two.

Style Where failure is signalled What a naive HTTP monitor sees
REST HTTP status code (4xx, 5xx) Accurate, if handlers set codes correctly
gRPC grpc-status in response trailers (e.g. UNAVAILABLE, DEADLINE_EXCEEDED) Usually HTTP 200, because the transport exchange succeeded
GraphQL errors array in the response body, or typed error results in data Often HTTP 200, even when resolvers fail

The root cause is that HTTP status describes the transport exchange, not the outcome of the operation. A gRPC call can complete a clean HTTP/2 exchange and still carry INTERNAL in its trailers. A GraphQL query can return partial data with one failed field. When expected failures are modelled as data, for example a PaymentDeclined result type, the GraphQL error-handling guidance [2] treats them as normal responses with no error flag at all. A load balancer counting 5xx responses will report all of these cases as healthy.

Instrument the outcome in each protocol's own terms:

  • gRPC: record metrics per method and per grpc-status code. Alert on server-side codes such as INTERNAL, UNAVAILABLE and DEADLINE_EXCEEDED. Track client-caused codes like INVALID_ARGUMENT separately.
  • GraphQL: label each metric with the operation name. Count responses that contain a non-empty errors array, and record which resolver path failed. Emit a separate counter for error-typed results so business failures stay visible.
  • REST: use status codes, but add the route template as a label so a single failing endpoint does not get averaged away.

Mixing styles, cost, and a per-boundary decision checklist

A common split is gRPC between services, REST at the public edge, and a GraphQL backend-for-frontend (BFF) that resolves UI queries by calling internal gRPC services. Each boundary gets a style that fits its clients, but the mix has costs:

  • Schemas to keep in sync: .proto files, an OpenAPI document, and a GraphQL schema can describe the same entity three ways.
  • Translation layers: the BFF and the edge gateway map fields, enums, and pagination between styles. Every mapping can drift.
  • Duplicate tooling: on-call engineers need different debuggers and dashboards for each style.
  • Inconsistent semantics: a gRPC status, an HTTP status, and a GraphQL errors entry can all describe one failure. Auth context must also survive each hop.
One style everywhere Style per boundary
Tooling and on-call One stack Several stacks
Fit Poor at some boundaries, such as REST between chatty services or gRPC exposed to browsers Matches each client
Ongoing cost Hand-built workarounds Translation and schema sync

The mix pays off where it removes work. Generated gRPC clients replace hand-written HTTP wrappers. A BFF replaces bespoke endpoints added for each screen. Typed contracts catch mismatches at compile or validation time, before they become integration bugs in production.

Per-boundary checklist

  • Client ownership and upgrade cadence: do you control when clients upgrade?
  • Browser reachability: must browsers call the API directly?
  • Caching: do you need HTTP or CDN caching?
  • Payload and latency: measure serialization and round-trip cost on your own traffic before you assume a winner.
  • Team familiarity: who will debug this at 3 a.m.?
  • Existing infrastructure: what do your gateways, proxies, and tracing already support?
 +------------------+                 +------------------+
 | Partner / public |                 |  Web & mobile UI |
 |   API clients    |                 |   (browser)      |
 +--------+---------+                 +--------+---------+
          | REST/JSON (OpenAPI)                | GraphQL query
          v                                    v
 +------------------+                 +------------------+
 | Edge gateway     |                 | GraphQL BFF      |
 | REST -> gRPC     |                 | resolvers->gRPC  |
 | [translation]    |                 | [translation]    |
 +--------+---------+                 +--------+---------+
          | gRPC                               | gRPC
          v                                    v
 +------------------------------------------------------------+
 |                internal services (gRPC, .proto)            |
 |  +-----------+       +-----------+       +-----------+     |
 |  | Orders    |<----->| Billing   |<----->| Inventory |     |
 |  +-----------+ gRPC  +-----------+ gRPC  +-----------+     |
 +------------------------------------------------------------+

 Same entity, three schemas:  OpenAPI doc | GraphQL schema | .proto
 One failure, three codes:    HTTP status | GraphQL errors | gRPC status
 Auth context must survive:   client -> edge/BFF -> service -> service

A mixed architecture with REST at the public edge, a GraphQL BFF for UI clients, and gRPC between internal services. The edge gateway and the BFF are translation layers, and every schema, error code, and auth hop they map must be kept in sync.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to grpc vs rest vs graphql, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Consumers learn about a breaking change at review time rather than from production errors.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.