cf.completefrontend
My progress
FUNDAMENTALS · MODULE 3

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.

A timeline of the post page on a phone, with each round trip taking 200 milliseconds, drawn to scale. With REST: GET posts 7 from 0 to 200 milliseconds, then GET users 12 and GET comments in parallel from 200 to 400, then GET the 3 comment authors from 400 to 600. The screen is complete at 600 milliseconds. With GraphQL: one POST to slash graphql from 0 to about 210 milliseconds, including about 10 milliseconds of fetching inside the data center. The screen is complete at about 210 milliseconds.REST: three dependent roundsGET /posts/7GET /users/12GET commentsGET 3 authorsdone at 600 msGraphQL: one round tripPOST /graphqldone at about 210 ms0 ms200 ms400 ms600 ms800 ms
Each REST round waits for the one before it, so the trips stack up. GraphQL still has the same dependencies, but resolves them on the server, where each hop costs milliseconds.

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 schema drawn as a graph of four types. Query is the entry point, with an edge called post with an id argument to Post. Post has scalar fields id, title, and likeCount, an edge called author to User, and an edge called comments to Comment. Comment has fields id and text and an edge called author to User. User has fields id, name, and avatarUrl. The PostPage query follows Query to Post, Post to User, Post to Comment, and Comment to User, picking scalar fields at each stop.post(id:)QueryPostUserCommentauthorcommentsauthorentry pointid, title, likeCountid, name, avatarUrlid, text
The schema is the map; a query is a path you draw on it. Every stop ends in scalar fields, and the response comes back in the shape of the path.

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.

graphql
query PostPage($id: ID!) {
  post(id: $id) {
    id
    title
    likeCount
    author { id name avatarUrl }
    comments(first: 3) {
      nodes { id text author { id name } }
    }
  }
}
One request. $id is a variable sent next to the query, so the query text never changes between posts.
json
{
  "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" } }
        ]
      }
    }
  }
}
The answer sits under 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.name without 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.

graphql
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 like DateTime. Every branch of a query ends in scalars, so you never get "the whole user" by accident.
  • Object types like User and Post have 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: Query for reads, Mutation for writes, Subscription for 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?

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.

http
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" }
}
The same request shape for every operation. Only the body changes.
  • 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) and large: 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.

The screen shows
The query has 12 lines. The GraphQL response is 416 B in 1 request and 1 round trip, about 210 milliseconds on 4G. The same screen over REST takes 4 requests in 3 dependent rounds, about 600 milliseconds, and downloads about 12.2 KB of full resources.the queryquery PostPage($id: ID!) {post(id: $id) {id title likeCountauthor { id name avatarUrl }comments(first: 3) {nodes {id textauthor { id name }}}}}bytes downloadedREST: 4 requests12.2 KBGraphQL: 1 request416 Btime on 4G, 200 ms a roundREST: 3 rounds, 600 msGraphQL: 1 round, 210 ms
REST sizes are illustrative full resources (a post, a user, three comments, three users). The GraphQL size is the real JSON of the response on the left. Each field you add costs bytes, never another round trip.

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.

graphql
mutation LikePost($input: LikePostInput!) {
  likePost(input: $input) {
    post { id likeCount viewerHasLiked }
  }
}
Asking for 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.

A sequence diagram with three actors: the browser, the GraphQL server, and an event bus. Over one WebSocket, the browser sends connection_init and the server replies connection_ack. The browser sends subscribe for CommentAdded on post 7. Later the event bus tells the server about a new comment on post 7, the server runs the subscription's resolvers and sends next with the comment's text and author name. When the user leaves the page, the browser sends complete.BrowserGraphQL serverEvent busconnection_initconnection_acksubscribe CommentAddedcomment on post 7next: { text, author }complete
The handshake happens once per socket; after that each 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

  1. 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.
  2. 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.
  3. 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.post loads a post by ID; Post.author takes 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.

On the phone, the PostPage screen sends one query over the mobile network, which takes 100 to 300 milliseconds. In the data center, the GraphQL server runs resolvers that call the Posts, Users, and Comments services, about 1 millisecond each, and sends back one response.PhonePostPageone query1 query100 to 300 msData centerGraphQLresolversPostsUsersCommentsabout 1 ms each
The slow trip happens once. The fan-out to three services happens inside the data center, where each call costs about a millisecond instead of a few hundred.

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.

