Full-Stack Questions

GraphQL Beyond Hasura

Schema design, nullability, DataLoader, cursor pagination, errors, mutations, limits, subscriptions, federation, and when not to use GraphQL.

Lesson 2922 min read#graphql#dataloader#api-design

On this page

  1. Words used in this lesson
  2. Schema design and naming
  3. Nullability: the part people get wrong
  4. Resolvers and the N+1 problem
  5. Cursor pagination (Relay-style connections)
  6. Errors: partial data plus an errors array
  7. Mutations: design and order
  8. Input validation
  9. Authentication and authorization in resolvers
  10. Protecting the endpoint: depth, cost, aliases, persisted queries
  11. Subscriptions
  12. Federation in one section
  13. When NOT to use GraphQL
  14. Common mistakes
  15. Interview questions
  16. Key takeaways
  17. What's next

In one line: knowing GraphQL as a client of Hasura is a start. A senior answer also covers how to design a schema, how resolvers cause the N+1 problem and how DataLoader fixes it, how to page, fail, validate and protect a public endpoint, and when GraphQL is the wrong tool.

Lesson 09 explains Hasura: the generated API, permissions, remote schemas, actions, events and the core ideas of queries, mutations and subscriptions. This lesson does not repeat that. Here you are the person who writes the server by hand with graphql-js (the reference JavaScript implementation) and has to answer: "What happens when a client sends a very deep query?" and "Why is my list endpoint making 101 database calls?" For the N+1 problem in an ORM, see Prisma in Production (Backend, Lesson 08), and for the REST versus GraphQL choice see Backend, Lesson 09. Input checks and error handling in general are in Backend, Lesson 03 and Lesson 04.

What I ran. Everything marked "I ran" was run in Node 22 with graphql 17.0.2 (the latest on npm when I tested) and dataloader 2.2.3, using in-memory arrays instead of a database. That covers the N+1 counts, DataLoader batching and caching, nullability, partial data and errors, input validation messages, serial mutations, cursor pagination, the depth rule, a toy cost rule, introspection blocking, a persisted-query lookup and a subscription. The spec statements come from the graphql/graphql-spec and graphql/graphql-over-http repositories and the Relay server guide in facebook/relay, which I read. Federation is read, not run: I did not start a gateway. Production HTTP behaviour (Apollo Server, Yoga, a CDN) is also not tested here.

Words used in this lesson

  • Schema is the typed description of your API: the types, the fields and which ones can be null. SDL (schema definition language) is the text format for it.
  • A resolver is a function that returns the value of one field. The server calls it only for the fields the client asked for.
  • Context is an object made once per request (current user, database handle, DataLoaders) and passed to every resolver.
  • N+1 problem is when you run 1 query for a list and then N more queries, one for each item (for example, 1 query for 100 posts and 100 for their authors).
  • DataLoader is a small library that collects all load(key) calls made in the same tick and calls your batch function once with all the keys. It also caches results per key.
  • Nullability is whether a field may return null. String may be null, String! may not.
  • Connection is the Relay-style list shape: edges (each with a node and a cursor) and pageInfo. A cursor is an opaque string that marks a position in a list.
  • Persisted query is a query saved on the server ahead of time, so the client sends only an id or hash instead of the full text.
  • Query depth is how many levels of fields are nested. Query cost (complexity) is a number you give to a query to estimate how much work it will cause.
  • Federation is a way to build one GraphQL API from several services (subgraphs) that each own a part of the schema.
  • Introspection is the built-in feature that lets a client ask the server for its schema (__schema, __type).

Schema design and naming

The schema is your public contract. Design it around what clients need, not around your database tables.

