GraphQL vs REST: An API Design Playbook for Operators
Founder & CEO Insight — Enow A. Jovial
REST gives every resource its own address and fixed shape. GraphQL gives the client one endpoint and a schema it queries for exactly the fields it wants.
This playbook settles the choice with reproducible numbers: over-fetching in bytes, round trips in milliseconds, pagination in request counts, rate-limit budgets in requests per second. Authorities: the GraphQL specification (October 2021 edition), RFC 9110, RFC 9111, Apollo Server documentation, and Stripe API documentation.
1. The Contract: Schema Against Endpoints
REST organises an API as resources with addresses: a client that wants an order, its customer, and its stock level calls GET /orders/1042, then GET /customers/88, then GET /inventory/sku-441, each returning a fixed document the server owns.
GraphQL organises an API as a schema: the client sends one query naming the types and fields it wants, and the server returns a document shaped like the query. The October 2021 specification defines three operation types — query, mutation, subscription — and a type system published through introspection.
That flexibility has a price the demo never shows: a REST endpoint is a unit the team can cache, rate-limit, monitor, and version on its own, while a GraphQL endpoint is one address carrying every operation, so caching, limiting, and monitoring move inside the server, where the team must build them.
2. Over-Fetching and Under-Fetching, Measured in Bytes
Over-fetching is the REST tax every mobile team knows: the endpoint returns the full document, and the client uses three fields and discards the rest. Under-fetching is the opposite tax: the endpoint returns too little, so the client fires follow-up calls.
Example: a storefront order endpoint returns a 2,400-byte document per order. A mobile list screen needs three fields — order id, total, status — totalling 180 bytes:
At 10,000 orders a day, REST moves 24,000,000 bytes against 1,800,000 for a GraphQL query returning only the three fields — a saving of 22,200,000 bytes a day, about 22.2 MB, roughly 666 MB monthly. On metered connections and per-gigabyte egress billing, that waste is a line item.
3. Round Trips, Pagination, and Request Counts
Latency is set by round trips, not payload size: a serial chain of dependent REST calls multiplies round-trip time by the number of calls:
Example: at 120 ms round-trip time, three serial REST calls cost 360 ms before server time, while one GraphQL query costs 120 ms. With five nested resources the gap is 600 ms against 120 ms. That 480 ms gap is the difference between a screen that feels instant and one that feels broken, and no compression closes it — the delay is structural.
Pagination changes the count again, and REST has the cleaner public reference. Stripe list endpoints accept a limit between 1 and 100, defaulting to 10, and return a cursor (starting_after) and a has_more flag. Listing 10,000 customers costs:
Raising the page size from default to maximum cuts the request count tenfold. GraphQL has no equivalent built-in convention: the schema author picks a pagination style, and every client must learn it.
4. Versioning: Breaking Change Against Additive Change
REST versions by address or header. Stripe versions by header: clients send a Stripe-Version header, and old versions keep working while new ones ship — but every version is a surface the team must maintain, test, and retire.
GraphQL versions by addition. New fields join the schema, old fields are marked deprecated, and existing queries keep working. The discipline is the team's own: a field marked deprecated but never removed is a permanent liability, and a field renamed without a deprecation window is a breaking change wearing an additive costume.
Count the migration surface before choosing: a REST API with 14 endpoints and 3 clients carries 42 call sites to audit at a version change, while a GraphQL schema carries one schema to extend — but every deprecated field still needs a removal date, an owner, and a client census.
5. Caching: Where REST Is Structurally Ahead
Caching is the strongest structural argument for REST. RFC 9110 defines the method properties that make caching safe: GET, HEAD, OPTIONS, and TRACE are safe methods, defined as essentially read-only, which caches may replay safely. RFC 9111 governs HTTP caching and defines the Cache-Control directives — max-age, no-store, no-cache — that state how long a response may be reused.
A REST GET /products/441 is one address with one cache key, which a CDN can store, serve, and invalidate by URL:
Example: at a hit ratio h of 0.80 and 10,000 requests a day, the origin answers 2,000 requests instead of 10,000. With a 25 ms cache hit and a 220 ms origin trip, average latency is 0.80 × 25 + 0.20 × 220 = 64 ms, against 220 ms uncached — the whole case for REST in one formula: the address is the cache key, and the cache does the scaling.
GraphQL breaks that mapping by design: queries arrive as POST bodies to one endpoint, so the URL says nothing about the content. Apollo Server documentation answers with cache hints: authors attach maxAge and scope (PUBLIC or PRIVATE) to fields, and teams that want CDN caching send hashed queries by GET through automatic persisted queries (APQ) and useGETForHashedQueries. All of it is machinery the team must build, test, and operate.
maxAge values, PRIVATE responses, and the persisted-query route to the CDN by GET. If that document does not exist, the migration is not ready.6. Rate Limiting and the Request Budget
Rate limits turn request counts into money. Stripe documents a default limit of 100 requests per second in live mode and 25 per second in sandbox, with per-endpoint limits on top, and returns HTTP 429 with a Stripe-Rate-Limited-Reason header when a client exceeds them:
Example: a nightly sync reading 10,000 customers at the maximum page size needs 100 requests — 1 second of live budget. At the default page size of 10 it needs 1,000 requests, or 10 seconds. The page-size choice from section 3 is a rate-limit choice too, and the team that leaves limit at its default pays ten times the budget for the same data.
GraphQL concentrates the same problem into one endpoint, which makes per-URL limiting useless: one query can ask for 10 fields or 10,000 nested nodes, and both arrive as a single POST. Production GraphQL teams therefore limit by computed query cost — depth, breadth, node count — rejecting queries above a budget before execution. The team must write, tune, and defend that design against clients who split one expensive query into ten cheap ones.
7. Introspection, Security, and the Open Schema
GraphQL introspection makes the tooling work — and makes security teams pause. The specification states that a GraphQL service supports introspection over its schema, queried with GraphQL itself. Fields beginning with two underscores are reserved for introspection, and a query such as __type(name: "User") returns the fields of the User type. Any client that can reach the endpoint can ask the schema what it contains.
That openness is a gift in development and a disclosure in production: an exposed introspection endpoint hands an attacker the full field list, including deprecated fields, internal identifiers, and admin-only mutations the team forgot to gate. The mitigations are standard and must all be present before launch: disable introspection in production, require authentication, authorise per field, cap depth and complexity, and limit query size.
8. Errors: Partial Data Against Status Codes
REST errors speak HTTP: a missing resource is 404, a bad request is 400, a rate-limit breach is 429, a server failure is 500. RFC 9110 defines the status-code classes, and clients, caches, proxies, and monitoring tools all read the same signal.
GraphQL errors speak the response body: the specification provides that a field error produces a null at that position together with a field error added to the response, and a response may carry both data and errors at once. A query fetching ten fields can return nine values and one error, with a 200 status code. Clients must read the errors array — message, locations, path — instead of trusting the status code.
It is a trap for clients written the REST way, which treat 200 as success and never inspect the body. Every GraphQL client must handle partial data on day one, or the first field error becomes a silent wrong number.
9. The Decision Table
| Dimension | REST | GraphQL | Authority |
|---|---|---|---|
| Contract | Fixed documents | Client-selected fields | GraphQL spec, Oct 2021 |
| Over-fetching | Fixed shape; 92.5% waste | Client selects fields | Section 2 |
| Round trips | One per resource | One query collapses reads | Section 3 |
| Pagination | Cursor; limit 1–100, default 10 | Author must choose one | Stripe API docs |
| Versioning | Header or URL versions | Additive fields, deprecation | Stripe API docs |
| Caching | URL is cache key | Needs hints, APQ, GET queries | RFC 9110 §9.2; RFC 9111 §5.2 |
| Rate limiting | 100 req/s live, 25 req/s sandbox | Needs query-cost budgets | Stripe docs |
| Introspection | Audit the route table | Queryable; disable in production | GraphQL spec §4 |
| Errors | Status codes; 200 means success | 200 with partial data, errors array | RFC 9110 §15; spec §7 |
| Realtime | Polling or webhooks | Subscriptions; one root field | GraphQL spec §5 |
10. Who Should Avoid Each
GraphQL is wrong for a simple CRUD service with one client, uniform responses, and no bandwidth pressure: the schema, gateway, persisted queries, and cost analysis are a standing tax a two-person team pays for nothing. It is also wrong for a public, cacheable feed — catalogue, blog index, status page — where REST hands the CDN the cache key for free, and for a team with no one to own the gateway: an unowned GraphQL endpoint drifts into an unguarded one.
REST is wrong for a mobile app on metered connections where every client needs a different field set and every extra round trip costs a user, for a fast-moving surface where the frontend ships weekly and the backend cannot ship an endpoint per screen, and where the client count keeps growing — each new client is a new endpoint or parameter, and the endpoint list becomes the versioning problem from section 4.
11. Case Study: A Composite Storefront Operator
Case Study: a composite storefront operator, built from this playbook's arithmetic with stated assumptions, not a report on any real company. The operator serves 40,000 mobile sessions a day, each loading an order list of 20 orders. The REST document is 2,400 bytes per order; the screen needs 180.
Daily REST payload for the list alone is 40,000 × 20 × 2,400 = 1,920,000,000 bytes, about 1.92 GB a day; the GraphQL equivalent is 40,000 × 20 × 180 = 144,000,000 bytes, about 144 MB. The saving is about 1.78 GB a day, about 53 GB a month. At a stated 0.09 dollars per GB, that is about 4.80 dollars a month — small money, stated plainly. The real saving is latency: at 120 ms round trips, the REST list costs three serial calls (360 ms) against one GraphQL query (120 ms).
12. Execution Checklist
- List each client's fields per screen; compute the waste share from section 2 for the three heaviest screens.
- Map dependent read chains; count round trips at measured round-trip time; price them with the section 3 formula.
- Pick the pagination convention in writing before the first resolver or endpoint ships.
- Write the versioning policy: retirement dates for REST, deprecation windows for GraphQL.
- Write the caching design: keys and max-age for REST; hints and persisted queries for GraphQL.
- Set the rate-limit budget: requests per second, the 429 path, and the retry policy.
- Close the security surface: introspection off, fields gated, depth capped, outsider test passed.
- Ship the error contract: partial-data handling for GraphQL, status codes for REST, tested.
- [x] Waste measured: the three heaviest screens carry a computed share.
- [x] Round trips counted: every chain has a measured cost.
- [x] Cache designed: keys, ages, owners set before launch.
- [x] Limits budgeted: the busiest hour is covered; 429 handling tested.
- [x] Surface closed: introspection off; outsider test passed.
The choice between GraphQL and REST is a budgeting exercise, not a loyalty test. Measure the waste, count the round trips, price the cache, budget the limit — then let the cheaper surface own the traffic. Our API testing tooling prices the tools for either contract, our headless WordPress vs Next.js playbook shows the same two styles as content-delivery transports, and our LLM API pricing playbook prices metered APIs on the same request-count arithmetic. Run your totals through our ROI calculator before committing to either style.

