{}The Interview
Handbook

Tracks / GraphQL

Schema design, N+1 & query cost

mid 10 questions · 9 min read graphqlapidataloadercaching

Questions in this set 10
  1. 01When is GraphQL the right choice, and when is REST better?
  2. 02Explain the N+1 problem in resolvers and fix it.
  3. 03How do you do authorisation in GraphQL?
  4. 04A client sends a deeply nested query. How do you stop it taking the service down?
  5. 05Caching is HTTP's strength. What do you lose, and what do you do instead?
  6. 06How do you version and evolve a GraphQL schema?
  7. 07What are fragments, and why does Relay insist on colocating them?
  8. 08How do mutations differ from queries, and how do you design them well?
  9. 09What is federation, and when is it worth it?
  10. 10How do you monitor and debug a GraphQL API in production?

GraphQL interviews reward candidates who can say precisely what it costs. Anyone can describe "ask for exactly the fields you need"; the hire is the person who knows that you have just given up HTTP caching, invited a resolver N+1, and exposed a denial-of-service surface — and knows what to do about each.

01

When is GraphQL the right choice, and when is REST better?

GraphQL wins when many different clients need different shapes of the same data (web, iOS, Android, partner integrations each over-fetching from a REST endpoint), when the UI is deeply nested and REST would need three or four round trips, when frontend teams need to iterate without waiting on backend endpoint changes, and when a strongly-typed schema with generated clients is worth real money in developer time.

REST wins when responses are cacheable by URL (GraphQL POSTs are opaque to CDNs and browser caches), when the API is public and consumers expect conventional HTTP semantics, when the surface is small and stable, when file uploads or streaming matter, and when your team does not have the appetite to operate query-cost analysis and per-field authorisation — both of which are mandatory, not optional.

The strongest version of this answer names the alternative: for internal service-to-service traffic, gRPC usually beats both. And many teams land on REST plus a small number of purpose-built aggregation endpoints, which captures most of GraphQL's benefit without the operational surface — worth saying, because it shows you are choosing rather than advocating.

02

Explain the N+1 problem in resolvers and fix it.

Each field resolves independently, so a list of 100 posts each resolving author fires 100 separate database queries — and the query looks innocent:

graphql
query { posts(first: 100) { title author { name } } }

The fix is DataLoader: batch the keys collected within one tick of the event loop, dedupe them, and resolve them in a single query.

js
const authorLoader = new DataLoader(async (ids) => {
  const rows = await db.author.findMany({ where: { id: { in: ids } } });
  const byId = new Map(rows.map(r => [r.id, r]));
  return ids.map(id => byId.get(id) ?? null);   // MUST return in the same order, same length
});

const resolvers = {
  Post: { author: (post, _args, ctx) => ctx.loaders.author.load(post.authorId) },
};

Three details that separate a real answer from a memorised one:

  1. The returned array must match the input keys in order and length — including nulls for misses. Getting this wrong silently returns the wrong author for the wrong post, which is a data-leak-shaped bug.
  2. Create loaders per request, in the context. A module-level loader caches across users and requests, which is both a stale-data bug and a cross-tenant disclosure.
  3. DataLoader solves the number of queries, not the depth. A deeply nested query still walks level by level; batching makes it 4 queries instead of 400, not 1.

Alternatives worth knowing: look-ahead resolvers that inspect the requested field set and build one join, and query-planning layers (Prisma, Hasura, PostGraphile) that compile a GraphQL query to a single SQL statement. Both are faster; both are more machinery.

03

How do you do authorisation in GraphQL?

Not at the endpoint — there is only one — and not in the gateway. Authorisation belongs on every field that exposes protected data, because a client can traverse to it through any path you did not anticipate:

graphql
query { publicPost(id: 1) { comments { author { email phoneNumber } } } }

The email field was safe on the Me type and is now reachable through three hops. That traversal is the characteristic GraphQL vulnerability.

Practical approach:

  • Put the authenticated principal in the context, resolved once per request.
  • Enforce in the data layer where possible — a repository that scopes every query by tenant cannot leak across tenants regardless of the query shape. This is far more robust than resolver-level checks, which must be remembered every time.
  • For field-level rules, use a schema directive (@auth(requires: ADMIN)) or a policy layer so the rule is declarative and auditable rather than scattered through resolvers.
  • Return null plus an error for unauthorised fields rather than failing the entire query, and make the nullability deliberate — a non-nullable field that errors propagates the null upward and can blank out the whole response.
  • Never rely on the client not asking. Introspection can be disabled in production (mild obscurity, not security); the schema is still discoverable by guessing.
04

A client sends a deeply nested query. How do you stop it taking the service down?

graphql
query { user(id:1){ friends { friends { friends { friends { posts { comments {}}}}}}}}

This is the standard GraphQL DoS: a small query producing exponential work. Because there is one endpoint and one HTTP method, ordinary rate limiting by URL does not help. Layered defences:

  1. Depth limiting — reject beyond ~7-10 levels. Cheap, crude, effective against the naive attack.
  2. Query complexity analysis — assign a cost per field (higher for lists and for expensive resolvers), multiply by requested pagination sizes, and reject above a budget. This is the real control.
  3. Rate limit by cost, not by request count — a token bucket where each query consumes its computed complexity. A client can make 1,000 cheap queries or 10 expensive ones.
  4. Pagination limits — cap first/last at, say, 100, and require a limit rather than defaulting to unbounded.
  5. Persisted queries / an allow-list — for a first-party client, the server accepts only known query hashes. This eliminates the entire class of problem and makes CDN caching possible again. It is the correct answer for a public app with a first-party frontend.
  6. Timeouts on the whole operation and on individual resolvers, plus AbortSignal propagation so a cancelled request stops the downstream work.