Two columns. Left, without batching: one query for 20 posts, then one query per author: author of post 1, 2, 3, and 17 more, up to post 20, for 21 database calls. Right, with DataLoader: one query for 20 posts, then the 20 author IDs are collected in one tick and fetched in one query, users where id in the 20 IDs, for 2 database calls.without batchingposts LIMIT 20author of post 1author of post 2author of post 3… 16 more …author of post 2021 database callswith DataLoaderposts LIMIT 20collect 20 idsusersWHERE id IN (20 ids)2 database calls
The resolvers are the same in both columns. DataLoader only changes when they fetch: it waits until every author in this tick has been asked for, then makes one call.
ts
// 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),
  },
};
The batch function must return one result per key, in the same order as the keys: a value, null, or an 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 first above, 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.

A component tree. PostPage owns the query. Its children are PostHeader, which needs title and author, and CommentList, which needs comments with first 3. PostHeader renders AuthorBadge, which needs name and avatarUrl. CommentList renders CommentItem, which needs text and author, and CommentItem renders AuthorBadge again. The page query spreads PostHeader_post and CommentList_post.PostPageowns the queryPostHeadertitle, authorCommentListcomments(first: 3)AuthorBadgename, avatarUrlCommentItemtext, authorAuthorBadgename, avatarUrlthe page query spreads:...PostHeader_post...CommentList_post
Each component lists only the fields it shows. The page query is built by spreading its children's fragments, and the same AuthorBadge fragment is reused wherever an author appears.
tsx
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 }
The style GraphQL Code Generator's client preset produces. Its 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 AuthorBadge cannot read user.email without 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.

On the left, the nested PostPage response: post 7 with a title, an author who is user 12 with a name, and comments holding comment 301 with text and an author who is user 31. On the right, the flat table a normalized cache stores. ROOT_QUERY maps post with id 7 to a reference to Post:7. Post:7 holds its title and a reference to User:12. User:12 holds name Asha. Comment:301 holds its text and a reference to User:31. User:31 holds name Ravi.the response, nestedpost (id 7)titleauthor (User 12)namecommentsComment 301textauthor (User 31)the cache, flatROOT_QUERYpost(id: 7) → Post:7Post:7title, author → User:12User:12name: "Asha"Comment:301text, author → User:31User:31name: "Ravi"one row per object, references in between
Every object with a type and an id becomes one row; wherever it was nested, the cache keeps only a reference. Any screen that shows User:12 reads the same row.
  1. Apollo adds __typename to every selection automatically, so the cache knows each object's type. (Relay's compiler adds id the same way.)
  2. For each object, it builds the key. Apollo uses __typename plus id by default, and you change it per type with typePolicies, for example keyFields: ["sku"] for a Product. Relay keys records by id alone, so it expects IDs unique across all types, usually through a Node interface with id: ID!.
  3. Fields from different queries merge into the same record.
  4. Root fields are stored too: ROOT_QUERY.post({"id":"7"}) holds a reference to Post:7. That is how a later query for the same post can be answered from the cache.
  5. 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.

Cache results
Three screens show the same user: a feed card, a comment, and the profile. The cache stores results separately for each query, so each holds its own copy of the name. No rename has happened yet.what screens showwhat the cache storesFeed cardby AshaFeedQueryauthor: { name: "Asha" }Commentby AshaPostQueryauthor: { name: "Asha" }Profileby AshaProfileQueryauthor: { name: "Asha" }press Rename to run the mutation
Each query keeps its own copy of the user. The rename only reaches the query that ran it, so the other screens keep showing the old name until they refetch.

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 update function with cache.modify prepends the new reference to ROOT_QUERY.feed. Relay has updaters and directives like @prependEdge on 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?

Document cache (by query)

Store each response under its query text and variables, as urql does by default.

Invalidate after a mutation by refetching queries that mention the changed __typename.

Pros

  • Tiny and easy to reason about.
  • No need for IDs on every type.
  • Nothing to configure.

Cons

  • Duplicate copies of the same object go stale independently.
  • Mutations cause refetches instead of in-place updates.

Normalized cache (Apollo, Relay, urql Graphcache)

Split responses into records keyed by type and ID; store references.

Mutation results merge into records; every watcher updates.

Pros

  • One update fixes every screen.
  • Queries can be partly or fully answered from cache.
  • Natural home for optimistic updates.

Cons

  • Needs stable IDs everywhere.
  • List membership and pagination need manual code.
  • More memory and CPU on large responses; more to learn.

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.
  • POST is not reused. HTTP never lets a cache answer one POST with a response stored for another, so browsers and CDNs send every POST to 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-Control header 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.

