Back to Blog
Backend16 min readJun 2026

REST vs GraphQL vs gRPC: Choosing an API Style

REST, GraphQL, and gRPC solve the same problem, moving data between services, in three very different ways. Here is when each wins, the trade-offs nobody markets, and how they happily coexist in one system.

RESTGraphQLgRPCAPI Design
SB

Sri Balaji

Founder

On this page

Three doors to the same room

Who this is for

You can build an API, but every project picks a different style and you are not sure why. You have heard 'GraphQL fixes over-fetching' and 'gRPC is faster' and want the honest version, what each one actually trades away, and when to reach for which. No prior GraphQL or gRPC experience assumed.

Every API does the same job: a client asks for data or an action, a server answers. REST, GraphQL, and gRPC are three different contracts for that conversation. They are not ranked, there is no 'best' one. They are tuned for different callers, different networks, and different teams. Pick by the shape of your problem, not by what trended last quarter.

REST gives you a menu of resources. GraphQL gives you a kitchen and a language to order from it. gRPC gives you a private phone line to a specific colleague who already knows your shorthand.

An analogy: three ways to order lunch

Before any code, anchor the three styles to something physical. The difference between them is really a difference in *who shapes the request* and *how tightly the two sides agreed in advance*.

A fixed menu, each dish has its own number, you order item #12, you get exactly item #12REST: each resource has its own URL; GET /users/12 returns the user resource, no more, no less
A build-your-own-bowl counter, you describe the exact bowl you want and get back precisely that, nothing extraGraphQL: one endpoint, the client writes a query describing the exact fields it needs and gets that shape back
A phone order to a regular in a shared shorthand, fast, terse, both sides already memorised the codesgRPC: a strongly-typed contract compiled into both client and server; calls are compact binary over a persistent connection
Who shapes the request and how much was agreed up front is the whole story.

The picture: one client, three back-ends

In a real system you rarely choose only one. A typical setup puts a gateway in front: the browser or mobile app talks one friendly protocol to the edge, and the gateway fans out to whichever style each downstream service speaks.

HTTPSRESTGraphQLgRPC
Web / Mobile App

Browser, iOS, Android

API Gateway / BFF

Edge, auth, routing

Catalog Service

REST + HTTP cache

GraphQL Server

Client-shaped reads

Pricing Service

gRPC, internal

Data Stores

Postgres, Redis

A client hits one gateway; the gateway speaks REST, GraphQL, and gRPC to the services behind it.

  1. 1

    Client makes one request

    The app sends a single HTTPS call to the gateway, it never needs to know how many services live behind it.

  2. 2

    Gateway authenticates and routes

    It validates the token once, then decides which downstream service (and which protocol) serves this path.

  3. 3

    Fan-out to the right style

    Cacheable catalog reads go to a REST service; a screen needing many joined fields hits the GraphQL server; latency-sensitive internal calls use gRPC.

  4. 4

    Compose and return

    The gateway stitches the responses into one payload shaped for the client and sends it back.

This is the key mental shift: the three styles are not competitors fighting for your whole system. They are tools for different *edges* of it. See API gateways and the edge for how this front door is built.

The big comparison

Here is the whole debate on one screen. Read each row as 'what does this style optimise for, and what does it cost me?'

DimensionRESTGraphQLgRPC
TransportHTTP/1.1 or 2, JSONHTTP, JSON, one POST endpointHTTP/2, binary protobuf
Schema / typingOptional (OpenAPI)Strong, introspectable schemaStrong, contract-first .proto
FetchingFixed per endpointClient picks exact fieldsFixed per RPC method
CachingEasy, native HTTP cachingHard, POST, custom layerHard, not HTTP-cacheable
StreamingLimited (SSE, chunked)Subscriptions (extra setup)First-class bi-directional
Browser supportNative, trivialNative, trivialNeeds grpc-web + proxy
Best forPublic APIs, CRUD, cachingBFF, rich client screensInternal microservice calls
No row makes one style 'win', each is a deliberate trade.

Read the caching row twice

REST's biggest quiet advantage is that the entire web, CDNs, browsers, proxies, already knows how to cache a GET by URL. GraphQL and gRPC throw that away and make you rebuild caching yourself. For high-read public traffic, that single fact often decides the choice.

Same request, three dialects

Nothing makes the difference concrete like one task in all three styles. The task: get a user with their orders. Watch how the request shape and the contract change.

REST: two resources, fixed shapes

http
GET /users/42 HTTP/1.1
Host: api.shop.com
Accept: application/json

# Response: the full user resource (every field, fetched or not)
{
  "id": 42,
  "name": "Ada",
  "email": "ada@shop.com",
  "createdAt": "2026-01-04T10:00:00Z"
}

# Orders are a separate resource, a second round trip
GET /users/42/orders HTTP/1.1

REST is dead simple to read, cache, and debug, but you get the *whole* user even if you wanted only the name, and you pay two round trips. That is over-fetching (too many fields) and under-fetching (too few resources per call) in one example.

GraphQL: one call, exactly the shape you asked for

graphql
query {
  user(id: 42) {
    name
    orders(last: 3) {
      id
      total
    }
  }
}

# Response mirrors the query exactly, no extra fields, one round trip
{
  "data": {
    "user": {
      "name": "Ada",
      "orders": [
        { "id": "o_91", "total": 49.0 },
        { "id": "o_88", "total": 12.5 }
      ]
    }
  }
}

