Home/Blog

REST vs GraphQL: How to Choose the Right API Design

REST vs GraphQL for production APIs: caching, N+1 queries, versioning, errors and security, with a decision table and the failure modes each one brings.

REST vs GraphQL: How to Choose the Right API Design

Choose REST when your API is public, your data maps cleanly to resources, and HTTP caching matters: it is simpler to build, cache, secure and operate. Choose GraphQL when several first-party clients with different data needs read from a deeply related data model, and you are willing to run the server-side discipline it requires: request-scoped batching, query cost limits, persisted operations and field-level authorization. If you own both ends and both are TypeScript, tRPC is often simpler than either, and gRPC remains the default for internal service-to-service calls. Most production systems end up running two of these, one per audience.

Key takeaways

  • REST puts the server in charge of response shape; GraphQL hands that to the client, and the server must then defend itself with cost limits and allowlists.
  • HTTP caching works out of the box with REST. With GraphQL you rebuild it with persisted queries over GET and a normalized client cache.
  • The N+1 problem is a data-loading bug in both styles. GraphQL just makes it the default unless every resolver goes through a request-scoped batch loader.
  • Version REST by evolving additively and reserving URL versions for real breaks. Version GraphQL by deprecating fields and measuring usage before removal.
  • The biggest production risk in either style is the same: an endpoint or resolver that returns an object without checking that the caller may see it.

The core difference in one example

A REST API models resources, each with its own URL and HTTP methods:

GET    /v1/posts                 list posts
GET    /v1/posts/123             one post
POST   /v1/posts                 create a post
PATCH  /v1/posts/123             partial update
DELETE /v1/posts/123             delete
GET    /v1/posts/123/comments    comments for a post

A GraphQL API exposes one endpoint and a typed schema. The client sends a document describing exactly the fields it wants:

query PostPage($id: ID!) {
  post(id: $id) {
    title
    body
    author {
      name
      avatarUrl
    }
    comments(first: 10) {
      edges {
        node {
          body
          createdAt
          author { name }
        }
      }
    }
  }
}

The mechanism matters more than the syntax. In REST the server decides what a response contains, so a screen that needs a post, its author and its comments either makes three calls (under-fetching) or calls an endpoint that returns more than it needs (over-fetching). In GraphQL the screen gets exactly that shape in one round trip, but the server has handed query construction to the client. Every operational problem GraphQL has follows from that handoff: unpredictable query cost, harder caching, and authorization that has to hold at field level, not route level.

REST, GraphQL, tRPC or gRPC: the decision table

FactorRESTGraphQLtRPCgRPC
Best audiencePublic and partner APIs, simple CRUDFirst-party web and mobile apps with varied screensOne TypeScript frontend and backend you ownInternal service-to-service calls
ContractOpenAPI documentSchema (SDL)Your router's TypeScript typesProtobuf .proto files
HTTP cachingNative (URLs, ETags, Cache-Control)Needs persisted queries over GETPossible on GET queries, rarely usedNot HTTP-cacheable
Response shapeFixed by serverChosen by clientFixed by serverFixed by .proto
VersioningAdditive changes, URL version for breaksDeprecate fields, remove after usage hits zeroDeploy both ends togetherField numbers, never reuse them
Error modelHTTP status plus problem details bodydata plus errors array, usually HTTP 200Typed errors over HTTP statusStatus codes plus details
Third-party friendlinessHighMediumLowLow for browsers
Operational costLowestHighestLowMedium (tooling, proxies)

A quick rule that holds up well:

  1. Public API for developers you do not know: REST with OpenAPI. They expect it, every language can call it, and you can rate-limit and cache per route.
  2. Two or more first-party clients whose screens pull from many related entities: GraphQL as a backend-for-frontend, behind persisted operations.
  3. One web app, one backend, both TypeScript, one team: tRPC or typed REST handlers. Do not adopt a schema language you do not need.
  4. Service-to-service inside your network: gRPC if you need streaming or strict contracts across languages, plain REST if you do not.

Designing a REST API that holds up

REST is easy to start and easy to do inconsistently. These are the decisions worth making once, in writing, before the second endpoint ships.

Resources, methods and status codes

Use nouns for resources and HTTP methods for verbs. PATCH for partial updates, PUT only when the client sends the full representation. Return 201 Created with a Location header for creates, 204 No Content for deletes, 409 Conflict for state conflicts, 422 Unprocessable Content for validation failures, and 429 Too Many Requests (RFC 6585) with a Retry-After header when you throttle. Actions that are not CRUD (refund, cancel, resend) are fine as sub-resources: POST /v1/orders/123/refunds is clearer than contorting a refund into a PATCH.

