GraphQL vs REST: When to Choose Which
Understanding query languages, pagination strategies, and caching tradeoffs for senior interviews
Open interactive version (quiz + challenge)Real-world analogy
REST is like a restaurant with a fixed menu — you order dish #3 and get exactly what's listed. GraphQL is like ordering à la carte — you tell the kitchen exactly which ingredients you want on your plate and nothing more, but the kitchen needs more sophisticated infrastructure to handle custom orders.
What is it?
GraphQL is a query language and runtime for APIs that lets clients specify exactly the data they need, while REST is an architectural style using HTTP methods and resource-based URLs. Each has distinct caching, tooling, and performance characteristics.
Real-world relevance
A fintech claims app uses REST for its payment processing endpoints (leveraging HTTP caching and simple audit logs) but adds a GraphQL layer for its mobile client's dashboard — allowing the app to fetch claim status, related documents, and user profile in a single query instead of three separate REST calls.
Key points
- REST fundamentals — REST uses HTTP verbs (GET, POST, PUT, PATCH, DELETE) mapped to resources (/users, /orders/:id). Responses are server-defined — the server decides what fields are returned. Ideal for simple CRUD, public APIs, and teams with strong HTTP caching infrastructure.
- GraphQL fundamentals — GraphQL exposes a single endpoint (POST /graphql). Clients send queries specifying exactly which fields they need. The schema is strongly typed (SDL). Mutations change data; subscriptions push real-time updates over WebSocket.
- Over-fetching and under-fetching — REST suffers from over-fetching (extra fields you don't need) and under-fetching (requiring multiple requests to assemble a view). GraphQL solves both — one query can span multiple resource types and return only requested fields.
- N+1 problem in GraphQL — A naive GraphQL resolver fetches related data per-item in a loop, causing N+1 database queries. DataLoader (batching + caching) solves this by grouping per-field fetches into a single DB query per request cycle.
- Cursor vs offset pagination — Offset pagination (page=2&limit=20) is simple but breaks when items are inserted/deleted mid-list. Cursor pagination uses an opaque pointer (after: 'base64cursor') to the last seen item — stable under mutations, preferred for real-time feeds and infinite scroll.
- HTTP caching vs Apollo-style caching — REST benefits from HTTP cache headers (Cache-Control, ETags, CDN caching) because GET requests are cacheable by URL. GraphQL uses POST by default — not HTTP-cacheable. Apollo Client uses a normalized in-memory cache keyed by __typename + id, which is more powerful but application-level only.
- Flutter packages for GraphQL — graphql_flutter wraps Apollo-style client with Query/Mutation/Subscription widgets. ferry generates type-safe Dart classes from your schema. graphql (dart) provides lower-level client access. All support subscriptions over WebSocket.
- When to choose GraphQL — Choose GraphQL when: multiple client types (mobile, web, TV) need different data shapes; rapid frontend iteration without backend changes; complex nested data; real-time via subscriptions. Avoid for simple CRUD APIs, file uploads (multipart is awkward), or teams unfamiliar with the tooling overhead.
- When to choose REST — Choose REST when: public API with external consumers who expect standard HTTP; strong CDN caching requirements; simple CRUD; team expertise is REST-oriented; or you need HTTP-level caching for performance without application-layer solutions.
- Schema-first vs code-first — Schema-first: write SDL (.graphql files) first, generate server resolvers and client types. Code-first: define resolvers in code and auto-generate SDL. Schema-first is preferred for cross-team contracts; code-first suits small teams moving fast.
- Subscriptions and real-time — GraphQL subscriptions use WebSocket (graphql-ws protocol). The Flutter client maintains a persistent connection. For mobile, this means managing reconnection on network change — graphql_flutter handles this but you must configure keepAlive intervals.
- Federation and microservices — Apollo Federation lets multiple GraphQL services compose into a supergraph. Each service owns a subgraph (schema slice). The gateway stitches queries. This is the enterprise pattern when REST microservices would require a BFF layer.
Code example
// graphql_flutter — typed query with ferry
// schema.graphql excerpt:
// type Claim { id: ID!, status: ClaimStatus!, amount: Float!, documents: [Document!]! }
// Generated with ferry + build_runner:
// flutter pub run build_runner build
import 'package:ferry/ferry.dart';
import 'package:ferry_flutter/ferry_flutter.dart';
import 'claims_query.req.gql.dart';
import 'claims_query.data.gql.dart';
class ClaimsDashboard extends StatelessWidget {
const ClaimsDashboard({super.key, required this.userId});
final String userId;
@override
Widget build(BuildContext context) {
final client = context.read<Client>();
final request = GClaimsQueryReq((b) => b..vars.userId = userId);
return Operation(
client: client,
operationRequest: request,
builder: (context, response, error) {
if (response?.loading ?? true) return const CircularProgressIndicator();
if (error != null) return ErrorView(error: error);
final claims = response!.data!.claims;
return ListView.builder(
itemCount: claims.length,
itemBuilder: (_, i) => ClaimTile(claim: claims[i]),
);
},
);
}
}
// Cursor pagination query
const claimsQuery = r'''
query ClaimsQuery($userId: ID!, $after: String, $first: Int = 20) {
claims(userId: $userId, after: $after, first: $first) {
edges { node { id status amount } cursor }
pageInfo { hasNextPage endCursor }
}
}
''';Line-by-line walkthrough
- 1. ferry generates type-safe request/response classes from your SDL schema at build time — GClaimsQueryReq and GClaimsQueryData are Dart classes, not raw maps.
- 2. GClaimsQueryReq builder sets typed variables (vars.userId) — compile-time safety means typos in field names are caught before runtime.
- 3. Operation widget (from ferry_flutter) subscribes to the client's response stream and rebuilds automatically when data, loading state, or errors change.
- 4. response?.loading ?? true handles the initial state where response is null before the first server reply arrives.
- 5. The query string uses cursor-based pagination — $after accepts the endCursor from the previous page's pageInfo.
- 6. edges { node { ... } cursor } is the Relay-style pagination pattern — each edge carries the item and its cursor position.
- 7. pageInfo.hasNextPage tells the UI whether to show a 'load more' button or trigger the next fetch on scroll.
- 8. The client is read from context via Provider — ferry's Client is typically provided at the app root with configured link chain (auth, error, http).
Spot the bug
// Attempting a GraphQL subscription for new messages
final subscriptionRequest = GNewMessageSubReq(
(b) => b..vars.channelId = channelId,
);
Operation(
client: client,
operationRequest: subscriptionRequest,
builder: (context, response, error) {
if (response?.data == null) return const SizedBox();
final msg = response!.data!.newMessage;
return MessageBubble(message: msg);
},
);Need a hint?
The subscription connects but only ever shows one message — new messages don't appear. What's the architectural mistake?
Show answer
Bug: the Operation widget re-renders on each subscription event but replaces the single displayed message rather than accumulating messages into a list. Subscriptions emit individual events, not full lists. Fix: use a StreamBuilder or listen to the client's responseStream imperatively, accumulating messages into a local state list. With flutter_bloc or Riverpod: use an AsyncNotifier that listens to the subscription stream and adds each new message to a List<Message> state, then display the full list. The Operation widget pattern works for queries/mutations but is awkward for accumulating subscription events.
Explain like I'm 5
Imagine you ask a waiter for a burger. With REST, the waiter always brings the burger with fries, salad, and a drink — even if you only wanted the burger. With GraphQL, you tell the waiter exactly what you want: 'just the burger patty and the bun, no extras.' You get exactly what you asked for, nothing more.
Fun fact
GitHub migrated its public API from REST to GraphQL (v4) because mobile clients were making 300+ REST calls to render a single pull request page. The GraphQL API reduced that to a single query.
Hands-on challenge
Design a GraphQL schema for a multi-workspace chat app: define types for Workspace, Channel, Message, and User. Write a query that fetches a channel's last 20 messages with cursor pagination, and a subscription for new messages. Implement a basic Operation widget in Flutter using graphql_flutter that handles loading, error, and data states.
More resources
- GraphQL official documentation (graphql.org)
- graphql_flutter package (pub.dev)
- ferry — type-safe GraphQL for Dart (pub.dev)
- GraphQL vs REST — When to use which (Apollo Blog)