One request, only the fields the screen needs, joined across user and orders. Over- and under-fetching solved. The cost shows up elsewhere, see the N+1 warning below.

gRPC: a typed contract compiled into both sides

protobuf
syntax = "proto3";
package shop.v1;

service UserService {
  rpc GetUserWithOrders(GetUserRequest) returns (UserWithOrders);
}

message GetUserRequest {
  int64 user_id = 1;
  int32 last_orders = 2;
}

message Order {
  string id = 1;
  double total = 2;
}

message UserWithOrders {
  int64 id = 1;
  string name = 2;
  repeated Order orders = 3;
}

You define the contract once in a .proto file, then generate type-safe clients and servers in every language. Calls travel as compact binary over a reused HTTP/2 connection, fast and strict, but opaque to a casual curl and awkward from a browser. For how a contract like this evolves without breaking callers, see API design, evolution, and versioning.

The two sharpest gotchas

Each of the newer styles has one trap that bites teams hard in production. Know them before you commit.

GraphQL N+1: the resolver that quietly hammers your DB

A query for 'users and each user's orders' can naively run 1 query for the users, then 1 more per user for their orders, N+1 queries. The fix is a **DataLoader**: it batches all the per-user lookups within a tick into a single query and caches them. GraphQL does not give you this for free; you must wire batching into your resolvers or your 'one elegant query' becomes hundreds of DB hits.

typescript
import DataLoader from "dataloader";

// Batches every orders-by-userId lookup in one tick into ONE query
const orderLoader = new DataLoader(async (userIds: readonly number[]) => {
  const rows = await db.orders.findByUserIds(userIds);
  // Return results in the SAME order as userIds (DataLoader's contract)
  return userIds.map((id) => rows.filter((r) => r.userId === id));
});

const resolvers = {
  User: {
    orders: (user: { id: number }) => orderLoader.load(user.id),
  },
};

gRPC in the browser: it does not just work

Browsers cannot speak raw gRPC, they do not give JavaScript the low-level control of HTTP/2 frames that gRPC needs. You need **grpc-web**, a variant that runs through a proxy (Envoy or a sidecar) translating between the browser and your gRPC backend. It works, but it is extra moving parts and it loses client-streaming. This is why gRPC's sweet spot is service-to-service, not service-to-browser.

How to choose

Skip the religion. Walk down this list and stop at the first match, it is right far more often than agonising over benchmarks.

  1. Public API for third parties, or heavy cacheable reads? Reach for REST. Everyone knows it, every tool speaks it, and HTTP caching is free money.
  2. One client (web/mobile) pulling many fields from many sources per screen? Reach for GraphQL, usually as a BFF (backend-for-frontend). It kills over- and under-fetching and lets the front end evolve without new endpoints.
  3. Internal service-to-service calls where latency and strict typing matter? Reach for gRPC. Compact binary, HTTP/2 multiplexing, generated clients, and real streaming.
  4. Need real-time bidirectional streams (chat, telemetry, live updates) between services? gRPC streaming is purpose-built; GraphQL subscriptions or SSE are the browser-facing alternatives.
  5. Small team, simple CRUD, ship today? REST. Do not add a query language or a protobuf toolchain to a problem that a dozen endpoints solve.

They coexist, that is the norm

Mature systems use all three: REST at the public edge, GraphQL as the BFF for the app, gRPC humming between internal services. The gateway is what lets each style live where it is strongest. Choosing one for the whole company is usually a mistake.

Common mistakes that cost hours

  1. Picking GraphQL to look modern, then re-implementing caching and rate-limiting by hand. You gave up free HTTP caching; make sure the flexibility was worth it.
  2. Ignoring the N+1 problem until the DB melts under load. Add DataLoaders from day one in any resolver that fans out to children.
  3. Exposing gRPC to browsers without grpc-web and a proxy, then wondering why nothing connects. Use REST or GraphQL at the browser edge; keep gRPC internal.
  4. Treating REST as 'no schema needed'. Untyped JSON drifts. Write an OpenAPI spec so clients and tests have a contract, see REST API design.
  5. Letting a GraphQL query depth or cost go unbounded. A single nested query can become a denial-of-service. Add depth limits and query cost analysis.
  6. Versioning a gRPC contract by editing field numbers. Field tags are forever, only add new ones; never renumber or reuse, or you silently corrupt old clients.

Takeaways

The whole article in seven lines

  • All three move data; they differ in who shapes the request and how strictly both sides agreed up front.
  • REST: resources + HTTP verbs, simple, and the only one with free web-wide caching, ideal for public APIs and cacheable reads.
  • GraphQL: one endpoint, client-shaped queries that solve over/under-fetching, ideal as a BFF, but you own caching and the N+1 risk (DataLoader).
  • gRPC: HTTP/2 + protobuf, fast, strongly typed, real streaming, ideal internal, but needs grpc-web for browsers.
  • Caching is REST's quiet superpower; flexibility is GraphQL's; speed and typing are gRPC's.
  • Choose by problem shape, not hype, and expect to use all three behind one gateway.
  • Whatever you pick, write down the contract: OpenAPI, a GraphQL schema, or a .proto.

Where to go next

You now have a decision framework. Deepen each branch with these, then practise designing a contract end to end:

Want to go deeper?

This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.