One error format: Problem Details

Pick a single error shape for every endpoint. RFC 9457, Problem Details for HTTP APIs, defines one with the media type application/problem+json:

{
  "type": "https://api.example.com/problems/insufficient-funds",
  "title": "Insufficient funds",
  "status": 422,
  "detail": "Balance is 30.00, the transfer requires 50.00.",
  "instance": "/v1/transfers/7f3c2a",
  "balance": "30.00"
}

Clients branch on type, humans read title and detail, and you can add extension members like balance. The value is consistency: one parser in every client instead of one per endpoint.

Pagination: use cursors, not offsets

LIMIT 50 OFFSET 10000 asks the database to walk past ten thousand rows on every request, and pages drift when rows are inserted while a user scrolls. Keyset (cursor) pagination avoids both:

-- First page
SELECT id, created_at, title
FROM posts
WHERE tenant_id = $1
ORDER BY created_at DESC, id DESC
LIMIT 50;
 
-- Next page: the opaque cursor encodes the last row's (created_at, id)
SELECT id, created_at, title
FROM posts
WHERE tenant_id = $1
  AND (created_at, id) < ($2, $3)
ORDER BY created_at DESC, id DESC
LIMIT 50;

Back it with an index on (tenant_id, created_at DESC, id DESC). Include id as a tiebreaker or rows with identical timestamps will be skipped or repeated. Return the cursor as an opaque base64 string so you can change its contents later without breaking clients.

Idempotency for writes that matter

Networks fail after the server commits and before the client hears back. The client retries and you charge the card twice. The fix is an idempotency key, the Idempotency-Key header convention used by Stripe and other payment APIs:

app.post("/v1/payments", async (req, res) => {
  const key = req.header("Idempotency-Key");
  if (!key) return res.status(400).json(problem("missing-idempotency-key"));
 
  const requestHash = sha256(JSON.stringify(req.body));
 
  // Insert first: the unique constraint on (account_id, key) serializes concurrent retries.
  const claim = await db.idempotency.tryInsert({
    accountId: req.user.accountId,
    key,
    requestHash,
    state: "in_progress",
  });
 
  if (!claim.inserted) {
    const prior = claim.existing;
    if (prior.requestHash !== requestHash) {
      return res.status(422).json(problem("idempotency-key-reused"));
    }
    if (prior.state === "in_progress") {
      return res.status(409).json(problem("request-in-progress"));
    }
    return res.status(prior.status).json(prior.body); // replay the stored result
  }
 
  const result = await payments.charge(req.user.accountId, req.body);
  await db.idempotency.complete(claim.id, { status: 201, body: result });
  return res.status(201).json(result);
});

Two details people miss: claim the key before doing the work (a read-then-write check races under concurrent retries), and reject a reused key with a different body instead of silently replaying.

Caching and conditional requests

REST's real advantage is that HTTP already knows how to cache it. Send an ETag, honor If-None-Match, and let browsers and CDNs do the rest:

GET /v1/products/42 HTTP/1.1
If-None-Match: "a7f3c9"
 
HTTP/1.1 304 Not Modified
ETag: "a7f3c9"
Cache-Control: public, max-age=60, stale-while-revalidate=300

For anything user-specific, send Cache-Control: private or no-store. A per-user response cached at a shared CDN is a data leak, not a performance win.

Versioning and the contract

Most changes do not need a new version. Adding a field, adding an optional parameter or adding an endpoint is backward compatible. Renaming, removing, or changing a type is not. Reserve /v2 for genuine breaks, announce removals with the Sunset header (RFC 8594), and keep the OpenAPI document as the source of truth: generate client types from it and run a breaking-change diff against the previous version in CI so a removed field fails review instead of failing a customer.

Designing a GraphQL API that holds up

GraphQL rewards up-front schema thinking and punishes resolvers written like REST handlers.

Schema design rules

  • Design for screens, not tables. A schema that mirrors your database exports your database's mistakes to every client.
  • Use connections for lists. The Relay-style edges, node and pageInfo shape gives you cursor pagination for free and is what client libraries expect.
  • Give mutations input and payload types. createPost(input: CreatePostInput!): CreatePostPayload! lets you add fields to both later without breaking anyone.
  • Model expected failures as data. A union like CreatePostResult = CreatePostSuccess | ValidationFailed | QuotaExceeded forces clients to handle them. Reserve the errors array for the unexpected.
  • Be deliberate about nullability. A non-null field that fails nulls out its parent, up to the nearest nullable ancestor. One flaky non-null field can blank a whole page.

