GraphQL
A typed query language for APIs: the client asks one endpoint for exactly the fields a screen needs, across many related objects, in one round trip, and pays for it with harder caching and more work on the server.
The problem GraphQL solves
Picture the post page of a social app on a phone. The screen shows a post, its author's name and avatar, the like count, and the first three comments, each with its author's name. Five small things on one screen.
The backend is a clean REST API, designed the way the REST API design lesson teaches: one resource per URL. If REST basics are new to you, read that lesson first; this one builds on it.
In a typical app, each component fetches when it renders. The page calls GET /posts/7. When that arrives, it renders the header, which learns the author is user 12 and calls GET /users/12, and the comment list, which calls GET /posts/7/comments?limit=3. When the comments arrive, it needs their authors and calls GET /users?ids=31,40,52.
That is three rounds, one after the other. A careful client could start the comments call at once and save a round, but it can never get down to one: author IDs are only known after earlier responses arrive. On 4G a round trip often costs 100 to 300 ms, so three dependent rounds is 300 to 900 ms of waiting, none of it real server work.
Too much data and too little
The waterfall is one cost; the other is shape. GET /users/12 returns the whole user: 40 fields, including the bio, the settings, the follower counts, and the signup date. The post page needs two of them: name and avatarUrl. That is over-fetching: downloading and parsing data the screen throws away.
The opposite also happens. GET /posts/7 returns authorId: 12 but not the author's name, so the client must ask again. That is under-fetching: one call is not enough for one screen, which is what caused the waterfall in the first place.
REST has good fixes for both (sparse fields, ?include=author, a backend for frontend), covered in the REST lesson. They work, but each one is a server change, made per endpoint, by whoever owns it.
Many clients, many shapes
Now add more clients. The web app shows the author's bio on hover, iOS shows a verified badge, and an old Android release still expects a field the others dropped. Each wants a different slice of the same data, on its own release schedule.
Facebook hit exactly this in 2012, rebuilding its mobile news feed as native apps. The feed is a deep tree of stories, authors, attachments, likes, and comments, and an endpoint per screen per client did not scale. So a small team flipped it: the client describes the shape it wants, and one server fills any shape. Facebook released this as GraphQL in 2015; the GraphQL Foundation now runs the spec.
The mental model: a typed graph you ask by shape
Keep one picture in your head. The server holds a graph: things (a post, a user, a comment) joined by named edges (a post has an author, a user has posts). The schema is the map of that graph, with a type for every thing and every edge. A query is a tree you draw on top of the map: start at an entry point, follow some edges, and pick the fields you want at each stop.
The server walks your tree and fills it in. The response is JSON with exactly the same shape as the query: the same nesting, the same names, nothing extra. If you did not ask for a field, it is not in the response. If you ask for a field that is not on the map, the server rejects the query before running any of it.
The query and the response mirror each other
Here is the post page from the last chapter as one GraphQL query, next to the answer. Read them side by side: every line of the query has a matching key in the response.
query PostPage($id: ID!) {
post(id: $id) {
id
title
likeCount
author { id name avatarUrl }
comments(first: 3) {
nodes { id text author { id name } }
}
}
}
$id is a variable sent next to the query, so the query text never changes between posts.{
"data": {
"post": {
"id": "7",
"title": "Shipping GraphQL",
"likeCount": 42,
"author": { "id": "12", "name": "Asha", "avatarUrl": "/a/12.png" },
"comments": {
"nodes": [
{ "id": "301", "text": "Nice", "author": { "id": "31", "name": "Ravi" } }
]
}
}
}
}
data. Only the fields the query asked for come back (one comment shown to keep it short).Who decides the shape
This is the real shift. In REST, the server decides what each URL returns, and the client picks URLs. In GraphQL, the server decides what is possible (the schema), and the client decides what it gets (the query).
Think of a restaurant. REST is a set menu: meal 3 always comes with fries, and a drink is a second trip to the counter. GraphQL is an order form: tick the burger, skip the fries, add a drink, hand it in once. The kitchen works harder to assemble your tray and must watch for someone ticking 500 desserts; both costs return later.
- One endpoint. Usually
POST /graphql. The query, not the URL, says what you want. - Typed. Every field has a type, so tools can check queries at build time and generate TypeScript types for every response.
- Shape in, shape out. The response mirrors the query, so a component can read
post.author.namewithout guessing. - Transport-agnostic. The spec defines the language and how it runs, not HTTP. How it travels over HTTP is a separate spec, and WebSocket protocols come from libraries.
Check your understanding · Exercise 1
A client sends { post(id: 7) { title author { name } } }. Which statements about the response are true? Select all that apply.
The schema: the contract both sides share
Every GraphQL API starts with a schema, written in SDL (the schema definition language). It lists every type, every field on each type, and the type of each field. The server enforces it on every request, and the client tools read it to check queries and generate code. It is the single contract between frontend and backend.
type User {
id: ID!
name: String!
avatarUrl: String
posts(first: Int = 10, after: String): PostConnection!
}
type Post {
id: ID!
title: String!
likeCount: Int!
viewerHasLiked: Boolean!
author: User!
comments(first: Int = 10, after: String): CommentConnection!
}
type Query {
post(id: ID!): Post
feed(first: Int!, after: String): PostConnection!
}
type Mutation {
likePost(input: LikePostInput!): LikePostPayload!
}
! means never null. post(id:) returns a nullable Post because the ID might not exist.The parts, one by one
- Scalars are the leaves:
ID,String,Int,Float,Boolean, and custom ones likeDateTime. Every branch of a query ends in scalars, so you never get "the whole user" by accident. - Object types like
UserandPosthave fields; a field that points at another object type is an edge of the graph. - Non-null (
String!) and lists ([Post!]!) combine:[Post!]!is a list that is always there and never holds a null. - Arguments can go on any field, like
comments(first: 3), not just on entry points. - Enums limit a value to a fixed set; input types group the arguments of a mutation.
- Interfaces and unions let one field return several types, like
union SearchResult = User | Post; the query picks fields per type with... on User { name }. - Root types are the entry points:
Queryfor reads,Mutationfor writes,Subscriptionfor pushed events. Every operation starts at one of them.
Nullability is a design decision
When a resolver fails or returns null for a non-null field, the null bubbles up to the nearest nullable parent. If author: User! fails, the whole post becomes null. If everything up to the root is non-null, the entire data becomes null and one broken avatar service blanks the page.
So experienced teams make fields backed by other services nullable, and keep ! for things that cannot fail alone, like id. A broken user service then shows a placeholder author, not an empty screen. The client handles null in more places, and generated types make that explicit.
Lists that page: connections
Lists that grow, like comments or a feed, use a connection (a Relay convention): first and after arguments, and edges (or nodes) plus pageInfo { hasNextPage endCursor }. It is cursor pagination in a shape every GraphQL client understands.
Mutations have a convention too: one input argument and a payload result that returns the changed objects, so the client can update its cache with no second request.
A schema that describes itself
A server can describe its own schema through introspection, a built-in query on __schema. Editors use it to autocomplete fields, and GraphQL Code Generator can use it to write a TypeScript type for each operation. Many teams turn it off in production and give their tools the schema from the build instead.
Think first
The feed query asks for 20 posts, each with author { name }. The user service times out for one author. What does the client see if author is User!, and what if it is User?
With author: User!, the null cannot sit on author, so it bubbles to the post. If the feed list is [Post!]!, it bubbles again and the whole feed field becomes null, or the whole data if the root field is non-null too. One slow author erases 20 posts.
With author: User, only that one post has author: null, an entry in errors explains why, and the other 19 render normally. That is why fields backed by other services are usually nullable.
Queries, mutations, and subscriptions on the wire
GraphQL has three kinds of operation, one per root type. A query reads, a mutation writes, and a subscription listens. All three are written in the same language; what differs is when they run and how the answer travels.
Queries: reading, with variables and aliases
Over HTTP, a query is usually a POST to one URL with a small JSON body: the query text, a variables object, and the operation name. The server answers with JSON that has a data key, and an errors key if anything went wrong.
POST /graphql HTTP/1.1
Content-Type: application/json
Accept: application/graphql-response+json, application/json
{
"operationName": "PostPage",
"query": "query PostPage($id: ID!) { post(id: $id) { id title } }",
"variables": { "id": "7" }
}
- Variables (
$id: ID!) keep the query text constant. Never paste user input into the query string: it breaks caching of the parsed query, and it is an injection risk, like building SQL by hand. - Operation names (
PostPage) show up in server logs, traces, and the browser network panel. An unnamed query in production is very hard to find when it gets slow. - Aliases rename a field in the response, which lets you ask for the same field twice with different arguments:
small: avatar(size: 48)andlarge: avatar(size: 256).
Try it below. Turn on the fields a screen needs and watch the query, the response, and its size change together. Notice that adding the comments costs no extra request, only bytes.
Mutations: writing, and returning what changed
A mutation looks like a query but changes data. Two rules make it different. First, if one mutation operation has several top-level fields, the server runs them one after another, in order, while query fields may run in parallel. Second, by convention a mutation returns the objects it changed, and the client should ask for every field its screens show.
mutation LikePost($input: LikePostInput!) {
likePost(input: $input) {
post { id likeCount viewerHasLiked }
}
}
id lets a normalized cache find the post it already holds and update it in place.Mutations are not idempotent by default: a retried likePost may like twice. Take an idempotency key in the input, or make it a set (setLiked(liked: true)) rather than a toggle.
Subscriptions: events pushed by the server
A subscription asks the server to push a result every time an event happens, like a new comment on post 7. The spec says how a subscription runs, not how it travels. The common choice is a WebSocket with the protocol from the graphql-ws library (its WebSocket subprotocol is called graphql-transport-ws).
The older subscriptions-transport-ws library is unmaintained, and confusingly its subprotocol was named graphql-ws; new code should not use it. Server-sent events, through the graphql-sse protocol, are a simpler option that works over plain HTTP.
subscribe gets an id, and every event comes back as a next message shaped by the fields the client asked for.Each pushed message is a normal GraphQL result, so the client picks exactly what each event carries. The cost is a fleet of long-lived connections, resolvers re-run per event per listener, and reconnect handling. Many teams use subscriptions only where updates must be live, and refetch or poll elsewhere.
Errors: 200 OK is not success
GraphQL can return partial data. If 19 posts load and one author fails, the response has data with that author set to null and an errors array with a message and a path like ["feed", 4, "author"]. Traditionally all of this comes back as 200 OK. Monitoring that counts 5xx responses sees a perfectly healthy API while users see broken screens.
The GraphQL over HTTP spec (a draft, widely followed) improves this: with the application/graphql-response+json media type, a request that fails before execution (a parse or validation error, so the response has no data key) gets a 4xx or 5xx, while partial success stays 200. Dashboards must still read the errors array.
Check your understanding · Exercise 2
A feed query returns HTTP 200 with data.feed holding 20 posts, one of them with author: null, and an errors array with one entry whose path is ["feed", 4, "author"]. What should the client do? Select all that apply.
How the server runs a query: one round trip, many fetches
A frontend engineer does not write the server, but needs to know what a query makes it do. Every slow GraphQL screen is a frontend query meeting a backend resolver, and interviewers ask about both sides.
Parse, validate, execute
- Parse. The server turns the query text into a syntax tree. Many servers cache this per query text, which is one more reason to use variables instead of building strings.
- Validate. It checks the tree against the schema: do the fields exist, do the argument types match, are all variables declared. Any failure stops here, before a single resolver runs.
- Execute. It walks the tree from the root, calling a resolver for each field. A resolver is a function that returns the field's value given its parent object.
Query.postloads a post by ID;Post.authortakes that post and loads its author.
Most resolvers just read a property, like post.title. The interesting ones call a database, a REST service, or a cache. Sibling fields can run in parallel, so author and comments start together once the post has loaded.
Moving the waterfall into the data center
The dependency between the post and its author does not disappear. The server still has to load the post before it knows which author to load. What changes is where that wait happens. A call between two services in the same data center takes about a millisecond; the same call from a phone takes 100 to 300 ms.
The arithmetic for the post page: three dependent REST rounds from a phone at 200 ms each is about 600 ms of pure waiting. One GraphQL round trip is about 200 ms plus three rounds inside the data center at a few milliseconds each, so about 210 ms plus the time the services themselves take. A backend for frontend gives the same win with a hand-written endpoint; GraphQL gives it for any shape a client writes.
The N+1 problem
Per-field resolvers hide a classic trap. The feed asks for 20 posts and each post's author: Query.feed runs once and makes 1 database call, then Post.author runs 20 times, each with its own call. That is 1 + 20 = 21 calls for one screen: the N+1 problem. Add comments and comment authors and it multiplies.
The standard fix is DataLoader, a small batching utility that started at Facebook, now maintained under the GraphQL Foundation, with ports in most languages. A resolver does not fetch right away; it calls userLoader.load(12). DataLoader collects every key asked for during the current tick of the event loop, then calls your batch function once with all of them: SELECT * FROM users WHERE id IN (12, 31, 40, ...). It also deduplicates, so an author who wrote five of the 20 posts is fetched once.
// One loader per request, so users never see each other's cached rows.
function createLoaders(db: Db) {
return {
user: new DataLoader(async (ids: readonly string[]) => {
const rows = await db.users.findMany({ where: { id: { in: [...ids] } } });
const byId = new Map(rows.map((row) => [row.id, row]));
return ids.map((id) => byId.get(id) ?? null); // same order as ids
}),
};
}
const resolvers = {
Post: {
author: (post: Post, _args: unknown, ctx: Context) => ctx.loaders.user.load(post.authorId),
},
};
Error.Any client can write an expensive query
The power that lets a client ask for any shape also lets it ask for a terrible one: feed(first: 1000) with every comment of every post and every author's posts, nested ten deep. One request could ask for millions of objects. A REST server never faces this, because each endpoint does a fixed amount of work.
- Maximum page size. Reject
firstabove, say, 100. - Depth limit. Reject queries nested deeper than about 10 levels.
- Cost analysis. Give each field a cost, multiply by page sizes, and reject or rate-limit by the total. GitHub's public API charges points per query against an hourly budget.
- Timeouts per request and per resolver, so one slow query cannot hold a worker forever.
- Persisted queries (chapter 8): the server runs only queries your apps registered at build time.
Check your understanding · Exercise 3
A query asks for 50 posts, each with its author and its first 10 comments, each comment with its author. Which statements are true? Select all that apply.
Fragments: each component declares its own data
Back on the client. Real page queries grow to hundreds of lines, edited by several teams. Nobody knows which component uses author.avatarUrl, so nobody dares remove it, and the query only grows. This is the god query problem.
A fragment is a named set of fields
A fragment is a reusable piece of a query: a name, a type, and a set of fields on that type. fragment AuthorBadge_user on User { name avatarUrl } means "on any User, these two fields". A query includes it with a spread, ...AuthorBadge_user, and the result is the same as if the fields were written inline.
That alone is just reuse. The idea that changes how teams work is colocation: each component declares its fragment in the same file as its JSX, listing exactly the fields it renders. Parents spread their children's fragments, and only the page (or route) component owns an actual query. The query is built from the bottom up.
export const AuthorBadgeFragment = graphql(`
fragment AuthorBadge_user on User {
name
avatarUrl
}
`);
export function AuthorBadge(props: { user: FragmentType<typeof AuthorBadgeFragment> }) {
const user = useFragment(AuthorBadgeFragment, props.user);
return (
<span>
{user.avatarUrl && <img src={user.avatarUrl} alt="" />} {user.name}
</span>
);
}
// In PostHeader's fragment: author { ...AuthorBadge_user }
useFragment is a typed accessor, not a network call.What colocation buys you
- Safe deletes. Delete a component and its fragment goes with it. If no other fragment asks for
avatarUrl, it leaves the query automatically. - Safe adds. A component that needs a new field edits only its own fragment. The page query picks it up through the spread; nobody edits a shared file.
- Exact types. Code generation makes a TypeScript type per fragment, so
AuthorBadgecannot readuser.emailwithout asking for it first. The compiler catches it. - One request per screen. However many components there are, the spreads compile into one query, so the screen still costs one round trip.
Data masking
Colocation has a leak. The parent fetched name for the badge, so the parent can read user.name too, and one day it does. Now the parent depends on a field it never asked for. When the badge team removes name from their fragment, the parent breaks.
Data masking closes the leak: a component can read only the fields in its own fragment. The parent receives an opaque reference for the child's data and passes it down; only the child can unwrap it. Relay has always worked this way. GraphQL Code Generator's client preset does it at the type level, and Apollo Client added opt-in data masking in version 3.12.
One query per route, known before render
Because the whole screen's needs compile into one query, the app knows what to fetch for a route before any component renders. Relay builds on this: the router can start the query when the user clicks a link, in parallel with downloading the route's code, instead of waiting for components to mount and fetch one by one. This avoids the component-level waterfalls that useEffect fetching creates.
Check your understanding · Exercise 4
Your feed has FeedPage, PostCard, and AuthorBadge, with colocated fragments. The design team removes the avatar from AuthorBadge. What happens? Select all that apply.
Normalized caches: one copy of every object
GraphQL clients keep results in memory, so going back to a screen renders at once. The obvious way is to store each response as it arrived, keyed by query and variables: a document cache, which is how most REST data libraries work too.
The trouble starts when the same object appears in many responses. User 12 shows up in a feed card, in a comment, and on their profile page: three responses, three copies. Rename the user from the profile, and the profile updates while the feed and the comment keep the old name until they refetch.
How normalization works
Apollo Client and Relay normalize instead. When a response arrives, the cache walks it and pulls out every object that has an identity. Each one is stored once, in a flat table, under a key built from its type and ID, like User:12. Wherever the object appeared in the response, the cache stores a reference to that key instead of the data.
- Apollo adds
__typenameto every selection automatically, so the cache knows each object's type. (Relay's compiler addsidthe same way.) - For each object, it builds the key. Apollo uses
__typenameplusidby default, and you change it per type withtypePolicies, for examplekeyFields: ["sku"]for aProduct. Relay keys records byidalone, so it expects IDs unique across all types, usually through aNodeinterface withid: ID!. - Fields from different queries merge into the same record.
- Root fields are stored too:
ROOT_QUERY.post({"id":"7"})holds a reference toPost:7. That is how a later query for the same post can be answered from the cache. - Reading a query follows the references to rebuild its shape; components watching a record re-render when it changes.
Now the rename is one write. The renameUser mutation returns { id: "12", __typename: "User", name: "Asha K" }, the cache merges it into User:12, and every screen that shows user 12 updates together, with no refetch. Try both kinds of cache below.
Objects update themselves; lists do not
Normalization fixes changes to existing objects, not membership. When createPost returns Post:99, the cache stores it but cannot know which lists should contain it. You tell it, in one of three ways.
- Update the cache by hand. Apollo's
updatefunction withcache.modifyprepends the new reference toROOT_QUERY.feed. Relay has updaters and directives like@prependEdgeon the mutation's result. - Refetch the affected queries. Apollo's
refetchQueries: ["Feed"]. Simple and always correct, at the cost of a round trip. - Evict and let it reload. Remove the list field from the cache (
cache.evict) so the next read goes to the network.
Deletes are the same problem in reverse: remove Post:12 from every list that references it, or evict it. Paginated lists need one more rule: Apollo needs a field policy (like its relayStylePagination helper) to append page 2 to page 1 instead of replacing it.
Optimistic updates and fetch policies
The cache is also the natural place for optimistic updates. Pass an optimisticResponse like likeCount: 43 and the cache writes it to a temporary layer at once; the real answer replaces the layer, and a failure drops it, rolling every screen back.
Each query also picks a fetch policy: cache-first (Apollo's default: the network only if a field is missing), cache-and-network (show the cache, refresh in the background), or network-only. A settings page can be cache-first; a live price wants network-only or a subscription.
Decision How should a GraphQL client cache results?
What we pick: For an app where the same objects appear on many screens and users edit them (feeds, dashboards, collaborative tools), a normalized cache is worth its cost. For read-mostly pages or a small app, a document cache, or plain fetch with a generic server-state library, is simpler and usually enough. The server-state libraries and normalization lessons cover the general versions of this choice.
Check your understanding · Exercise 5
An app uses Apollo Client's normalized cache. Which of these update the screen without a network request? Select all that apply.
HTTP caching, persisted queries, and security
The client cache from the last chapter lives inside one tab. The browser's HTTP cache and CDNs, driven by headers (see the HTTP caching lesson), serve everyone, and a REST API gets them almost for free. GraphQL, as usually deployed, loses them.
Why POST /graphql breaks HTTP caching
HTTP caches find a stored response by its cache key: the method and the URL, plus any headers named in Vary. GET /products/42 has its own URL, so a CDN can keep it for 60 seconds and answer millions of requests without touching your servers.
- One URL for everything. Every request goes to
/graphql. What makes requests different, the query and variables, is in the body, and caches do not key on bodies. POSTis not reused. HTTP never lets a cache answer onePOSTwith a response stored for another, so browsers and CDNs send everyPOSTto the origin unless you add custom edge code.- Mixed freshness. A title that is fine for an hour and a stock count stale after a second share one response, so one
Cache-Controlheader must fit the shortest. - Mixed audiences. If any field is personal (
viewer { cartCount }), the whole response is private and no shared cache may store it.
The result is that every request reaches your origin. For a logged-in app showing personal data this costs little, because those responses were never shareable anyway. For a public catalog or a news site, where a CDN used to absorb 95 percent of traffic, it can multiply origin load many times over.
POST /graphql gives every read the same key and a method caches skip; a persisted query over GET gets REST-like caching back.Persisted queries
A persisted query replaces the query text with a short ID, usually its SHA-256 hash. The server keeps a map from hash to query. The client sends { "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "ab12..." } }, "variables": {...} } instead of a few kilobytes of query text. There are two kinds.
- Automatic persisted queries (APQ). The server learns hashes at runtime. The client sends only the hash; on a miss the server answers
PersistedQueryNotFound, the client resends hash plus full text once, and the server stores it. Smaller requests, but anyone can still register any query. - Trusted documents (also called persisted operations or an allowlist). At build time, the compiler extracts every operation from your apps and uploads them. In production the server runs only those IDs and rejects any other query. Facebook has long worked this way, Relay supports it directly, and it is the strongest defence against expensive or malicious hand-written queries.
GET URLs that a CDN can cache like any REST resource.Getting HTTP caching back
Persisted queries make the URL meaningful again. Send queries (never mutations) as GET /graphql?extensions=...hash...&variables={"id":"42"}, and each query and variables pair has its own short URL that browsers and CDNs cache by the usual rules. A GET with the full query text also works, until long queries hit URL limits, often about 8 KB.
The response also needs a Cache-Control header. Apollo Server, for example, lets the schema carry hints like @cacheControl(maxAge: 60) on types and fields; the server computes the response's lifetime as the smallest maxAge of all fields it resolved, and marks it private if any field is private. Some CDNs understand GraphQL and can also purge cached responses by the types they contain when a mutation changes them.
Security checklist
- Authorize in resolvers, per field and per object. One endpoint means you cannot protect URLs in a gateway.
Query.order(id:)must check that the viewer owns the order, every time, however the query reached it. - Limit cost, depth, and page size (chapter 5), and rate-limit by query cost, not by request count. A single request with 500 aliases of
login(password:)is 500 password guesses. - Block CSRF. A browser can send a cross-site
GETor a formPOSTwithout a preflight check. Never run mutations overGET, and accept onlyContent-Type: application/jsonor require a custom header, which forces a CORS preflight. Apollo Server (version 4 and later) has this CSRF prevention on by default. - Hide introspection and stack traces from the public in production.
- Prefer trusted documents for first-party apps; the rest of this list then becomes a second line of defence.
Check your understanding · Exercise 6
A public recipe site moves its pages from REST to GraphQL over POST. Origin traffic jumps 20 times. Which fixes help? Select all that apply.
GraphQL or REST: an honest comparison
GraphQL does not replace REST; it trades one set of costs for another, moving work from clients to the server and from endpoint design to schema design. Whether that pays depends on your data and your teams.
| Question | REST (with the REST lesson's practices) | GraphQL |
|---|---|---|
| Who picks the response shape? | The server, per endpoint. Sparse fields and include help. | The client, per query, within the schema. |
| Requests per screen | Often several, sometimes dependent. | One, by design. |
| HTTP caching (browser, CDN) | Works by default for GET. | Lost with POST; needs persisted GET queries and cache hints. |
| Client cache | Generic server-state libraries keyed by URL. | Normalized caches that know types and IDs. |
| Types for the frontend | Possible with OpenAPI and codegen. | Built in; codegen per operation is standard. |
| Server effort | Fixed work per endpoint, easy to reason about. | Resolvers, batching, cost limits, schema governance. |
| Status codes and monitoring | Meaningful status per request. | Mostly 200; read the errors array. |
| Files and binary data | Plain PUT or POST of the bytes. | Not in the spec; use a separate upload endpoint. |
| Versioning | URL or header versions. | Usually none: add fields, deprecate with @deprecated, remove when unused. |
The order of the questions matters. Shape of data comes first, because without deep, shared data GraphQL solves a problem you do not have. Caching comes second, because losing the CDN on public pages is the most expensive surprise. Team capacity comes last, and it is the one teams most often skip.
When GraphQL pays off
- Many clients, different shapes. Web, iOS, and Android read the same connected data but show different fields, and ship on different schedules.
- Deep, connected screens. Feeds, dashboards, and product pages that combine five or more kinds of objects.
- Slow networks. Mobile users far from the data center, where every round trip saved matters.
- Many backend teams. Federation lets each team own part of the schema (users, orders, reviews) and joins the parts into the one graph clients see.
When not to use it
- Simple CRUD on a few resources. A handful of REST endpoints is less code, and every developer already knows them.
- Public, cacheable content. Articles, product pages, docs: plain
GETURLs cache at the edge with zero extra work. - Files, streams, and binary data. Uploads and downloads are outside the spec. Teams use a separate endpoint or presigned URLs anyway.
- One team, one client, full stack TypeScript. A typed RPC layer such as tRPC, or server functions in your framework, gives end-to-end types with far less machinery.
Decision A new product has a web app and two mobile apps showing a feed, profiles, and comments. Which API style?
What we pick: With three clients reading the same connected graph and changing often, GraphQL (or a BFF that exposes GraphQL) is the strongest fit. With one web client and simple screens, REST is less work, and a BFF is the middle ground when only a few screens hurt. Say which facts would change your answer.
In real products
- Facebook built GraphQL for its native news feed around 2012 and uses Relay with persisted queries across its apps.
- GitHub announced its public GraphQL API (v4) in 2016 next to its REST API, with a points-based rate limit per query.
- Shopify offers its Storefront and Admin APIs in GraphQL. Its REST Admin API has been legacy since October 2024, and new public apps must use the GraphQL Admin API since April 2025.
- Netflix runs a federated GraphQL layer in front of many backend services and open-sourced its Java framework for it, DGS.
Think first
Your shop uses GraphQL for the logged-in app. Marketing wants the public product pages to load fast worldwide. Do you move them to REST?
Not necessarily. Most large GraphQL users mix: GraphQL for logged-in, personal, deeply nested screens, and plain cacheable GET endpoints (or server-rendered, CDN-cached HTML) for public pages and file uploads.
If you keep GraphQL for product pages, use persisted queries over GET with public cache hints so the CDN can serve them. If the team cannot do that soon, server-render the public pages and cache the HTML at the edge: the browser never calls GraphQL for them at all.
Using GraphQL in a system design interview
GraphQL comes up in the API step of almost every frontend design question: the news feed, a chat app, an e-commerce product page. The interviewer is not checking whether you like it. They are checking whether you can pick an API style for this product and defend it with costs on both sides.
When to bring it up
- When a screen needs several kinds of related objects at once, like a feed item with an author, media, reactions, and the first comments.
- When the prompt mentions several clients (web and mobile), or slow networks.
- When the interviewer asks how you would avoid request waterfalls or over-fetching.
- Not for a single widget like an autocomplete or a modal dialog. There a single
GETendpoint is the honest answer, and saying so shows judgement.
What to draw and what to say
Draw the client with a normalized cache, one arrow to a GraphQL layer, and that layer fanning out to the services. Then write the main query as a short tree of fragments for the main screen. Five lines are enough: feed(first: 20, after: $cursor) { edges { node { ...FeedItem_post } } pageInfo { endCursor hasNextPage } }.
- Why: one round trip for a deep screen, exact fields, typed contract.
- Client: fragments colocated with components, a normalized cache keyed by
__typenameandid, optimistic updates for likes, and how a new post gets into the feed list. - Server: DataLoader for N+1, cost and depth limits, persisted queries for first-party apps.
- Costs: lost HTTP caching and how you would win it back,
200witherrors, the extra operational work. - Realtime: a subscription for new comments only if the product needs it live; otherwise refetch on focus or poll.
How deep to go by level
| Level | What a good answer covers |
|---|---|
| Junior | What a query, mutation, and schema are; one round trip versus several; asking only for the fields the screen needs. |
| Mid | Fragments per component, a normalized cache and why every object needs an id, cursor pagination with connections, N+1 and DataLoader. |
| Senior | Cache updates for list membership, optimistic updates with rollback, persisted queries and HTTP caching, cost limits, nullability for partial failure, when REST or a BFF is the better choice. |
| Staff | Schema governance across teams, federation, trusted documents as a security boundary, deprecation without versions, measuring resolver cost per operation in production. |
Check your understanding · Exercise 7
In which of these interview prompts is GraphQL a strong choice to propose? Select all that apply.
Summary
- GraphQL is a typed contract (the schema) plus a query language: the server says what is possible, the client asks for exactly what it needs, and the response mirrors the query.
- One query fetches a whole screen in one round trip; the dependent fetches still happen, but inside the data center where they cost milliseconds instead of hundreds of them.
- Fragments colocated with components, with data masking, keep large queries maintainable: each component owns its fields, and the page still sends one request.
- Normalized caches (Apollo, Relay) store each object once by type and
id, so one mutation result updates every screen; list membership and pagination still need code. - On the server, batch with DataLoader to avoid N+1, and limit cost, depth, and page size, because any client can write an expensive query.
POST /graphqldefeats browser and CDN caching; persisted queries overGETwith cache hints win it back, and trusted documents also lock the server to known queries.- A
200can still carry errors: design nullability for partial failure, put business errors in the schema, and monitor theerrorsarray. - Choose GraphQL for deep, connected data used by several clients; choose REST, a BFF, or typed RPC for simple, public, cacheable, or single-client APIs.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
What problem does GraphQL solve for a frontend, and what does it cost?
It lets the client ask one endpoint for exactly the fields a screen needs, across many related objects, in one round trip. That removes request waterfalls and over-fetching, which matter most on mobile networks and when several clients need different shapes of the same data. The schema also gives typed contracts and generated types.
The costs: the server must handle any query safely, so it needs batching for N+1, cost and depth limits, and ideally persisted queries. HTTP caching is lost with POST and must be won back on purpose. Errors come back as 200 with an errors array, so monitoring changes. And the team must learn and run a new layer.
Question 2
Why does a normalized cache need every object to have an id, and what does it still not handle?
It stores each object once under a key like User:12, from its type and id, and replaces nested copies with references. Without an id it cannot tell that the author in the feed and the author in a comment are the same user, so it keeps separate copies and a rename updates only one of them.
It still does not know list membership. A newly created post is stored, but nothing says which feeds or profiles contain it. I would update those lists in the mutation's update function, or refetch or evict them. Deletes and pagination merges need the same care.
Question 3
Your team moved a public product catalog from REST to GraphQL and origin load went up many times. What happened, and how do you fix it?
The CDN stopped caching. REST product pages were GET URLs the CDN served from the edge. Now every request is POST /graphql with the query in the body, so each one reaches the origin.
I would use persisted queries sent as GET, so each query and variables pair has its own URL, and add cache hints so public responses carry Cache-Control: public, max-age=.... If that takes too long, I would server-render the public pages and cache the HTML at the edge, keeping GraphQL for logged-in screens.
Question 4
How do fragments help 40 engineers work on the same feed?
Each component declares the fields it renders in a fragment in its own file, and the page query is built by spreading the children's fragments. Teams add or remove fields in their own components without editing a shared query, and it still compiles to one request per screen.
With data masking, a component can read only its own fragment's fields, so no hidden dependencies form, and deleting a field is safe once no fragment asks for it. I would also name fragments after their components and avoid shared field bags, which grow into a god query.
Question 5
The feed loads 50 posts with authors and comments, and the GraphQL server is slow. Where do you look?
First at the resolvers for N+1: without batching that query can make hundreds of database calls. Per-request DataLoaders for authors and comments turn each level into one batched call. Tracing per resolver shows which field is slow.
Then at the query itself: is first: 50 needed for the first paint, or can we load 10 and page? Are we asking for fields nobody renders? Finally, cost limits protect the server from even larger queries, and persisted queries let us see and tune every operation the apps send.
References
- GraphQL specification The language, validation, and execution rules, including null propagation.
- Learn GraphQL (graphql.org) Official introduction to schemas, queries, mutations, and best practices.
- GraphQL over HTTP specification How requests, GET versus POST, media types, and status codes work over HTTP.
- Apollo Client: caching overview How the normalized cache stores and identifies objects.
- Relay documentation: thinking in Relay Fragment colocation and data masking, from the team that started them.
- DataLoader The reference batching and per-request caching utility.
- GitHub GraphQL API: rate limits and query limits A real points-based cost model for a public GraphQL API.
Read next
- REST for frontendsA way to shape an HTTP API around resources and a few standard verbs, so the frontend can fetch, cache, retry, and handle errors in the same predictable way everywhere.
- Backend for frontend (BFF)A small server, usually owned by the frontend team, that gathers data from many backend services and returns exactly what one screen of one kind of client needs.
- Server-state librariesTanStack Query, SWR, RTK Query, and Apollo cache server data by key, refetch stale entries in the background, and refresh them after mutations, so components stop hand-writing loading flags, races, and invalidation.
- Normalizing stateStoring each entity once, in a table keyed by its id, and having lists and relationships point at ids instead of holding copies, so one change updates every view that shows it.
- HTTP cachingThe browser's built-in cache, steered by headers on each response, which lets a repeat visit skip the network entirely or ask only "has this changed?": the cheapest speed-up on the web.
- WebSocketsOne long-lived connection, opened by an HTTP handshake, on which browser and server can each send a message at any moment. The usual pick for chat, multiplayer, and collaboration, and the one where you own the protocol, reconnects, and scaling.