RuleWhy
Types are PascalCase, fields and arguments camelCaseConvention in nearly every tool and style guide
Enum values are SCREAMING_SNAKE_CASEConvention; they are constants
Name mutations verbNoun: createPost, publishPost, archiveProjectThe name tells what happens. Avoid generic updatePost for everything
Use ID for identifiers and custom scalars for DateTime, URLThe spec lets you point a scalar at its definition with @specifiedBy
Return domain objects, not table rows (Order.total, not orders.total_cents)You can change storage without breaking clients
Prefer specific fields over a JSON blobA JSON scalar hides the shape from tools and from validation
Use an enum for a fixed set of statesstatus: PAID is checked by the server
Do not version (/v2). Add fields, and mark old ones @deprecated(reason: "...")Clients choose their fields, so adding is safe. Removal comes later, after usage data shows nobody uses it
Make changes additive: new nullable fields, new types, new optional argumentsMaking a field nullable again (removing !), or an argument required, breaks old clients
GraphQL
type Post {  id: ID!  title: String!  body: String!  status: PostStatus!  author: User!  publishedAt: DateTime        # null while it is a draft  slug: String! @deprecated(reason: "Use id for links")}enum PostStatus { DRAFT PUBLISHED ARCHIVED }

A good schema review question: "If the table is split in two next year, does a client change?" If yes, you exposed the database, not the domain.

Nullability: the part people get wrong

In GraphQL every field is nullable by default. ! is a promise: "this will never be null". That promise has a price, and I ran each case to show it:

SchemaWhat I didResult
a: String (nullable)resolver throwsdata: { a: null, b: "ok" } plus one error with path: ["a"]. Other fields are fine
b: String! (non-null)resolver throwsthe error bubbles up to the nearest nullable parent. When there is none, the whole result is data: null
posts: [Post!]! and one post's boom: String! throwsone field of one postdata: null (every parent up the chain is non-null)
xs: [Int!] and the list is [1, null, 3]return a null elementxs: null, one error with path: ["xs", 1]
ys: [Int]! and the list is [1, null, 3]return a null elementys: [1, null, 3], no error

The last two lines are the trap: [Int!] means "a nullable list of non-null items", and one bad item removes the whole list.

How to decide:

  • Use ! only for fields you can always produce. A field that depends on another service or a permission should be nullable. Then a failure shows up as one null and an error, not as a blank screen.
  • A non-null field is safe when it comes from the same row (id, title).
  • Removing ! later is a breaking change (strict clients assumed a value), and so is adding a new required argument. Adding ! to an output field is usually safe for readers, but you cannot take the promise back, so be careful.
  • Clients should treat a nullable field with an error next to it as "unknown", not as "empty".

Resolvers and the N+1 problem

A resolver runs once per object. If Post.author makes a database call, then a list of 6 posts makes 6 author calls. I built a tiny schema with six posts and three authors:

JavaScript
const root = { posts: () => posts.map(p => ({ ...p,  author: () => db.users.find(p.authorId) })) }   // one call per post

Without DataLoader I counted 6 author lookups for { posts { title author { name } } }. With 100 posts it would be 100. Add the list query and you get "1 + N".

DataLoader fixes this. You create it once per request, put it in the context, and call load inside the resolver:

JavaScript
import DataLoader from 'dataloader'function makeLoaders(db) {  return {    userById: new DataLoader(async (ids) => {      const rows = await db.users.findMany({ id: { in: ids } })      const byId = new Map(rows.map(r => [r.id, r]))      return ids.map(id => byId.get(id) ?? new Error(`User ${id} not found`))    }),  }}// context per request: { loaders: makeLoaders(db), user }// resolver:  author: (post, _args, ctx) => ctx.loaders.userById.load(post.authorId)

What I ran:

  • The six author calls became one batch with the keys [1, 2, 3]. Duplicate keys were removed.
  • On a bigger query (posts { author, comments { author } } with 3 posts and 6 comments) there were 1 batch for comments and 2 batches for users: one for the post authors and one for the comment authors. Batching happens per tick, so each depth level makes its own batch. The second user batch asked only for user 4, because 1 to 3 were already cached.
  • The batch function must return an array of the same length as the keys, in the same order. When I returned a one-item array for two keys, both load calls failed with "DataLoader must be constructed with a function which accepts Array<key> and returns Promise<Array<value>>, but the function did not return a Promise of an Array of the same length as the Array of keys."
  • Returning an Error object at one position fails only that key: in my test the keys 1 and 3 resolved and key 2 rejected with "no row 2".
  • Loading key 1 again afterwards made no new batch (cached). After clear(1) it fetched again. maxBatchSize: 2 split five keys into batches of 2, 2 and 1.

Key rules:

  1. One DataLoader instance per request, never global. The cache lives as long as the loader. A global loader keeps data for the next user: wrong data, and a security leak when permissions differ.
  2. Return rows in key order. Build a Map by id and map the keys. Do not trust the database order.
  3. Loaders work for "has many" too: commentsByPostId returns an array per key. I ran it: 3 post ids gave one comments batch.
  4. After a mutation, clear or prime the cache (clear(id), prime(id, value)) if the same request reads the same id again.
  5. DataLoader solves the number of calls, not the cost of each call. A batch with 10,000 ids is still a slow query: use maxBatchSize.

A DataLoader batch function still needs a limit on list fields. If posts can return 100,000 rows, you have just made one huge query instead of many small ones. Always page lists (next section).

Cursor pagination (Relay-style connections)

limit/offset slows down with big offsets and skips or repeats rows when data changes (see Design Pagination: Offset vs Cursor for the database side). The GraphQL answer is the connection shape, described in the Relay server guide:

GraphQL
type Query { posts(first: Int = 10, after: String): PostConnection! }type PostConnection { edges: [PostEdge!]!  pageInfo: PageInfo! }type PostEdge { cursor: String!  node: Post! }type PageInfo { hasNextPage: Boolean!  hasPreviousPage: Boolean!  startCursor: String  endCursor: String }

Rules from the guide and from my run:

  • The client sends first: 3, after: <cursor>. The cursor is an opaque string: the guide says to base64 it "to remind viewers that the string is an opaque identifier". The client must not parse it.
  • To know whether there is a next page without a second request, fetch first + 1 rows, return first, and set hasNextPage from the extra row. I ran this on 7 items: page 1 returned ids 1 to 3 with hasNextPage: true and endCursor; passing that cursor gave ids 4 to 6 with hasNextPage: true; the next page gave the last item and hasNextPage false.
  • A cursor must point to a stable sort key: (created_at, id) with the id as a tie-breaker. In SQL this is WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT $3, which can use an index. Put the sort values inside the cursor.
  • Cap first (for example 100). A client that sends first: 1000000 is a denial-of-service request.
  • A totalCount field is useful but can be a slow COUNT(*). Make it optional, and compute it only when asked (it is a resolver, so it runs only if requested).

The connection shape looks heavy, but it gives you a place for extra data (cursor, edge fields like joinedAt) and for page information, and client libraries (Relay, Apollo relayStylePagination) understand it.

Errors: partial data plus an errors array

A GraphQL response can have both data and errors. The spec says execution errors do not stop the whole response. A failing nullable field becomes null and gets an entry with a message, locations and a path. I got this for a forbidden field:

JavaScript
{ "errors": [{ "message": "Not allowed", "locations": [{"line":1,"column":3}],    "path": ["secret"], "extensions": { "code": "FORBIDDEN" } }],  "data": { "secret": null, "ok": "fine" } }

Good practice:

  • Put machine-readable data in extensions, for example code: "FORBIDDEN". Clients switch on the code, never on the message.
  • Two kinds of errors. A request error (bad syntax, validation failure, bad variables) happens before execution: the response has errors and no data. An execution error happens inside a resolver: you may get partial data.
  • Do not leak internals. Replace unknown errors (a SQL error, a stack trace) with a generic message and log the real one with a request id. Throw GraphQLError for errors you meant to show.
  • HTTP status. The GraphQL-over-HTTP draft says: when there is no data, use an appropriate 4xx or 5xx code (for example 400 for a document that cannot be parsed, 422 for a validation failure); when data is not null and there is no error, 200; and it recommends the unusual 294 Partial Success when both are present. I only read this; I did not check what real servers do. Servers differ and many send 200 for everything, so monitor on the errors array, not on status codes, and tell your gateway and CDN how to treat each case.
  • Business errors that users must fix (email already used) are often better as data in the mutation payload (next section), not as top-level errors.

Mutations: design and order

A mutation changes data. Three habits:

  1. One input object and one payload type per mutation. The input is an input type, so you can add fields later. The payload is an object type, so you can return more than the changed record.
  2. Return what the client needs to update its screen: the changed object, and often the parent or list that changed.
  3. Expected failures go into the payload.
GraphQL
input CreatePostInput { title: String!  body: String!  clientMutationId: String }type CreatePostPayload { post: Post   userErrors: [UserError!]! }type UserError { field: [String!]  code: ErrorCode!  message: String! }type Mutation { createPost(input: CreatePostInput!): CreatePostPayload! }

Here "title is too short" is a userError with field: ["input", "title"], while "database is down" is a top-level error. This separates what the user can fix from what the system must fix.

Order. The spec says the root fields of a mutation run serially (one after the other), while the fields of a query may run in parallel. I ran it: the mutation { x y } where x waits 50 ms and y is instant logged x, y. The same two fields as a query logged y, x. So several mutations in one request are safe to rely on in order; nested fields inside one mutation are not serial.

Idempotency. A retry after a network error can create a second post. Accept a client-supplied key (idempotencyKey in the input) and store it, as in Payments and Third-Party Integrations (Lesson 23).

Input validation

There are three layers, and each catches different things.

  1. The type system rejects wrong shapes before your code runs. I ran four bad inputs against input NewUser { email: String!, age: Int }:
    • age: "x" gave Int cannot represent non-integer value: "x".
    • A missing email gave Expected value of type "NewUser" to include required field "email".
    • An unknown field extra gave Expected value of type "NewUser" not to include unknown field "extra".
    • email: null gave Expected value of non-null type "String!" not to be null.
    • With variables, { "email": 5 } gave Variable "$i" has invalid value at .email: String cannot represent a non string value: 5.
  2. Custom scalars and directives for formats: EmailAddress, PositiveInt, URL. A scalar's parseValue throws when the value is bad, so the error appears as a variable error before any resolver runs.
  3. Business rules in your service layer (unique email, stock available, state allowed). Return them as userErrors.

Never trust the type system for security. It checks types, not meaning: a String can still hold ' OR 1=1 --. Use parameterised queries, and validate length and format (Backend, Lesson 03). A newer spec feature is @oneOf input objects: exactly one field must be given. The spec text says "OneOf Input Objects require exactly one field be provided"; check your server library for support before you use it.

Authentication and authorization in resolvers

  • Authenticate once per request, outside GraphQL: read the cookie or token in the HTTP layer and put user in the context. Do not pass tokens as field arguments.
  • Authorize near the data, not only at the top. The same User object may be reached from me, from post.author and from a search, so a check inside only Query.user is not enough. Put the rule in the service or the loader (canViewEmail(viewer, user)), and call it from every path.
  • Field-level rules. User.email returns the value only to the owner or an admin, otherwise null with an error extensions.code = "FORBIDDEN". That is why the field must be nullable.
  • Do not rely on hiding the schema. Turning introspection off does not secure anything; it only makes exploring harder. I ran the NoSchemaIntrospectionCustomRule validation rule that graphql-js exports, and { __schema { types { name } } } was rejected with "GraphQL introspection has been disabled...". Attackers can still guess field names (some servers also suggest names in error messages, so check yours).
  • Authorization with batching. Put the viewer's permissions in the loader key or filter in the batch query. If the loader is per request (as it must be), the cache holds only data that this viewer may see, but make sure the batch function applies the filter.
  • Rate limits and ownership checks (WHERE owner_id = $viewer) stay mandatory for mutations. A resolver that loads post(id) and then deletes it without checking the owner is a classic bug (an IDOR, insecure direct object reference).

Protecting the endpoint: depth, cost, aliases, persisted queries

GraphQL gives the client power to build expensive queries. A public endpoint needs defence in layers.

Depth limit. A cycle in the schema (User.friends: [User!]!) lets a client nest forever. A validation rule walks the document and rejects queries that are too deep. I wrote a short rule with graphql-js and ran it with max 3: { me { friends { friends { friends { name } } } } } was rejected with "Query depth 5 exceeds max 3" (me counts as level 1), and { me { friends { name } } } passed. Real projects use a maintained rule such as graphql-depth-limit, or the limits that come with their server.

Cost limit. Depth is not enough: a shallow query can ask posts(first: 1000) { comments(first: 1000) }. Give each field a cost and multiply by the list size. My toy rule multiplies the first arguments down the tree: posts(first:10){ comments(first:50){ author{name} } } scored 1510 and a simple { me { name } } scored 2. Reject the query above a budget (for example 1000) before executing. Combine with the first cap in pagination.

Aliases and batching. A client can ask the same expensive field many times in one request using aliases: a1: login(u:"x") a2: login(u:"x") .... I ran it with 3 aliases and the resolver ran 3 times. So rate limiting by HTTP request is not enough for login-like fields: count resolver calls or put the cost in your rule, and limit the number of aliases or operations in a batch.

Timeouts and size. Set a request body size limit and an execution timeout. A client can disconnect, so pass an abort signal to the database calls when you can.

Persisted queries. Instead of accepting any query text, the server keeps a map id -> query. The client sends only the id. There are two goals, and they are different:

  • Smaller requests and CDN-friendly GETs ("automatic persisted queries": the client sends a hash, and if the server does not know it, the client sends the text once to register it). I ran only a toy lookup with a Map and a SHA-256 of the text; the unknown hash threw PersistedQueryNotFound.
  • Security: an allowlist. If production accepts only ids from your own build, nobody can send an arbitrary query, so depth and cost attacks mostly disappear. The GraphQL-over-HTTP draft even notes that a server may skip validation for persisted operations that were validated earlier. This works for your own apps; it does not work for a public API with unknown clients.

Subscriptions

A subscription is a long-lived operation. The server sends a result every time an event happens. graphql-js has subscribe(), which returns an async iterator of results. I ran a subscription whose tick resolver is an async function* and got three results {"data":{"tick":1}}, 2, 3, then it finished.

The spec does not say how events travel to the browser. The usual transports are WebSocket (commonly with the graphql-ws protocol) and Server-Sent Events. Points for production:

  • You need a pub/sub layer (Redis, Postgres LISTEN/NOTIFY, a broker) so that an event on server A reaches a subscriber connected to server B. An in-memory emitter works only with one process.
  • Authenticate the connection once, then check permission for each event, since a user's access can change while the connection stays open.
  • Subscriptions hold sockets open, so they use memory and need sticky load balancing or a gateway that supports them. Heartbeats, reconnection and back-pressure are the same issues as in Realtime: WebSockets, SSE and Socket.IO (Lesson 20).
  • For simple "refresh when something changes", polling or a refetch after a mutation is cheaper than a subscription. Use subscriptions for real live data like chat or presence.

Federation in one section

Federation splits one GraphQL API into several services called subgraphs. Each subgraph owns part of the schema. A gateway or router gets the client's query, builds a query plan, calls the subgraphs and merges the results, so clients still see one schema.

The core idea is the entity: a type that more than one subgraph can contribute to, identified by a key. For example the users subgraph defines type User @key(fields: "id") { id: ID! name: String! }, and the reviews subgraph adds type User @key(fields: "id") { id: ID! reviews: [Review!]! }. For a query { user { name reviews { body } } }, the router asks users for the user and then asks reviews for the reviews, sending the id. The federation helper fields used for this (a representation like { __typename: "User", id: "1" }) are the part that makes it work. I know this from the Apollo Federation design; I did not run a router for this lesson.

The graphql/composite-schemas-spec repository defines an open specification for the same idea, with directives such as @key, @lookup, @shareable, @provides, @external, @override and @inaccessible. Its README marks it as "Stage 0: Preliminary", so treat it as work in progress. On npm I saw @apollo/subgraph 2.15.1 and @apollo/gateway 2.14.4 when I looked.

When it helps: many teams own different parts of the data, and each wants to deploy alone. When it hurts: small teams (a gateway is another thing to run and debug), cross-subgraph joins that make a request slow (the router calls multiple services in sequence), and tracing an error to the right subgraph. Do not start with federation. Start with one schema; split along team boundaries when you have a real organisational need. Note that DataLoader still matters inside each subgraph, because the router sends a batch of keys to the entity lookup.

When NOT to use GraphQL

GraphQL is a good fit when many different clients need different shapes of connected data. It is a poor fit in these cases:

SituationWhy another choice is better
A small CRUD app with one web clientREST or tRPC is less setup, and you get HTTP caching easily
Public API with unknown clientsEvery client can craft costly queries; you must build cost limits and often use REST with fixed endpoints
File upload and download, big binary dataNot a native part of GraphQL; use a normal HTTP endpoint or signed URLs
Heavy HTTP/CDN caching of GET resourcesA single POST /graphql endpoint hides the resource from caches; you need persisted queries over GET or client caching
Server-to-server calls with a stable contractREST/OpenAPI or gRPC give simple contracts and generated clients
Streaming or long jobsUse SSE, WebSockets or a job queue (Lesson 19) and return a job id
A team with no time for schema review, monitoring and cost controlThe flexibility turns into support work

The cost of GraphQL is real: you need DataLoader, limits, schema governance, and client tooling. See REST vs GraphQL (vs Hasura) for the full decision, and Lesson 09 for the case where a generated API removes most of that cost.

Common mistakes

  • Making everything non-null, so one failing field blanks the page.
  • A global DataLoader (stale data and cross-user leaks).
  • A batch function that returns rows in database order instead of key order.
  • Unlimited first on lists, and no depth or cost limit on a public endpoint.
  • Authorization only on the top-level query, so another path reaches the same object.
  • Throwing raw database errors to the client.
  • Returning 200 for everything and never alerting on the errors array.
  • Exposing table columns as fields, and removing fields without checking usage first.
  • Using limit/offset pagination on a table that changes while clients scroll.
  • Starting with federation before there is more than one team.

Interview questions

What is the N+1 problem in GraphQL, and how do you fix it? (Must know)

A resolver runs once per object, so a list of 100 posts calls Post.author 100 times. If each call hits the database, I get 1 + 100 queries. I fix it with DataLoader: the resolver calls ctx.loaders.userById.load(id), and DataLoader collects all keys from the same tick and calls my batch function once. In my test, 6 author lookups became one batch [1, 2, 3]. I make the loaders per request, return results in key order, and still limit the list size.

How does DataLoader work, and what are the rules for the batch function? (Must know)

It batches load calls made in one tick and caches by key. The batch function gets an array of keys and must return a promise of an array of the same length and order. If not, every load fails with an error about the length. If the value for one key is an Error object, only that key fails. I tested that: keys 1 and 3 resolved and key 2 rejected. Duplicate keys are asked once, and a second load of a key is served from the cache until I call clear.

Why must DataLoader instances be created per request? (Trap)

The cache lives as long as the loader. A global loader would keep one user's data and give it to the next request: stale data, and a leak when permissions differ. It also never forgets, so memory grows. I create the loaders in the context factory, so they live and die with the request, and I clear or prime them after a mutation if the same request reads the same id again.

Does DataLoader batch across nested levels? (Senior signal)

It batches per tick, so each depth level gets its own batch. In my run, for posts with authors and comments with authors, there was one comments batch and two user batches: first the post authors, then the comment authors. The second user batch asked only for the id that was not cached yet. So the number of database calls follows the depth of the query, not the number of items. For very different data sources I also check the generated SQL, because a batch of ids can still be slow.

Explain nullability. When do you use !? (Must know)

Fields are nullable by default; ! promises a value. If a non-null field throws, the null bubbles to the nearest nullable parent, and with no nullable parent the whole data is null. I ran it: a throwing nullable field left its siblings intact, while a throwing non-null field inside [Post!]! gave data: null. So I use ! for things I can always produce, such as id and fields from the same row, and nullable for anything that depends on another service, a permission or a feature flag. Removing ! later breaks clients that assumed a value, and adding it is a promise I cannot take back, so I decide carefully at the start.

Follow-up: "What is the difference between [Int!] and [Int]!?" [Int!] is a nullable list of non-null items: one null item makes the whole list null (I saw xs: null with an error at path ["xs", 1]). [Int]! is a non-null list that may hold nulls (I saw [1, null, 3] returned with no error).

How do you design pagination in GraphQL? (Must know)

I use the Relay connection shape: edges { cursor node } and pageInfo { hasNextPage endCursor }, with first and after. The cursor is opaque (base64 of the sort key), and it points to a stable sort such as (created_at, id), used in a keyset WHERE clause that can use an index. I fetch first + 1 rows to compute hasNextPage, I cap first, and I make totalCount optional. Compared with offset, it stays fast for deep pages and does not skip or repeat rows when new rows arrive.

How do errors work in GraphQL? What would you return for a validation error in a mutation? (Must know)

A response can have data and errors together. A failing nullable field becomes null with an entry that has message, path and extensions. Request errors (syntax, validation, bad variables) come with no data. For something the user can fix, such as "title too short", I return it in the payload as userErrors with a field and a code. For system problems I throw an error with extensions.code, and I hide internal details and log them with a request id.

What HTTP status codes does a GraphQL endpoint return? (Senior signal)

Servers differ, and many return 200 for everything, which is why I alert on the errors array and not on status. The GraphQL-over-HTTP draft I read is more precise: when there is no data (parse error, validation error, bad variables), use a 4xx such as 400 or 422; for a request with data and no errors, 200; and for both data and errors it recommends 294. It also says a mutation sent with GET should get 405. I would check what my server and my CDN actually do before I write alert rules.

Are mutations run in parallel? (Trap)

No. The spec says the top-level fields of a mutation run serially, in the order written, while query fields may run in parallel. I tested two fields where the first waits 50 ms: as a mutation, they logged x, y; as a query, y, x. Fields nested inside one mutation payload are not serial. That is why I design one mutation per business action and not rely on side effects in resolvers of the payload.

How do you design a mutation and its payload? (Senior signal)

A verb-noun name, one input object and one payload object. createPost(input: CreatePostInput!): CreatePostPayload!. The payload returns the changed object and anything the client must refresh, plus userErrors for failures a user can fix. The input object and payload object let me add fields later without a breaking change. For safe retries I accept an idempotency key in the input and store it.

How do you validate input? What does the schema already do for you? (Must know)

The schema checks types and shapes before my code runs. I ran bad inputs and got clear messages: a string for an Int, a missing required field, an unknown field, a null for String!, and a wrong type in a variable. For formats I use custom scalars (EmailAddress, URL). For business rules (unique email, stock) I check in the service layer and return userErrors. The type system does not give security: a String can hold anything, so I still use parameterised queries and length limits.

Where do you put authorization in a GraphQL server? (Senior signal)

I authenticate once per request in the HTTP layer and put user in the context. I authorize close to the data, in the service or loader, and not only on the top-level query, because the same object can be reached through several paths (me, post.author, search). Sensitive fields such as email are nullable, and return null with FORBIDDEN for a viewer who may not see them. I also check ownership on every mutation to avoid IDOR bugs. Turning off introspection is not authorization.

How do you protect a public GraphQL endpoint from expensive queries? (Senior signal)

In layers. A validation rule for depth: I wrote one and rejected a query of depth 5 with a limit of 3. A cost rule that multiplies list sizes: my toy rule scored posts(first:10){ comments(first:50){ author{name} } } as 1510. A cap on first. A limit on aliases and batched operations, because three aliases ran the resolver three times in my test and one HTTP request can hide many calls. A timeout and a body size limit. And persisted queries as an allowlist if the only clients are my own apps. I also rate-limit by user or key, and I log slow queries.

What are persisted queries, and why use them? (Senior signal)

The server stores queries and the client sends an id or hash instead of the text. It gives smaller requests and lets a CDN cache GETs. As an allowlist it gives security: production runs only queries that my build registered, so a client cannot invent a costly one. The first kind (automatic persisted queries) falls back to sending the text once when the server does not know the hash. The limits: a public API with unknown clients cannot use an allowlist, and the registry must be updated on every client release. I ran only a toy lookup; real servers have ready-made support.

How do subscriptions work, and what changes when you run more than one server? (Senior signal)

A subscription returns a stream of results. In graphql-js, subscribe() returns an async iterator, and my test produced three events. The transport (WebSocket or SSE) is up to me. With several servers, an in-memory emitter only reaches users on the same process, so I need a pub/sub layer like Redis or a broker. I authenticate when the connection opens, check permissions for each event, and set up heartbeats and limits. For simple freshness I prefer a refetch after a mutation or polling.

What is federation, and when would you avoid it? (Senior signal)

Federation lets several services (subgraphs) each own part of one schema. A router plans the query, calls the subgraphs and merges the results. A type shared by services is an entity with a @key, so reviews can add reviews to a User that users owns. I would use it when separate teams need to deploy their parts alone. I would avoid it for a small team, because the router adds cost, latency and debugging work. I know it from the design, not from running a router for this lesson, and the open composite-schemas spec is still at "Stage 0: Preliminary".

How do you evolve a GraphQL schema without breaking clients? (Must know)

I do not version the API. I add new fields, types and optional arguments, which cannot break existing queries, and I mark old fields with @deprecated(reason: ...). Before I remove a field I check usage data (from traced operations or persisted query logs) and wait until no client uses it. Breaking changes are making a field nullable again, making an argument required, removing a value from an enum that clients send, and changing a type. I add a schema check in CI that compares the new schema with the old one.

When would you not choose GraphQL? (Must know)

For a small CRUD app with one client, a public API with unknown clients where I cannot control query cost, file upload and download, heavy CDN caching of GET resources, server-to-server calls where REST/OpenAPI or gRPC are simpler, and streaming or long jobs. GraphQL moves work from the client to the server: I need DataLoader, limits, schema review and monitoring. If the team cannot pay that, plain REST is the more honest choice. If the pain is only the cost of writing the API, a generated one like Hasura removes much of it (Lesson 09).

Your list query makes 101 database queries and the client says "but it is only one GraphQL request". How do you find the cause? (How would you debug)

One GraphQL request does not mean one database query. I turn on query logging or a tracing tool and look for the same query repeated with different ids, which is the N+1 pattern. I find the resolver (usually a relation field like Post.author), and replace the direct call with a per-request DataLoader. Then I count again: in my own test the count went from 6 to 1 batch. I also add a test that fails if a list query makes more than a set number of database calls, so the problem does not return.

Key takeaways

  • Design the schema around the domain. Add fields, deprecate old ones, do not version.
  • ! is a promise. A failing non-null field bubbles up to the nearest nullable parent. [Int!] and [Int]! are different.
  • Resolvers run per object, which causes N+1. Use a per-request DataLoader whose batch function returns results in key order and the same length.
  • Page with Relay connections: first, after, opaque cursors, a stable sort with a tie-breaker, and a cap on first.
  • A response can hold both data and errors. Use extensions.code, return fixable failures as userErrors, hide internals, and alert on errors.
  • Mutation root fields run serially, query fields may not. Use input and payload objects, and idempotency keys.
  • The type system checks shape, not meaning or safety. Authorize near the data, on every path.
  • Protect public endpoints with a depth limit, a cost limit, a cap on aliases and first, timeouts, and persisted queries.
  • Subscriptions need a pub/sub layer in a multi-server setup. Federation is for several teams, not for a first version.
  • Know when REST, gRPC or a generated API is the better answer.

What's next

Take one list query in a project you know and count its database calls with and without DataLoader. Then add a depth rule and a cap on first. For the generated-API view of the same ideas, read Hasura & GraphQL Interview Questions (Lesson 09), and for the trade-off against REST read REST vs GraphQL (vs Hasura).

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Related series