Solve N+1 with request-scoped loaders

Resolvers run independently. Fetch 50 posts, resolve each author separately, and you issue 51 queries. DataLoader collects the keys requested during one tick of the event loop and resolves them with a single batched call:

import DataLoader from "dataloader";
 
export function createLoaders(db: Db) {
  return {
    userById: new DataLoader<string, User | null>(async (ids) => {
      const rows = await db.user.findMany({ where: { id: { in: [...ids] } } });
      const byId = new Map(rows.map((u) => [u.id, u]));
      // Must return results in the same order as the keys.
      return ids.map((id) => byId.get(id) ?? null);
    }),
  };
}
 
// Build loaders per request, inside the context factory. Never share them globally.
export const context = async ({ req }: { req: Request }) => ({
  user: await authenticate(req),
  loaders: createLoaders(db),
});

The 51 queries become 2. The comment matters: DataLoader caches per instance, so a module-level loader shares cached rows across users and across requests. That is both a stale-data bug and a data leak.

Cost limits, depth limits and persisted operations

A client can ask for posts(first: 100) { comments(first: 100) { author { posts(first: 100) ... } } }, and each level multiplies the work. Defend in layers:

ControlWhat it stopsNotes
Depth limitDeep recursive queriesCheap, coarse. Set it just above your deepest real screen
Cost analysisWide list fan-outWeight list fields by their first argument and reject above a budget
Alias and batch limitsBrute force hidden in one requestAliases let one request run the same mutation many times, which defeats per-request rate limits
Persisted operationsArbitrary queries from first-party clientsThe strongest control: the server only runs operations it has seen at build time
TimeoutsSlow resolvers holding connectionsSet at the resolver data layer, not only at the gateway

Persisted operations are worth the build-step cost for any GraphQL API that only serves your own apps. The client build extracts every operation, hashes it, and ships the manifest to the server. In production the server refuses anything else:

import manifest from "./persisted-operations.json"; // sha256 hash -> document
 
export function resolveDocument(body: { id?: string; query?: string }): string {
  if (body.id) {
    const doc = (manifest as Record<string, string>)[body.id];
    if (!doc) throw new HttpError(400, "Unknown operation id");
    return doc;
  }
  if (process.env.NODE_ENV === "production") {
    throw new HttpError(400, "Ad hoc queries are disabled. Send a persisted operation id.");
  }
  return body.query ?? "";
}

With an allowlist, query cost becomes a code review problem rather than a runtime one, and the operation id is short enough to send over GET, which brings CDN caching back.

Errors and partial results

A GraphQL response can contain both data and errors, and most servers answer HTTP 200 either way. One failed resolver nulls its field, the rest of the query succeeds, and your load balancer's 5xx graph stays flat while users stare at a broken widget. Log and count errors entries per operation name in the server, and alert on that, not on HTTP status.

Versioning by deprecation

GraphQL does not need /v2. Add new fields, mark the old ones @deprecated(reason: "Use priceV2"), and record field usage per client. Remove a field only when its usage is zero across every client version still in the wild. That last clause is what makes this slow: a mobile app version from a year ago still sends the old query.

Where tRPC and gRPC fit

tRPC gives a TypeScript client the server's types directly, with no schema language and no code generation:

import { z } from "zod";
import { router, protectedProcedure } from "./trpc";
 
export const postRouter = router({
  byId: protectedProcedure
    .input(z.object({ id: z.uuid() }))
    .query(({ input, ctx }) =>
      ctx.db.post.findFirst({ where: { id: input.id, tenantId: ctx.tenantId } }),
    ),
});

It is excellent inside a monorepo where web and server deploy together. It is a poor contract for anyone outside that repository, because the contract is your server code. Our TypeScript practices guide covers how to keep those shared types honest.

gRPC uses Protobuf contracts and HTTP/2, with code generation for most languages, and supports streaming in both directions:

syntax = "proto3";
 
service Inventory {
  rpc Reserve (ReserveRequest) returns (ReserveReply);
}
 
message ReserveRequest {
  string sku = 1;
  int32 quantity = 2;
}
 
message ReserveReply {
  string reservation_id = 1;
}

Its contract discipline (never reuse a field number) is one of the better versioning models in the industry. Browsers cannot call it directly without a proxy layer, so it lives behind your edge, between services. See our microservices patterns guide for where those boundaries usually go.

Security: different surface, same root risk