Also disable introspection and field suggestions in production, and turn off automatic persisted-query registration by untrusted clients (a known cache-poisoning vector).

05

Caching is HTTP's strength. What do you lose, and what do you do instead?

You lose almost all of it: every operation is a POST to /graphql with the query in the body, so browser caches, CDNs and reverse proxies see one opaque, uncacheable endpoint. What replaces it:

  • Normalised client caches (Apollo Client, urql, Relay) keyed by __typename + id. This is genuinely powerful — one entity updated in one query updates it everywhere on the page — but it requires every type to expose a stable global id, and it introduces its own class of bugs when a mutation returns a partial object and overwrites cached fields.
  • Server-side caching per resolver or per entity in Redis, which is where most real caching ends up.
  • Automatic Persisted Queries with GET — send a hash instead of the query, which makes the request cacheable by a CDN again. This is the main way teams recover edge caching.
  • @cacheControl / response cache plugins that compute a max-age as the minimum across the fields touched, then set the HTTP header for the whole response.
  • DataLoader's per-request cache deduplicates within a single operation — worth naming, because it is caching, but only for the lifetime of one request.
06

How do you version and evolve a GraphQL schema?

You generally do not version it. The standard practice is continuous evolution: add fields and types freely (additive changes are safe because clients request fields explicitly), and never change the meaning or type of an existing field.

To remove something: mark it @deprecated(reason: "Use fullName. Removal 2026-06-01."), measure real usage with field-level analytics — which GraphQL makes unusually easy, since you know exactly which clients request which fields — notify the consumers still using it, and remove only when usage reaches zero.

Guard it in CI with schema checks: diff the proposed schema against the current one, classify changes as safe or breaking, and validate breaking changes against recorded client operations from the last 30 days. That combination (usage analytics plus operation-checked breaking-change detection) is what makes schema evolution safer in GraphQL than in REST, and it is the strongest argument in GraphQL's favour.

07

What are fragments, and why does Relay insist on colocating them?

A fragment is a reusable selection on a type. Colocating a fragment with the component that uses it means a component declares its own data requirements, and the parent composes them:

graphql
fragment AvatarFields on User { id name avatarUrl }
fragment PostCard on Post { id title author { ...AvatarFields } }

The payoff is that adding a field to a component does not require editing a page-level query, and deleting a component removes its data requirement automatically — the over-fetching problem that REST has, reappearing inside GraphQL if all queries live at the page level.

Related: @include/@skip for conditional fields, @defer/@stream (now stabilising) for streaming slow parts of a response after the fast parts, and interfaces/unions for polymorphic results — where clients must select __typename and use inline fragments per concrete type.

08

How do mutations differ from queries, and how do you design them well?

Queries run in parallel; top-level mutation fields run serially, in the order they appear in the document, which is the only ordering guarantee GraphQL gives you. Nested fields inside each mutation's payload resolve in parallel as normal.

Design conventions that hold up:

  • One mutation per business action, not per CRUD verb: publishPost, cancelSubscription, refundOrder — named for intent so authorisation and auditing are meaningful.
  • A single input object argument so you can add optional fields without changing the signature.
  • Return a payload type, not the bare entity: { order, userErrors: [UserError!]! }. This is the important one — GraphQL's top-level errors array is for exceptional failures (auth, validation of the query itself, a crash), and it is a bad channel for expected business outcomes like "card declined" or "email already taken". Typed userErrors in the payload gives clients something they can branch on, with the field path attached.
  • Return the mutated entity with its id so normalised client caches update automatically instead of requiring a manual refetch.
  • Idempotency for anything that spends money — accept a client-generated key, exactly as you would in REST.
09

What is federation, and when is it worth it?

Federation composes several independently-deployed GraphQL services into one schema. Each subgraph owns some types and can extend types owned by others, and a gateway (or router) plans the query across them:

graphql
# reviews subgraph extends the users subgraph's type
type User @key(fields: "id") {
  id: ID! @external
  reviews: [Review!]!
}

It is worth it when several teams must ship independently behind one API and the alternative is a monolithic gateway that every team has to modify. The costs are real: an extra network hop and a query planner to reason about; distributed N+1s across subgraph boundaries; performance debugging that now spans services; and composition errors that only appear when two teams' schemas meet. Schema stitching is the older, more manual approach and is largely superseded.

If you have one backend team, federation is almost certainly overkill — and saying that is a better answer than describing the architecture.

10

How do you monitor and debug a GraphQL API in production?

The usual metrics do not work, because everything is one POST /graphql returning 200. You need GraphQL-aware observability:

  • Metrics per operation name (and enforce that clients send one — reject anonymous operations in production), not per endpoint: rate, error rate, p95 latency.
  • Per-resolver timing as tracing spans, so you can see that one field is responsible for 80% of the latency across many operations.
  • Errors returned in a 200 response body must still be counted and alerted on. A naive dashboard shows 100% success while every user sees a broken page — this is the single most common GraphQL observability failure.
  • Field-level usage analytics for deprecation decisions.
  • Query cost distributions, so you see an expensive client before it takes you down.

Tools: Apollo Studio, Hive, or OpenTelemetry instrumentation with a GraphQL plugin. The principle to state is that your monitoring must understand the schema, or it is measuring the wrong thing.