A matrix of four ways to send a read, against four questions: can the browser cache it, can a CDN cache it, does each read have its own URL, and how big is the request. REST GET: yes, yes, yes, small. GraphQL over POST: no, no, no, and the request is large because it carries the full query text. GraphQL over GET with the query in the URL: yes, yes, yes, but the URL gets long. GraphQL over GET with a persisted query hash: yes, yes, yes, and only a hash is sent.browserCDNown URLrequestREST GETyesyesyessmallGraphQL POSTnononolargeGraphQL GETyesyesyeslong URLGET + persistedyesyesyeshash only
Caches key on method and URL. 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.
A sequence diagram with the browser, a CDN, and the GraphQL server. The browser sends GET with only the hash ab12 to the CDN, which misses and forwards it to the server. The server does not know the hash yet and replies PersistedQueryNotFound. The browser sends a POST with the hash and the full query text; the server runs it, stores the hash, and replies with data. Later, a GET with the hash reaches the CDN, which fetches it once from the server and stores it for 60 seconds, then answers later users from its cache.BrowserCDNGraphQL serverGET ?hash=ab12miss: forwardPersistedQueryNotFoundPOST hash + querydata, hash storedlater, any userGET ?hash=ab12fetch once, keep 60 scached response
The full query text crosses the network once per server, not once per request. After that, reads are short 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 GET or a form POST without a preflight check. Never run mutations over GET, and accept only Content-Type: application/json or 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.

QuestionREST (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 screenOften 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 cacheGeneric server-state libraries keyed by URL.Normalized caches that know types and IDs.
Types for the frontendPossible with OpenAPI and codegen.Built in; codegen per operation is standard.
Server effortFixed work per endpoint, easy to reason about.Resolvers, batching, cost limits, schema governance.
Status codes and monitoringMeaningful status per request.Mostly 200; read the errors array.
Files and binary dataPlain PUT or POST of the bytes.Not in the spec; use a separate upload endpoint.
VersioningURL or header versions.Usually none: add fields, deprecate with @deprecated, remove when unused.
A flowchart with three questions. First: is the data deep and shared by several clients? If no, use REST. If yes, second: is it mostly public, cacheable content? If yes, server-render and cache at the CDN, using GraphQL only for logged-in parts. If no, third: can the team run batching, cost limits, and schema review? If no, use REST with a backend for frontend. If yes, use GraphQL with fragments and a normalized cache.Deep, shared data?several clients, many object typesnoRESTplain endpointsyesPublic, cacheable?articles, catalog, docsyesSSR + CDNGraphQL behind login onlynoCan you run it?batching, cost limits, schema reviewnoREST + BFFone call per screenyesGraphQLfragments, normalized cache
Most products end up mixed: GraphQL for logged-in, deeply nested screens, and plain cacheable URLs for public pages and files.

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 GET URLs 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?

Plain REST

Resource endpoints, sparse fields, include for embedding.

HTTP caching and a generic server-state library on the client.

Pros

  • Simplest server; every engineer knows it.
  • HTTP caching for free.

Cons

  • Several requests per deep screen.
  • New screen shapes need server changes.

REST plus a backend for frontend

One BFF per client type builds view-shaped responses from the REST services.

Clients make one call per screen.

Pros

  • One round trip per screen.
  • Plain HTTP caching still works.

Cons

  • One more service per client to run.
  • Shape logic is hand-written per screen.

GraphQL

One schema over the services; fragments per component; normalized client cache.

Persisted queries for first-party apps.

Pros

  • Any shape in one round trip, no server change.
  • Generated types and a normalized cache.

Cons

  • Server needs batching, cost limits, and governance.
  • HTTP caching takes deliberate work.

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?

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 GET endpoint 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 } }.

  1. Why: one round trip for a deep screen, exact fields, typed contract.
  2. Client: fragments colocated with components, a normalized cache keyed by __typename and id, optimistic updates for likes, and how a new post gets into the feed list.
  3. Server: DataLoader for N+1, cost and depth limits, persisted queries for first-party apps.
  4. Costs: lost HTTP caching and how you would win it back, 200 with errors, the extra operational work.
  5. 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

LevelWhat a good answer covers
JuniorWhat a query, mutation, and schema are; one round trip versus several; asking only for the fields the screen needs.
MidFragments per component, a normalized cache and why every object needs an id, cursor pagination with connections, N+1 and DataLoader.
SeniorCache 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.
StaffSchema 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 /graphql defeats browser and CDN caching; persisted queries over GET with cache hints win it back, and trusted documents also lock the server to known queries.
  • A 200 can still carry errors: design nullability for partial failure, put business errors in the schema, and monitor the errors array.
  • 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?

Question 2

Why does a normalized cache need every object to have an id, and what does it still not handle?

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?

Question 4

How do fragments help 40 engineers work on the same feed?

Question 5

The feed loads 50 posts with authors and comments, and the GraphQL server is slow. Where do you look?

References

Read next