RiskRESTGraphQL
Object-level authorizationCheck ownership in every handlerCheck ownership in every resolver or a shared policy layer, including nested ones
Resource exhaustionRate limit per route and per clientRate limit by computed cost, not by request count
Schema discoveryOpenAPI may be public anywayIntrospection off in production reduces discovery, but it is not a control on its own
Mass assignmentValidate input against an explicit schemaInput types help, but resolvers still must not spread input into the database
Batching abuseRareAliases and array batching multiply one request

Broken object level authorization sits at the top of the OWASP API Security Top 10 for good reason. In GraphQL it hides in nested resolvers: the top-level post(id) checks ownership, but post.author.privateEmail was written later by someone who assumed the parent check covered it. Put authorization in the data access layer, where every path to a record has to pass through it. Our API security guide goes deeper on authentication, rate limiting and input validation for both styles.

What goes wrong in production

These are the incidents that show up after launch, not in the design review.

GraphQL

  • The dashboards lie. Every request is POST /graphql, so your APM shows one endpoint with a blended latency. Tag spans and metrics with the operation name from day one, or you will not find the slow query during an incident.
  • Errors return 200. Covered above, and worth repeating because it is the most common reason a GraphQL outage is reported by customers first.
  • One slow field holds the whole response. A query is as slow as its slowest resolver. Use @defer where your server and client support it, or move slow fields to a separate query.
  • A shared DataLoader leaks data. Loaders created at module scope cache one user's rows for the next user. Always construct them per request.
  • Old mobile clients pin the schema. You cannot remove a field until the app versions requesting it are gone, so track per-client field usage before you need it.

REST

  • Chatty clients build waterfalls. A screen that needs five calls, each depending on the previous, is slow on mobile regardless of server speed. Add a composite endpoint for that screen, or put a backend-for-frontend in front.
  • Version sprawl. /v1, /v2 and /v3 all alive, each with its own bugs. Breaking changes should be rare, and old versions need a published sunset date.
  • Offset pagination times out on big tables. It works in staging with a thousand rows and falls over in production with millions.
  • Retries without idempotency create duplicates. Duplicate orders and double charges come from client retries plus non-idempotent POSTs.
  • A missing Cache-Control: private puts one user's data in a shared cache. Audit the caching headers on every authenticated route.

In both styles, what saves you is per-operation latency and error metrics, request ids that travel through every service, and structured logs you can search by tenant. We cover that setup in observability in production.

The hybrid architecture most teams end up with

Loading diagram...

First-party apps get GraphQL shaped to their screens. Outside developers get a stable, cacheable REST API. Webhooks and file uploads stay plain HTTP (for uploads, hand out a pre-signed storage URL instead of streaming bytes through GraphQL). Internal services talk REST or gRPC. Each audience gets the style that fits it, and no one style is forced to do every job.

Frequently Asked Questions

Is GraphQL faster than REST?

Not inherently. GraphQL can cut round trips when a screen needs data from several related resources, which helps on slow mobile networks. It can also be slower, because every query is parsed, validated and resolved field by field, and responses are harder to cache at the CDN. Speed comes from the data access layer and caching strategy, not the API style.

Can you use REST and GraphQL together?

Yes, and most mature systems do. A common split is GraphQL as a backend-for-frontend for first-party apps, REST for the public API, webhooks and file uploads, and gRPC or REST between internal services. The rule is one style per audience, not one style per company.

Is GraphQL harder to secure than REST?

It has a larger surface. Clients can compose arbitrary queries, so you need depth and cost limits, persisted operations for first-party clients, and authorization on every field that exposes sensitive data. The core risk is the same in both: broken object level authorization, where an API returns a record the caller does not own.

When should you not use GraphQL?

Skip GraphQL when you have one or two clients with stable data needs, a public API for third-party developers, heavy reliance on CDN caching, or a team that has not run it before and has no time to learn its failure modes. In those cases a well-designed REST API with an OpenAPI contract is cheaper to build and operate.

Does GraphQL remove the need for API versioning?

It removes the need for URL versions, not for a deprecation process. You add fields freely, mark old ones with the deprecated directive, measure which clients still request them, and remove them only when usage reaches zero. Mobile apps that users never update are what make this slow.

Is tRPC a replacement for REST or GraphQL?

tRPC replaces them only when you own both ends and both are TypeScript, typically a web app and its backend in one monorepo. It gives end-to-end types without a schema language or code generation. It is a poor fit for public APIs, non-TypeScript clients or long-lived mobile apps, because the contract is your server code.

Need help designing your API?

We design APIs we then have to run, which tends to keep the designs honest. If you are choosing between REST and GraphQL, untangling a GraphQL layer that got expensive, or hardening an API before a launch, talk to the senior engineer who would do the work. If we build it, we are on the pager for it.