API Design
GraphQL and gRPC: When Not to Use REST
What problems GraphQL and gRPC solve, the real costs (N+1, caching, tooling), a small working example of each, and a decision table.
On this page
In one line: GraphQL lets the client ask for exactly the fields it needs in one request, and gRPC gives services a fast, typed, binary way to call each other. Both solve real problems that REST handles awkwardly, and both bring their own costs: GraphQL brings N+1 queries, expensive-query attacks and weak HTTP caching; gRPC brings tooling, browser and debugging friction. Choose with a reason, not a trend.
By now you can design a clean REST API. So why are there two other styles in so many job descriptions?
Because REST has two well-known pain points. First, a screen may need an order, its items and the customer's name, which can mean three round trips, or one fat endpoint that returns far more than the screen needs. Second, when dozens of internal services call each other thousands of times a second, JSON over HTTP/1.1 is more expensive and less strict than it needs to be.
GraphQL targets the first pain point. gRPC targets the second. This is lesson 8 of the API design series. Lesson 7 wrote the REST contract in OpenAPI. Here we look at two other ways of carrying a contract. For deeper reading from the interview side, see Backend lesson 9 (REST vs GraphQL vs Hasura) and Full-Stack lesson 29 (GraphQL beyond Hasura). The next lesson, lesson 9, is the full checklist and interview Q&A for the whole series.
What I ran / checked. All code ran on Linux with Node.js v22.22.0 with these npm packages: graphql 17.0.2 (the reference JavaScript implementation, the current release when I installed it), dataloader 2.2.3, @grpc/grpc-js 1.14.6, @grpc/proto-loader 0.8.1, and protobufjs 7.6.6 (installed by proto-loader). I pasted real output. Sources I read: the GraphQL specification text, execution section (from graphql/graphql-spec on GitHub, main branch); the GraphQL over HTTP specification (from graphql/graphql-over-http, main branch, which is a draft work in progress); the OWASP GraphQL Cheat Sheet; the gRPC status codes page (grpc/grpc repository, doc/statuscodes.md); the gRPC over HTTP/2 protocol document (doc/PROTOCOL-HTTP2.md). Could not reach: graphql.org, grpc.io and protobuf.dev documentation sites (blocked), so what I say about protobuf encoding comes from running it. Not run: gRPC-Web, a gRPC-to-REST gateway, Apollo Federation, and any production GraphQL server library; I used graphql-js directly with plain Node http. I did not verify any GitHub star counts.
Words used in this lesson
- GraphQL is a query language for APIs and a runtime to run those queries. There is usually one URL (
/graphql). - Schema is the typed description of everything a GraphQL server offers: types, fields, and the entry points.
- Query reads data. Mutation changes data. Subscription streams updates.
- Resolver is a function that produces the value of one field.
- Over-fetching means getting more data than you need. Under-fetching means one call is not enough, so you make more calls.
- N+1 problem means one query to get a list, then N more queries, one per item, to get something related.
- Batching means collecting many small lookups into one bigger lookup.
- DataLoader is a small library that batches and caches lookups within one request.
- Introspection is a GraphQL feature that lets a client ask the server for its own schema.
- gRPC is a framework for remote procedure calls (RPC) over HTTP/2, usually with Protocol Buffers.
- Protocol Buffers (protobuf) is a compact binary format, with messages defined in
.protofiles. - RPC means calling a function on another machine as if it were local.
- Deadline is a time by which a call must finish, set by the caller.
- Streaming means sending a sequence of messages in one call instead of one request and one response.
Part 1: GraphQL
The idea
In REST you have many URLs and each returns a fixed shape. In GraphQL there is typically one endpoint and the client writes a query listing the fields it wants. The server returns exactly that shape. The API is described by a schema with types, and the server checks every query against it before running anything.
A schema for a small orders API:
type Order { id: ID! status: String! totalCents: Int! items: [Item!]! }type Item { sku: String! quantity: Int! }input ItemInput { sku: String! quantity: Int! }type Query { order(id: ID!): Order }type Mutation { createOrder(items: [ItemInput!]!): Order! }The ! means "never null". [Item!]! is a list that is not null and whose items are not null. We will see why this matters soon.
I ran several operations against this schema with graphql-js. Each output line is the JSON the server returned.
only what the screen needs: {"data":{"order":{"status":"paid","totalCents":4200}}}The query was { order(id: "o1") { status totalCents } }. The server returned only those two fields, even though the Order type has more. This kills over-fetching: a mobile screen that only needs a status does not download the rest.
with nested items: {"data":{"order":{"id":"o1","items":[{"sku":"A1","quantity":2},{"sku":"B2","quantity":1}]}}}The query asked for the order and its items, nested, in one request. In REST this would be GET /orders/o1 plus GET /orders/o1/items, or a custom endpoint. That kills under-fetching.
missing order (nullable field): {"data":{"order":null}}mutation with variables: {"data":{"createOrder":{"id":"o2","status":"pending","totalCents":100}}}wrong variable type: {"errors":[{"message":"Variable \"$items\" has invalid value at [0].quantity: Int cannot represent non-integer value: \"five\"","locations":[{"line":1,"column":10}]}]}two things, one request, aliases: {"data":{"a":{"status":"paid"},"b":null}}Four more things to notice:
- A missing order is just
null. Becauseordercan be null in the schema (no!), this is not an error. - A mutation is the way to change data. It uses variables (
$items) so the query text stays fixed and the data travels separately. That is the GraphQL version of parameterised queries: never build query strings by joining user input. - The wrong type (
"five"for an integer) was rejected by the schema before any of my code ran, with a message that points at the exact place. You get this validation for free, the same idea as validation in the OpenAPI lesson. - Aliases (
a:andb:) let you ask for the same field twice in one request under different names.
Notice also that GraphQL has no PUT, PATCH or DELETE and no URL per resource. The operation names are your API, so you design them like function names: createOrder, cancelOrder, addItemToOrder. Many teams find mutations named after business actions clearer than REST's generic verbs. The lesson 0 rules for naming still apply in spirit: be consistent and predictable.
How GraphQL travels over HTTP
GraphQL is not tied to HTTP, but almost everyone uses it. The GraphQL over HTTP specification (a draft I read on its main branch) says a server must accept a POST with a JSON body and Content-Type: application/json. The body holds query, and optionally variables and operationName. The server may also support GET for queries (never for mutations). In the draft, the media type for responses is application/graphql-response+json, and the client should send an Accept header that lists it.
A basic call looks like this:
POST /graphql HTTP/1.1Content-Type: application/jsonAccept: application/graphql-response+json, application/json;q=0.9{"query": "query($id: ID!) { order(id: $id) { status } }", "variables": {"id": "o1"}}Status codes are where GraphQL surprised many REST developers for years: older servers answered 200 to almost everything and put failures inside the body. The draft spec says something more careful, for the new media type:
- If the request is not valid GraphQL (bad JSON, a query that cannot be parsed, a query that fails validation), the server should answer with a
4xxcode. The draft names400for parse errors and422for validation failures. - If execution produced
datawith no errors:200. - If execution produced both
dataanderrors(a partial result): the draft recommends294, a special code, and says2xxmust be used wheneverdatais not null. I note that294is an unusual code in the draft; real servers today mostly send200. Check what your server library does before you write client code that relies on it. - Authentication and permission problems should use
401or403.
So: do not assume 200 means success in GraphQL. Always look at the errors array.
Errors and nullability, with real output
GraphQL can return partial results. If one field fails and the others succeed, you get both data and an errors list. I made a resolver for author throw an error for the first post:
const schema = buildSchema(`type Author { name: String! } type Post { title: String! author: Author } type Query { posts: [Post!]! }`);const root = { posts: () => [ { title: "ok", author: () => { throw new Error("author service down"); } }, { title: "fine", author: { name: "Ann" } } ] };{"errors":[{"message":"author service down","locations":[{"line":1,"column":17}],"path":["posts",0,"author"]}], "data":{"posts":[{"title":"ok","author":null},{"title":"fine","author":{"name":"Ann"}}]}}Here author is nullable (Author, no !), so the error is contained: that post's author is null, the other post is fine, and the error has a path telling you exactly where it happened.
Now make author non-null (author: Author!), as in the first schema I used for the N+1 demo below, and the same failure gives:
{"errors":[{"message":"author service down","locations":[{"line":1,"column":17}],"path":["posts",0,"author"]}],"data":null}The whole data became null. This is the spec's rule: when a non-null position raises an error, the error propagates up to the nearest nullable parent. Here posts is [Post!]!, non-null all the way up, so there was nothing nullable to catch it and the entire result was lost. I confirmed the rule in the execution section of the spec.
Non-null (!) is a promise you must keep. It feels tidy to mark everything !. But then one failing field can null out a whole screen. A good habit: make fields nullable when they come from another service or can fail on their own, and use ! for things that are always there, like an id.
The N+1 problem: GraphQL's famous trap
Because each field has its own resolver, it is easy to write code that runs a database query inside each resolver. Take this query: "list posts, and each post's author name".
{ posts { title author { name } } }The server runs one query to get the posts, then calls the author resolver once per post, and each call does its own lookup. For 10 posts that is 1 + 10 = 11 queries. For 100 posts, 101. This is the N+1 problem. REST has it too (looping over a list and fetching a detail for each), but GraphQL makes it very easy to hit by accident, because the client controls the shape and the resolvers are written separately.
I built a fake database that records every call. Naive resolvers first:
const db = { async allPosts() { queries.push("SELECT * FROM posts"); return posts; }, async authorById(id) { queries.push(`SELECT * FROM authors WHERE id = ${id}`); return authors.find(a => a.id === id); }, async authorsByIds(ids) { queries.push(`SELECT * FROM authors WHERE id IN (${ids.join(", ")})`); return ids.map(id => authors.find(a => a.id === id)); },};// naive: one author query per postconst naiveRoot = { posts: () => db.allPosts().then(ps => ps.map(p => ({ ...p, author: () => db.authorById(p.authorId) }))) };Result for 10 posts and 5 distinct authors:
NAIVE : db calls = 11SELECT * FROM postsSELECT * FROM authors WHERE id = 1SELECT * FROM authors WHERE id = 2SELECT * FROM authors WHERE id = 3 ...first result: {"title":"Post 1","author":{"name":"Author 1"}}Eleven database calls, even though there are only five authors (the naive version even asked for each author twice).
The fix is batching. Instead of fetching an author right away, the resolver says "I will need author 3" and a helper collects all such requests made during the same tick of the event loop, then asks the database once. The standard helper is DataLoader (from Facebook, available on npm as dataloader). You give it a batch function that takes a list of ids and returns the results in the same order. It also removes duplicate ids and caches results for the duration of one request.
import DataLoader from "dataloader";const authorLoader = new DataLoader(ids => db.authorsByIds([...ids]));const fixedRoot = { posts: () => db.allPosts().then(ps => ps.map(p => ({ ...p, author: () => authorLoader.load(p.authorId) }))) };BATCHED: db calls = 2SELECT * FROM postsSELECT * FROM authors WHERE id IN (1, 2, 3, 4, 5)same result: {"title":"Post 1","author":{"name":"Author 1"}}Eleven calls became two, the result is identical, and the five authors were fetched once each. At 1,000 posts the difference is 1,001 queries against 2.
Rules for DataLoader that avoid real bugs:
- Create a new loader per request. Do not make it a global. Its cache would then leak data between users (a security bug) and serve stale data. Put loaders in the per-request context.
- The batch function must return an array of the same length and order as the input ids.
- Batching only helps for lookups of the same kind. It does not replace a good database design, indexes or a SQL
JOIN.
(The same N+1 pattern appears in ORMs. Backend lesson 8 shows it with Prisma.)
Danger: the client chooses the cost
In REST, the server decides how much work each endpoint does. In GraphQL, the client builds the query, so the client can ask for something enormously expensive. A schema where User has friends: [User!]! allows this:
{ user(id: 1) { friends { friends { friends { friends { name } } } } } }Each level multiplies the work. I made a toy server where every friends resolver returns 3 users and counts calls. A query nested 9 levels deep with no protection:
no rule, depth 9: resolver calls = 9841That is 9,841 resolver calls from a query of about 130 characters (1 + 3 + 9 + ... + 3^8 adds up to 9,841). Make it 20 levels deep and the same arithmetic gives about 1.7 billion calls. This is a denial of service, and it comes in a single request, so ordinary request-count rate limits (lesson 5) do not see it.
The OWASP GraphQL cheat sheet lists the defences. In my words:
- Limit query depth. Reject queries nested deeper than a fixed number.
- Limit amounts. Cap list sizes (a
firstargument with a maximum) and use pagination (lesson 3). - Query cost analysis. Give fields a cost and reject queries above a budget. The cheat sheet adds a useful warning: before spending a lot of time on this, try to break your own staging API and see if you need it.
- Timeouts on execution and on database calls.
- Rate limit by cost, not just by HTTP request count, since one request may hold many operations. The cheat sheet says HTTP request counts alone do not account for expensive queries or multiple operations in one request.
- Limit batching. GraphQL allows many operations (or many aliases) in one HTTP request. An attacker can try thousands of passwords or OTP codes in a single request, avoiding per-request limits. The cheat sheet suggests limiting the number of operations per request, and not allowing batching for sensitive actions.
- Turn off introspection and GraphiQL in production if the API is not meant to be explored publicly. Introspection lets anyone download your full schema.
- Authorization still applies to every field. The schema is not a permission system. A resolver that returns
user.emailmust check who is asking. (See lesson 6: BOLA and property-level bugs are even easier to introduce in GraphQL, because every field is a door.)
Here is a depth limit as a GraphQL validation rule, so it runs before any resolver. This is real code I ran with graphql-js:
import { validate, specifiedRules, GraphQLError } from "graphql";const MAX = 4;const depthLimit = (context) => { let d = 0; return { Field: { enter(node) { if (node.selectionSet && ++d > MAX) context.reportError(new GraphQLError(`Query is too deep (max ${MAX}).`, { nodes: node })); }, leave(node) { if (node.selectionSet) d--; } } }; };const errs = validate(schema, parse(body.query), [...specifiedRules, depthLimit]);if (errs.length) { res.writeHead(422, { "content-type": "application/graphql-response+json" }); return res.end(JSON.stringify({ errors: errs.map(e => ({ message: e.message })) })); }I put that in a small HTTP server and sent three queries:
simple 200 resolver calls: 0 {"data":{"hello":"world"}}depth 2 200 resolver calls: 4 {"data":{"user":{"friends":[{"friends":[{"name":"U3"},{"name":"U4"},{"name":"U5"}]},{"friedepth 4 (too deep) 422 resolver calls: 0 {"errors":[{"message":"Query is too deep (max 4)."}]}The too-deep query was refused with 422 (the code the draft suggests for validation failures) and zero resolver calls. (Counting note: my rule counts every field that has a sub-selection, including user, so "depth 4" in my helper means five levels of nesting in the query.) A depth limit is blunt, since a shallow query can still be costly if it asks for 10,000 items, so combine it with the other defences.
Caching is harder
REST gets a lot of free help from HTTP: a GET /orders/1 can be cached by browsers, CDNs and proxies using the URL as the key and headers like ETag and Cache-Control. A typical GraphQL call is a POST to one URL with a different body each time. Proxies do not cache POST by default, and the URL says nothing about the data.
Ways to deal with it: use client-side normalised caches (libraries such as Apollo Client and urql keep a cache keyed by object id), use persistent queries (the client sends a short hash of a pre-registered query, which can then be a cacheable GET), and cache at the resolver level on the server. It works, but it is more work than "put a CDN in front", and that is a real cost. The REST-versus-GraphQL discussion in Backend lesson 9 goes further.
Evolving a GraphQL schema
GraphQL tends to avoid version numbers (/v2). Instead the schema grows. Adding a field or type is safe, since old clients never ask for it. For removal you mark a field with the @deprecated(reason: "...") directive, watch usage, and remove it once no client asks for it. That fits the idea from lesson 4: adding is safe, removing is breaking, so make removal visible and slow. The catch is that you need usage analytics per field, otherwise you cannot know when removal is safe.
When GraphQL fits
Good fit:
- Many different clients (web, iOS, Android, partners) with different data needs, from the same backend.
- Deeply connected data (social graphs, product catalogues) where screens need data from several related types.
- Frontend teams that want to move fast without waiting for new endpoints.
- A gateway over many backend services, which gives clients one schema (this is the idea behind federation).
Poor fit:
- Simple CRUD with one client. REST is easier to build, cache and secure.
- File upload and download, long-running streams and anything that is not "fetch a tree of JSON". (Possible, awkward.)
- Teams with no capacity for the extra work: query cost control, per-field authorisation, batching and monitoring.
- Public APIs where you cannot control or trust the clients' queries.
Part 2: gRPC
The idea
gRPC is a way for programs to call functions in other programs, across the network. You write a .proto file that defines the messages and the service (the callable methods). Tools generate client and server code in many languages from that one file. The wire format is Protocol Buffers (a compact binary format), and the transport is HTTP/2.
The .proto file for our orders service:
syntax = "proto3";package orders.v1;service Orders { rpc GetOrder (GetOrderRequest) returns (Order); rpc WatchOrders (WatchRequest) returns (stream Order);}message GetOrderRequest { string id = 1; }message WatchRequest { int32 count = 1; }message Order { string id = 1; string status = 2; int64 total_cents = 3;}Read it like this: there is a service Orders with two methods. GetOrder takes one request and returns one Order. WatchOrders takes one request and returns a stream of Order messages, sent over time. The = 1, = 2, = 3 are field numbers. They matter a lot, as we will see in a moment.
Running it
I started a real gRPC server and client in one Node script, using @grpc/grpc-js and @grpc/proto-loader. Here is the server's implementation of the two methods:
server.addService(pkg.Orders.service, { GetOrder(call, cb) { const id = call.request.id; if (!id) return cb({ code: grpc.status.INVALID_ARGUMENT, message: "id is required" }); if (id === "slow") return setTimeout(() => cb(null, store.ord_1), 500); const o = store[id]; if (!o) return cb({ code: grpc.status.NOT_FOUND, message: `order ${id} not found` }); cb(null, o); }, WatchOrders(call) { let i = 0; const t = setInterval(() => { i++; call.write({ id: "ord_" + i, status: "pending", total_cents: i * 100 }); if (i >= call.request.count) { clearInterval(t); call.end(); } }, 50); },});And the client calls it five ways. Real output:
ok : { id: 'ord_1', status: 'paid', total_cents: '4200' }not found: { code: 5, name: 'NOT_FOUND', message: 'order ord_9 not found' }bad input: { code: 3, name: 'INVALID_ARGUMENT', message: 'id is required' }deadline : { code: 4, name: 'DEADLINE_EXCEEDED', message: 'Deadline exceeded after 0.101s,LB pick: 0.001s,remote_addr=127.0.0.1:36855'}stream : got { id: 'ord_1', status: 'pending', total_cents: '100' } got { id: 'ord_2', status: 'pending', total_cents: '200' } got { id: 'ord_3', status: 'pending', total_cents: '300' }Notice:
- A call looks like a function call:
client.GetOrder({ id: "ord_1" }, callback). The types come from the.protofile. total_centscame back as the string'4200'. That is because I set the loader optionlongs: String: anint64can be larger than JavaScript's safe integer range, so the loader has to choose a representation (string, number or a Long object). It is a classic gotcha when using 64-bit numbers in JavaScript. (JSON APIs have the same problem with big numbers.)- Errors are not HTTP statuses. gRPC has its own status codes, a fixed list of 17 numbers (0 to 16). The gRPC docs page I read lists them, and I used
NOT_FOUND(5) andINVALID_ARGUMENT(3). - The deadline was set by the client (
Date.now() + 100). The server took 500 ms, so the client gave up at about 0.101 seconds withDEADLINE_EXCEEDED(4). Deadlines are central in gRPC: they travel with the request (in thegrpc-timeoutheader, I checked the protocol doc), so a slow call chain across five services can be cut off everywhere, not just at the first. - The stream delivered three messages over time on one call. No polling, no WebSocket code.
gRPC status codes you will actually use
From the gRPC status codes page. The mapping to HTTP is my own teaching table, not an official one, so adjust it to your needs:
| gRPC code | Number | When | Closest HTTP idea |
|---|---|---|---|
| OK | 0 | Success | 200 |
| INVALID_ARGUMENT | 3 | The request is wrong whatever the state of the system | 400 |
| DEADLINE_EXCEEDED | 4 | The time limit passed (may have been done anyway for writes) | 504 |
| NOT_FOUND | 5 | The entity is not there | 404 |
| ALREADY_EXISTS | 6 | Creating something that exists | 409 |
| PERMISSION_DENIED | 7 | Authenticated but not allowed | 403 |
| RESOURCE_EXHAUSTED | 8 | Quota or rate limit hit | 429 |
| FAILED_PRECONDITION | 9 | System not in the needed state (for example deleting a non-empty folder) | 400 or 409 |
| UNIMPLEMENTED | 12 | Method does not exist | 501 |
| INTERNAL | 13 | Broken invariant, serious bug | 500 |
| UNAVAILABLE | 14 | Temporary, retry with backoff | 503 |
| UNAUTHENTICATED | 16 | No valid credentials | 401 |
Two distinctions from the page that people mix up: INVALID_ARGUMENT (the argument is bad no matter what) versus FAILED_PRECONDITION (the argument is fine but the system state is wrong). And UNAVAILABLE is the code that is typically safe to retry with backoff, though the page warns that it is not always safe to retry non-idempotent calls. That is the same idea as lesson 5, in new clothes.
Why it is small and fast: the wire format
I encoded one order with protobuf (using protobufjs, the library that proto-loader uses inside) and compared it with JSON:
protobuf bytes: 16 0a056f72645f3112047061696418e820JSON bytes : 48 {"id":"ord_1","status":"paid","totalCents":4200}decoded back : { id: 'ord_1', status: 'paid', totalCents: Long { low: 4200, high: 0, unsigned: false } }The same data takes 16 bytes in protobuf and 48 in JSON, a third of the size. Look at the hex to see why:
0a 05 6f72645f31is: field 1 (0a), length 5, the bytes oford_1.12 04 70616964is: field 2 (12), length 4, the bytes ofpaid.18 e820is: field 3 (18), then the number 4200 written as a variable-length integer.
There are no field names on the wire, only numbers. That is why the .proto file is the contract: without it the bytes are meaningless. This is also why you must never reuse or change a field number. Rules for evolving a .proto file safely (these are the standard protobuf rules, which I did not re-read from the official site):
- You may add new fields with new numbers. Old readers ignore them.
- Never change a field's number or type.
- Never reuse a number that was removed; mark it
reservedso nobody does. - Do not make fields "required"; proto3 does not have it, and that is on purpose.
Notice the JSON line used totalCents rather than total_cents: protobufjs converts names to camelCase by default (I first passed total_cents and it was silently ignored, so the first 13-byte result I got was wrong; the numbers above are from the corrected run). That is another gotcha: when a field seems to vanish, check the name conversion.
Size is not everything. The protocol document I read says each gRPC call is an HTTP/2 POST to a path like /orders.v1.Orders/GetOrder, with content type application/grpc (and the format, such as +proto), a grpc-timeout header for the deadline, and the final status delivered in HTTP/2 trailers (grpc-status). HTTP/2 lets many calls share one connection at the same time (multiplexing), and allows streaming in both directions.
gRPC trade-offs
Good:
- Strict contract and generated code in many languages. No hand-written clients, no guessing a field's type.
- Efficient on bandwidth and CPU.
- Streaming built in (server, client, or both directions).
- Deadlines and cancellation are part of the protocol.
- Great for service-to-service calls inside your system.
Costs:
- Browsers cannot call gRPC directly. The browser APIs do not give access to HTTP/2 trailers and framing. The usual answer is gRPC-Web with a proxy, or a gateway that exposes REST/JSON in front of gRPC. I did not run either here, so look them up before choosing.
- Hard to debug by eye. You cannot
curla JSON body and read the answer. You need tools that understand protobuf (grpcurl, or a client that loads the.proto). - Load balancers and proxies need HTTP/2 support. Connections are long-lived, which can spread load unevenly if you balance per connection.
- Not a public-API default. Outside developers expect JSON over HTTP, a docs page and a curl command.
- Schema discipline is required (field numbers,
reserved). Mistakes break clients silently, as the bytes still parse but mean different things.
gRPC security is still on you. TLS between services, authentication metadata (tokens in call metadata, the equivalent of headers) and per-method authorisation all need design, just as in lesson 6. My demo used an insecure local connection because it is a lab on one machine. Never do that on a real network.
Choosing: REST, GraphQL or gRPC
| Question | REST | GraphQL | gRPC |
|---|---|---|---|
| Best for | Public APIs, CRUD, simple integrations | Many clients with varied data needs, connected data | Internal service-to-service calls, streaming |
| Contract | OpenAPI (optional but recommended) | The schema (built in, always) | .proto file (built in, always) |
| Wire format | JSON (text) | JSON (text) | Protobuf (binary) |
| Transport | HTTP/1.1 or 2 | Usually HTTP POST | HTTP/2 only |
| Browser support | Native | Native | Needs gRPC-Web or a gateway |
| Caching | Excellent, via HTTP | Harder (POST, one URL) | Not HTTP caching; do it in the app |
| Over/under-fetching | Common, fixed by design of endpoints | Solved by the client query | Solved by defining messages per method |
| Biggest risk | Many round trips, endpoint sprawl | N+1, expensive queries, per-field authorisation | Tooling, debugging, browser access |
| Error model | HTTP status + problem details | 200/4xx plus an errors array | gRPC status codes |
| Streaming | SSE, WebSocket, polling (extra) | Subscriptions (extra) | First-class |
| Learning curve | Lowest | Medium | Medium |
My rule of thumb:
- Start with REST for anything public or for a simple product. Good OpenAPI docs, caching and curl are hard to beat.
- Add GraphQL when you have several kinds of clients fighting over the shape of responses, and you have the team to run it (batching, cost limits, field-level auth). A common pattern is GraphQL at the edge for apps, with other protocols behind it.
- Use gRPC between your own services where speed, strict types and streaming matter. Expose REST or GraphQL to the outside.
- Mixing is normal. A phone app talks GraphQL to a gateway; the gateway talks gRPC to services; partners get a REST API with webhooks. Each part uses the tool that fits it.
A last honest point: none of these fixes a bad design. Poor naming, unclear errors, missing idempotency (lesson 5), no authorisation (lesson 6) and no docs make any of them painful. Most of this series applies to all three.
Try it
- In the N+1 demo, change the data so there are 100 posts and 5 authors. How many database calls does the naive version make, and how many does the DataLoader version make?
- Write a GraphQL type where
Post.authoris nullable and one where it is non-null. Make the author resolver throw. Describe what the client gets in each case. - Your GraphQL API allows
users(first: Int)with no maximum. What query can an attacker send, and what is the smallest fix? - A
.protofile hasstring email = 3;. A developer wants to delete the field and later addstring phone = 3;. Why is this dangerous, and what should they do? - Choose REST, GraphQL or gRPC for each: (a) a public API for payment partners, (b) a mobile app and a web app that show the same data differently, (c) a pricing service called 5,000 times a second by internal services.
Answers
- Naive: 1 + 100 = 101 calls. DataLoader: 2 calls (one for posts, one
IN (...)for the 5 distinct author ids). - Nullable: the post's
authorisnull, other posts are intact, anderrorscontains an entry with apathsuch as["posts", 0, "author"]. Non-null: the error propagates to the nearest nullable parent. Ifpostsis also[Post!]!all the way up, the wholedataisnulland the client gets only the error. users(first: 100000000)forces a huge query. The smallest fix is a maximum onfirst(for example 100), checked in the resolver or a validation rule, plus pagination. Also add a depth limit and a timeout.- Old messages in storage or in flight that still contain the old field 3 (email bytes) would be read as
phone, so a stranger's email could appear as a phone number. They should mark the old numberreserved 3;(and the old name), and givephonea new number like 4. - (a) REST with OpenAPI and webhooks, because partners expect JSON, curl and clear docs. (b) GraphQL is a good candidate, since two clients need different shapes of the same data; REST with a well-designed
fieldsparameter could also work. (c) gRPC, for speed, strict types and deadlines inside your network.
Interview questions
What problems does GraphQL solve compared with REST? (Must know)
Over-fetching and under-fetching. The client states the fields it needs and gets exactly that shape, including nested related data, in one request. That helps when several clients need different data from the same backend. It also gives a strongly typed schema that is validated before any code runs. It does not remove complexity; it moves it to the server, where I must handle batching, query cost and field-level authorisation.
What is the N+1 problem and how do you fix it in GraphQL? (Must know)
N+1 happens when I run one query for a list, then one more query per item for related data. In GraphQL it appears when each field's resolver does its own lookup. In my test, 10 posts caused 11 database calls. I fixed it with DataLoader, which collects the ids requested in one tick, deduplicates them, and calls the database once with IN (...): 2 calls. I create a new loader per request so the cache cannot leak data between users.
Why is it dangerous to let clients write arbitrary GraphQL queries? (Senior signal)
Because the client controls the cost. A deeply nested query on a self-referencing type grows exponentially. In my toy server, depth 9 caused 9,841 resolver calls from one short query. Also, aliases and batched operations let an attacker do thousands of actions in one HTTP request and bypass per-request rate limits. I defend with depth limits, size limits on lists, cost analysis, timeouts, cost-based rate limiting, limits on operations per request, and disabling introspection when the API is private.
What does 200 OK mean in a GraphQL response? (Trap)
On older servers, almost nothing: failures are reported in the body's errors array, often with 200. A client must check errors. The newer GraphQL over HTTP draft asks for 4xx when the request is invalid or cannot be run (such as 400 for a parse error and 422 for validation), and describes a special code for partial success. Real servers differ, so I read my server's behaviour and write the client to look at the body anyway.
How do nullable and non-null fields affect errors? (Senior signal)
When a field fails, GraphQL sets it to null if the field is nullable and records an error with a path. If the field is non-null, the error propagates to the nearest nullable parent, and in the worst case the entire data becomes null. I saw this with an author resolver that threw. So I make fields from other services or failure-prone sources nullable, and use non-null only for things that are always present.
Why is caching harder in GraphQL than in REST? (Must know)
REST uses URLs and GET, so browsers, proxies and CDNs can cache by URL and respect ETag and Cache-Control. GraphQL usually sends POST to one URL with the query in the body, so HTTP caches cannot help. I use client-side normalised caches (keyed by object id), persisted queries that turn a hash into a cacheable GET, and caching at the resolver or data layer.
What is gRPC and when would you choose it? (Must know)
gRPC is an RPC framework on HTTP/2 using Protocol Buffers. I define services and messages in a .proto file and generate typed clients and servers. I choose it for internal service-to-service traffic, where I want a strict contract, small messages, streaming and deadlines. I avoid it for public APIs and browser-facing calls without a gateway, since browsers cannot use it directly and outside developers expect JSON.
Why are protobuf messages small, and what rule does that create? (Senior signal)
They carry numeric field tags and values, not names. In my test, an order took 16 bytes in protobuf and 48 in JSON. Because the bytes mean nothing without the .proto, the field numbers are the real contract. I may add new fields with new numbers, but I must never change a number or type, and I must reserve the numbers of deleted fields so nobody reuses them.
What is a deadline in gRPC and why is it better than a timeout? (Senior signal)
A deadline is an absolute time by which the call must finish. The client sets it, and it is sent with the request so every service in a chain knows how much time is left. A plain timeout at each hop can leave the last service working long after the first caller gave up. In my test a call with a 100 ms deadline to a 500 ms handler returned DEADLINE_EXCEEDED after about 0.101 seconds. For writes I must remember the work may have completed anyway, so idempotency (lesson 5) still matters.
How do gRPC errors differ from HTTP errors? (Must know)
gRPC has its own small set of status codes (0 to 16) plus a message, delivered in HTTP/2 trailers, while the HTTP status is normally 200. Codes carry more precise meaning than HTTP, such as FAILED_PRECONDITION versus INVALID_ARGUMENT, and UNAVAILABLE for retryable conditions. I map them to HTTP codes when I expose a REST gateway in front.
A team wants to rewrite a working REST API in GraphQL "because it is modern". What do you ask? (Senior signal)
I ask which concrete problem they have: many round trips, clients needing very different shapes, slow front-end teams waiting on endpoints. If they have none, the rewrite adds cost: batching, query cost control, field-level authorisation, harder caching and new monitoring. If they do have the problem, I suggest trying to solve it first with better REST (field selection, embedding with an expand parameter, a backend-for-frontend), or adding GraphQL as a gateway in front of the existing API instead of rewriting it.
Can a browser call a gRPC service? (Trap)
Not directly. Browser APIs do not give a page the HTTP/2 trailers and framing control that gRPC needs. People use gRPC-Web with a proxy that translates, or put a gateway in front that exposes JSON over HTTP. I did not run these myself in this lesson, so for a real project I would test the exact proxy and client library first.
Key takeaways
- GraphQL: one typed schema, the client picks the fields, one request can fetch nested data. Always check
errors; do not trust200alone. - N+1 is the classic GraphQL trap. Batch with DataLoader: 11 database calls became 2. Make a new loader per request.
- The client controls the cost. Add depth limits, list-size limits, cost analysis, timeouts, cost-based rate limits, and field-level authorisation. A 9-level query caused 9,841 resolver calls.
- Nullability decides how far an error spreads. Be careful with
!. - gRPC: a
.protocontract, generated code, HTTP/2, binary messages (16 bytes against 48), streaming, deadlines and its own status codes. Field numbers are the contract. - gRPC suits internal services; browsers and public APIs need a gateway or a different style.
- Start with REST, use GraphQL when client needs diverge, use gRPC inside the system. Mixing is normal. None of them fixes poor design.
What's next
You have now seen the whole toolbox: resources, methods, errors, pagination, versioning, retries, webhooks, rate limits, auth, OpenAPI, GraphQL and gRPC. The last lesson pulls it into one place. Lesson 9: API Design Interview Questions and Review Checklist gives you a checklist you can use to review any API in ten minutes, a practice design problem, and more than fifteen interview questions with model answers. Practise now: pick an API you use daily and note which of the three styles it uses and why you think the team chose it.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- REST vs GraphQL (vs Hasura): How to Choose · Backend in Depth
- Backend and API Scenario Bank · Scenario Debugging
- GraphQL Beyond Hasura · Full-Stack Questions
Part of the study guide
Related series