GraphQL Interview Questions · 2026

GraphQL Interview Questions (2026): Most Asked, With Answers

On November 6, 2018, ten companies, Facebook, GitHub, Shopify, Airbnb, Twitter, Coursera, Apollo, Elementl, Hasura, and Prisma, agreed to hand GraphQL over to a neutral home at the Linux Foundation instead of leaving it as one company's internal project (Linux Foundation, 2018). That's not a small list. It's the kind of company roster that explains why GraphQL interview questions show up across so many different stacks now, not just at Facebook-adjacent shops. If your target company runs a GraphQL API in production, at least one round of your loop is probably going to test whether you understand it past the tutorial level.

Most prep guides spend their word count on schema design, arguing over whether a field should be nullable, debating interfaces versus unions. Fine topics, but here's my opinion, and I could be wrong about it: the questions that actually separate a candidate who's built something real from one who's only read the docs are about resolvers and the N+1 problem. Schema mistakes get caught in code review. A resolver that quietly issues 200 database queries for a list of 200 items gets caught by an angry ops engineer at 2am, or worse, doesn't get caught until the bill arrives.

This page covers GraphQL interview questions across nine areas: what GraphQL actually solves versus REST, the schema and type system, queries and mutations and subscriptions, resolvers, fragments and variables, the N+1 problem and DataLoader batching, cursor-based pagination, error handling and caching, and the security questions around query depth and cost that tend to show up more in senior loops. Code examples use GraphQL SDL for schemas, GraphQL query syntax for operations, and JavaScript, the ecosystem DataLoader itself was built in.

52Questions
Resolvers & N+1Core Topic
Schema, Query & JSFormat
Linux Foundation, 2018Foundation

What GraphQL actually solves versus REST

Every loop starts here, and it's a fair warm-up question. A shaky answer on why GraphQL exists at all makes an interviewer wonder if you've only ever consumed an API somebody else already designed.

Easy questions

15

GraphQL is a query language for APIs and a runtime that executes those queries against a typed schema, not a database and not a framework tied to one language. A client sends a query describing exactly the fields and nested relationships it wants, the server resolves each field against your data sources, and it returns a response shaped like the query. There's usually one HTTP endpoint, commonly a POST to /graphql, instead of one URL per resource.

SDL, the Schema Definition Language, is GraphQL's own syntax for declaring types, the fields on each type, and the arguments each field accepts. It's not JSON and it's not tied to any host language; a Node server and a Python server can both read the same.graphql file.

graphql
type Book {
 id: ID!
 title: String!
 publishedYear: Int
 author: Author!
}

type Author {
 id: ID!
 name: String!
 books: [Book!]!
}

type Query {
 book(id: ID!): Book
 books: [Book!]!
}

Query is the entry point every read operation starts from. Everything else is an object type with fields, some scalar (ID, String, Int, Float, Boolean), some pointing at other object types.

Naming convention aside, the meaningful difference is execution order. Query fields at the top level execute in parallel, since reads generally don't depend on each other finishing first. Mutation fields at the top level execute serially, one completes before the next starts, because writes often do depend on ordering. You don't want a batch of mutations racing each other against the same row.

A resolver is a function attached to one field in the schema that returns that field's value, everything from a top-level Query.user field down to a leaf field like User.email. If you don't write a custom resolver, most GraphQL server implementations fall back to a default resolver that just reads a same-named property off the parent object, which is why simple pass-through fields, User.name reading straight off a user row, often need zero resolver code at all.

A fragment is a named, reusable selection set. fragment UserCard on User { id name avatarUrl } lets you write those three fields once and spread them into any query that needs a user card's worth of data with...UserCard, instead of retyping the same three fields in a dozen queries and having to update all of them when a designer adds a field to the card. It's the GraphQL version of not repeating yourself, and on a codebase with a few hundred queries it matters more than a toy example makes it look.

Offset pagination asks the database for rows skip through skip+limit, ordered somehow, on every request. If a new row gets inserted at the front of that order between page 1 and page 2 of someone's scroll, everything shifts by one position, and the user either sees the same row twice or skips one entirely, without either the client or the server doing anything wrong. It's also just slow at scale: skipping 100,000 rows to reach page 2,001 usually means the database still has to walk past those rows first, depending on the index.

Because a single GraphQL request can partially succeed. A query asking for five fields might resolve four of them fine and hit an error on the fifth, and GraphQL's answer to that is to return both a data object with whatever did resolve and an errors array describing what didn't, in the same 200 response, rather than failing the whole HTTP request over one broken field. Some teams return non-200 status codes for certain categories of failure, auth errors, malformed queries that never reach execution, but the general convention for partial execution failures is 200 with an errors array.

The schema is the single source of truth for every type, field, and operation an API supports, written in SDL (Schema Definition Language). It is not just documentation. The server validates every incoming query against it before execution ever starts, so if a field is not declared in the schema, no resolver can conjure it into existence. The request fails validation before a single resolver runs.

Because it is a formal artifact rather than prose, tooling can generate client types, API docs, and even mock servers straight from it. That is the practical reason schema-first teams treat a schema change like an API contract change, reviewed in a pull request just like code.

Five: Int, Float, String, Boolean, and ID. Int and Float are both signed numbers, the difference is precision handling on the wire, not a JavaScript-style distinction. ID looks identical to a string in JSON but signals to tooling that the value is an opaque identifier rather than displayable text, so a client should not sort it alphabetically or render it expecting readable content.

Anything outside those five, dates, money, JSON blobs, UUIDs with format validation, needs a custom scalar. The built-ins cover primitives only, nothing about business-specific formats.

A custom scalar is a type you define yourself with your own serialization rules, most commonly DateTime, but also things like EmailAddress or a Money type that carries currency alongside an amount. You need one whenever a built-in scalar would technically work but silently allows garbage, a String field for a date lets someone submit "next Tuesday" and your resolver has no way to reject it at the schema level.

Implementing one means providing serialize, parseValue, and parseLiteral functions:

javascript
const DateTimeScalar = new GraphQLScalarType({
 name: "DateTime",
 serialize(value) { return value.toISOString(); },
 parseValue(value) { return new Date(value); },
 parseLiteral(ast) {
  return ast.kind === Kind.STRING ? new Date(ast.value) : null;
 },
});

Now invalid dates get rejected at the schema boundary instead of crashing three layers deep in business logic.

Variables keep the query document itself static while only the values change between requests. That matters for three separate reasons: it avoids string-escaping bugs when a value contains quotes or special characters, it lets a server treat the query text as cacheable and even pre-register it for persisted queries, and it keeps client code from building queries through string concatenation, which is exactly the kind of pattern that leads to injection-style bugs even in a typed API.

In short, the query shape and the data going into it are two different concerns, and variables are how GraphQL keeps them separate.

Aliasing lets you request the same field more than once in a single query with different arguments, by giving each instance a different name in the response. Without it you cannot ask for a field twice in one selection set, since the response key would collide.

A concrete example: fetching a user's posts filtered two different ways in one round trip.

graphql
query {
 published: posts(status: PUBLISHED) { title }
 drafts: posts(status: DRAFT) { title }
}

Without aliasing you would need two separate requests for something that is really one logical screen's worth of data.

An enum restricts a field to a fixed, named set of values instead of an open-ended string. For something like order status, PENDING, SHIPPED, CANCELLED, an enum means the server rejects any value outside that set at validation time, and client tooling gets autocomplete for the exact allowed options instead of guessing at string literals from documentation.

The alternative, a plain String field with a comment saying "must be one of pending, shipped, cancelled", relies entirely on developer discipline and gives you zero enforcement. An enum makes the invalid state unrepresentable at the type level.

Introspection is the ability to query the schema itself through meta-fields like __schema and __type, asking the server what types, fields, and arguments it supports. Tools like GraphiQL, Apollo Studio, and codegen all depend on it to build their UI and generate typed client code without a human copying documentation by hand.

Some teams disable it in production, partly to reduce how much internal schema shape an outside party can enumerate for free, and partly because internal-only fields sometimes leak into the public schema by accident. That said, disabling introspection is security through obscurity at best. The actual protections against abuse are authentication, authorization, and query complexity limits, not hiding the menu.

An Input Object type exists only to structure arguments going into the server, it can never be used as a return type. Its fields must themselves be scalars, enums, or other input types, never a regular output type or an interface. A regular Object type is for shaping data coming back out, and its fields can have their own resolvers, arguments, and reference other output types freely.

graphql
input CreateUserInput {
 name: String!
 email: String!
}

type Mutation {
 createUser(input: CreateUserInput!): User!
}

Trying to reuse the same type for both directions is a common beginner mistake, the two shapes are enforced separately for a reason: what a client sends you and what you send back rarely need identical fields.

Medium questions

25

REST hands the client whatever fields the endpoint's contract defines, take it or leave it. If a mobile screen needs a user's name and avatar but the /users/:id endpoint returns 40 fields, that's over-fetching, wasted bytes on every request. If that same screen also needs the user's last three orders and the endpoint doesn't include them, that's under-fetching, and the client either makes a second round trip to /users/:id/orders or the backend team ships a new endpoint shaped for that one screen. GraphQL fixes both by letting the client specify the shape of the response inside the query itself: one request, exactly the fields asked for, nested relationships included. That flexibility isn't free, though, a single endpoint means the server can't rely on the URL alone to know what work a request will trigger, which is most of why caching gets harder. More on that later.

It marks a field or argument as non-null. Author! on Book.author means the server guarantees a value there, never null, and if a resolver somehow returns null for a non-null field, GraphQL doesn't just null that one field out. It nulls the closest nullable parent up the tree and reports an error, which can wipe out a much bigger chunk of the response than you'd expect from one missing record. [Book!]! is the one that catches people out: a non-null list of non-null books, not "a list that might be empty." An empty list ([]) satisfies that type fine. A list containing one null entry doesn't. I've seen a bug where a soft-deleted book stayed in a list as a null placeholder and nulled out the entire parent object, because the list type had been declared non-null all the way down.

Both let one field return more than one possible object type, but an interface requires every implementing type to share a set of common fields, while a union just says "this field returns one of these unrelated types, no shared fields required." SearchResult that could be a Book, an Author, or a Magazine, with nothing meaningfully in common between them, is a union. Media that could be a Book or a Movie, both of which always have a title and a releaseYear, fits an interface better, since callers who don't care about the specific type can still query title without writing a type-specific fragment for each branch.

Nested fields inside a single mutation's selection set, the data you're asking to get back after the write, still resolve the normal way, which can include parallel resolution below the top level. The serial guarantee is specifically about the sequence of top-level mutation fields in one request, addUser and then deleteUser sent in the same operation, not about every resolver in the whole tree. Miss that distinction and you'll say something that sounds right but falls apart under a follow-up question.

A subscription is a long-lived operation the client keeps open, typically over a WebSocket using the graphql-ws protocol (the older subscriptions-transport-ws is largely deprecated at this point), rather than a request-response call that ends the moment a response comes back. The client sends a subscription query once, the server keeps that connection alive, and every time an event the subscription cares about fires, a new comment posted, a price change, the server pushes a new payload down the same connection. The part that trips candidates up: subscriptions need a pub-sub layer behind them somewhere, an in-memory emitter for a single server, Redis pub-sub or a queue once there's more than one server instance, because the server that handles the mutation that changes the data isn't necessarily the same server holding the WebSocket connection to the client who needs to hear about it.

parent is the value the resolver one level up already returned, which is how a Book resolver knows which author's books to fetch without re-querying from scratch. args holds whatever arguments the client passed to that specific field. context is shared across every resolver in one request, database connections, the authenticated user, and, this matters a lot for the next section, any DataLoader instances scoped to that request. info carries execution metadata most candidates never touch outside advanced tooling.

javascript
function resolveAuthorBooks(parent, args, context, info) {
 // parent: the resolved value from the parent field (here, an Author)
 // args:  arguments passed in the query, e.g. { limit: 10 }
 // context: shared per-request data (db connection, current user, dataloaders)
 // info:  field-level execution metadata, rarely needed day to day
 return context.dataSources.books.findByAuthor(parent.id, args.limit);
}

A named fragment (fragment X on Type {... }) is a standalone reusable block spread with...X anywhere that type applies. An inline fragment (... on Type {... }) is written directly inside a query without a separate name, and it's specifically for a field that returns an interface or a union, where the response could be one of several possible types and you need type-specific fields that don't exist on every branch.

graphql
query SearchAll($term: String!) {
 search(term: $term) {
 ... on Book {
   title
   publishedYear
  }
 ... on Author {
   name
   bookCount
  }
 }
}

Query a field typed SearchResult, a union of Book and Author, and you can't just ask for title, since Author doesn't have one. Inline fragments handle that: "if this is a Book, give me these fields, if it's an Author, give me these other ones."

One query fetches a list of N items (1 query), then a naive resolver fires one additional query per item to fetch related data (N more queries), for N+1 total round trips to a database or backend service where one or two would have done the job. A list of 50 posts where each post's author resolver hits the database separately is 51 queries for what should be closer to two.

Instead of paging by numeric position, a cursor identifies a specific row, often an opaque, encoded value derived from something stable like a row ID, not a raw offset, and the client asks for the N items after that cursor. Insertions and deletions elsewhere in the list don't shift what "after this cursor" means, since the cursor points at a record, not a position. This is standardized as the GraphQL Cursor Connections Specification (Relay documentation), and even teams that skip Relay's client library often adopt the edges, node, cursor, pageInfo shape because enough tooling expects it that reinventing your own format costs compatibility for no real benefit.

graphql
type BookConnection {
 edges: [BookEdge!]!
 pageInfo: PageInfo!
}

type BookEdge {
 node: Book!
 cursor: String!
}

type PageInfo {
 hasNextPage: Boolean!
 hasPreviousPage: Boolean!
 startCursor: String
 endCursor: String
}

query {
 books(first: 10, after: "opaqueCursorValue") {
  edges { node { title } cursor }
  pageInfo { hasNextPage endCursor }
 }
}

Each entry in the errors array typically carries a message, a path (which field in the tree the error came from, as an array like ["user", "posts", 2, "author"]), locations pointing at where in the query document the field was requested, and often an extensions object with a machine-readable code like UNAUTHENTICATED or NOT_FOUND for client-side branching. A field that's genuinely, legitimately null, a user with no middle name, shows up as null in data with nothing in errors. A field that failed to resolve shows up as null in data plus a matching entry in errors with that field's path, the only reliable way for a client to tell the two apart.

A REST endpoint has a fixed, known amount of work per request, the server team wrote it and knows roughly what it costs. GraphQL lets a client compose arbitrary nesting on the fly, user { friends { friends { friends { friends { name } } } } } five levels deep, and if each level fans out to dozens of related records, the resolved data set can grow exponentially with nesting depth even though the query text itself is short and looks harmless. A badly written, or malicious, client can accidentally construct a request that costs the database and CPU far more than the size of the query string suggests.

Field aliasing. GraphQL lets a client request the same field multiple times under different names in one query, a1: expensiveField a2: expensiveField, repeated fifty times, all at the same nesting depth, which stays inside any reasonable depth limit while still forcing the server to run that expensive resolver fifty separate times. Cost analysis assigns a numeric cost per field, a cheap scalar might cost 1, a resolver hitting an external API might cost 20, and rejects the query if the total exceeds a budget, catching the aliasing trick that depth limiting alone misses.

Schema-first means writing the SDL by hand first, then implementing resolvers that match it, the contract exists before any implementation code. Code-first means defining types directly in your programming language, using something like Nexus, TypeGraphQL, or Pothos, and letting the schema get generated from that code, so the types you write for resolvers and the schema stay in sync automatically instead of by discipline.

Large multi-team schemas, especially ones using federation, tend to lean schema-first because the SDL is the natural artifact to review in a pull request, a reviewer can see exactly what's changing in the public contract without reading implementation code. Smaller single-team services often prefer code-first because full type safety end to end, from resolver argument types straight through to the schema, matters more day to day than having a clean standalone SDL file to diff.

GraphQL's actual answer to versioning is to not version at all, and instead evolve the schema continuously. Adding new fields and types is non-breaking by nature, existing clients simply ignore fields they don't ask for. Fields that need to go away get marked with @deprecated and a message pointing at the replacement, and stay fully functional while usage telemetry confirms nothing is still querying them.

A genuine breaking change, renaming a field, tightening a nullable field to non-null, changing what an enum value means, gets modeled as an entirely new field living alongside the old one rather than a version bump. You end up with price and priceV2 coexisting for a while, not a whole new endpoint. The schema grows additively and shrinks only after telemetry proves it's safe.

@deprecated flags a field in the schema with a reason, visible through introspection, so tools like GraphiQL and Apollo Studio show it with a strike-through and a message pointing at the replacement.

graphql
type User {
 name: String!
 oldName: String @deprecated(reason: "Use name instead")
}

It doesn't stop the field from working, and that's the point, existing clients keep functioning exactly as before. Used responsibly, you pair it with actual usage analytics on that field broken down by client version, so you know precisely when real traffic against it hits zero before you ever remove it from the schema. Deprecating without measuring usage is just guessing at when it's safe to delete.

They conditionally include or exclude a field in a single query document based on a boolean variable, instead of maintaining two nearly identical query documents in your client code.

graphql
query GetProfile($debug: Boolean!) {
 user {
  name
  email
  internalId @include(if: $debug)
 }
}

A real use case: the same query powers both a lightweight profile card and a full detail view, and a UI-state boolean toggles whether the extra fields get requested at all, without the client needing to branch between two separate query strings.

Apollo Client's InMemoryCache stores every object under a cache key built from __typename plus id, unless you configure custom keyFields for a type. Results from two entirely different queries that both happen to reference the same entity get merged into one canonical record instead of living as two separate copies, so updating that entity anywhere in the app refreshes every screen showing it.

For that to work, every type in your schema needs a stable, unique identifier field, or you have to configure keyFields explicitly for types that lack one. Without a usable id, Apollo can't dedupe the object, it falls back to treating the result as an opaque blob tied only to its exact query, and a mutation elsewhere in the app won't automatically refresh that same entity shown somewhere else. This is one of the more common causes of "stale UI after a mutation" bugs that have nothing to do with the mutation itself.

Returning the bare entity works fine for the simplest case, but breaks the moment a mutation needs to report anything beyond the record itself, partial validation errors, warnings, a newly generated related resource, pagination info for a list that changed.

graphql
type UpdateUserPayload {
 user: User
 errors: [FieldError!]
 clientMutationId: String
}

Relay-style conventions expect exactly this shape, which also gives optimistic UI code a consistent place to look for both the result and any errors, instead of overloading the entity type itself with fields that only make sense in the context of one specific mutation.

A raw binary file doesn't fit naturally into a JSON request body, so the de facto approach is the graphql-multipart-request-spec, implemented by libraries like graphql-upload and Apollo Upload Client. The request becomes multipart/form-data with the GraphQL operation as one part, the file itself as a separate part, and a map field tying variable paths in the operation to the specific file part. Server-side, the resolver streams the incoming file straight to disk or cloud storage rather than buffering the whole thing in memory, which matters once someone uploads something large.

A lot of production systems skip this entirely and prefer a two-step flow instead: a mutation returns a pre-signed upload URL, the client PUTs the file directly to S3 or equivalent storage, then a second mutation confirms completion and attaches the resulting object key to whatever record needed it. That keeps large binary transfer off your GraphQL server entirely.

subscriptions-transport-ws was the original Apollo subscription protocol and has been unmaintained for years, with no proper connection acknowledgment step and messy error handling on the wire, it can silently hang through certain network transitions. graphql-ws is the actively maintained replacement with a real handshake (connection_init followed by connection_ack), ping/pong keepalive frames, and cleaner complete and error message framing.

Mismatching the two between client and server is a classic real-world bug: the WebSocket connects fine, stays open, but zero events ever arrive because the sub-protocol string negotiated during the handshake doesn't match what the other side expects, and the server just silently ignores frames it doesn't recognize instead of erroring loudly.

Resolver-level authorization means checking context.user permissions inside each resolver body before touching data. It's straightforward to write but easy to forget on a brand new field, and the same permission-check code ends up duplicated across the schema.

Directive-level authorization declares the rule right on the field in SDL, something like @auth(role: "admin"), with a schema transform that wraps the underlying resolveFn automatically. That makes the protection visible to anyone reading the schema and much harder to forget when a new field is added, though the actual permission logic still has to live somewhere in code, the directive is just where you invoke it from declaratively. Most production schemas end up mixing both, directives for coarse field-level and type-level gates, explicit resolver checks for row-level rules that depend on runtime data, like whether this specific user owns this specific record.

[Type]! means the list itself can never be null, an empty array is fine, but individual items inside it can be null. [Type!]! means neither the list nor any single item inside it can ever be null.

The gotcha shows up when one resolver in a list of, say, 200 items throws an error. With [Type!]! the whole list collapses to null and the error bubbles up to the parent field, potentially nulling out far more of the response than just the one broken item. With [Type]! that same failure just leaves a null in that one slot, and the client gets 199 good items plus one null instead of nothing. Teams often reach for [Type!]! by default because it looks stricter and safer, then get surprised in production the first time a single bad record wipes an entire feed down to null.

Mock the data layer, whatever repository or service functions your resolvers call, and test resolver logic in isolation. That covers argument handling, error mapping, and field-level business rules quickly and without flakiness. Then keep a smaller, separate set of true integration tests that run the full schema against a real or dockerized database, which is what actually catches wiring bugs between the schema and the resolvers, the kind of mismatch unit tests with mocks can't see.

Snapshot testing full query responses is useful for catching accidental shape changes to the schema, but snapshotting everything is brittle, a legitimate one-field addition can break dozens of unrelated snapshots in one commit. Scope snapshots narrowly to the fields actually under test in that suite, not the whole response.

Sending multiple named operations in a single HTTP request, or Apollo's query batching, is about cutting down round trips and connection overhead between client and server. It has nothing to do with what happens once those operations start resolving on the server, you can batch ten operations into one request and still hit N+1 database calls inside each one.

DataLoader batches at a different layer entirely, resolver to data source, deduping and grouping the many small key-lookup calls a set of resolvers generate within a single execution into one underlying query. They solve two different problems, and a well-built production API usually wants both, fewer round trips from the client to the server, and fewer queries per round trip once execution starts.

Instead of sending the full query text over the wire on every request, the client sends a hash, or the server has the exact query string pre-registered ahead of time, and the server looks up the actual query by that identifier. Bandwidth savings are real, especially for large queries repeated constantly, but they're the smaller benefit.

The bigger production value is that a server can enforce an allowlist, only the pre-approved set of query shapes can execute at all. That shuts down arbitrary ad hoc queries an attacker, or a rogue internal script, might try to run against a public-facing API, and it makes query complexity auditing tractable, since you know the entire finite set of shapes that can actually reach your resolvers instead of an open-ended surface.

Hard questions

12

Yes, and it's the part GraphQL evangelists tend to skip. REST caching works because a GET to /users/42 always returns the same resource at the same URL, so a CDN or reverse proxy can cache against that URL with standard HTTP semantics, ETags included. GraphQL operations are usually POST requests with a query in the body, and two different queries hitting the same endpoint can ask for completely different data, so there's no URL to key a cache off by default. Whether that trade-off is worth it depends on what you're building: a handful of tightly coupled frontends, one team controlling both ends, gets real value from the flexibility. A public API serving thousands of unknown consumers, where a CDN should do most of the work for free, is a much harder sell without extra infrastructure like persisted queries. I don't think GraphQL is the right default for every API, and I'd push back on anyone who tells you it is.

Not exactly, it's the expected default behavior. GraphQL resolvers are independent by design, one field doesn't know or care what a sibling field is doing, which is what makes a schema easy to reason about and extend. Nobody wrote coordination logic between resolvers, so nothing batches those 50 separate Post.author calls into one query unless something is added that does. That something is DataLoader.

Each call to.load(id) doesn't hit the database immediately. DataLoader collects every.load() call made during the same tick of the event loop, waits until that tick ends, then fires one batchLoadFn call with all the collected keys at once, one query for 50 author IDs instead of 50 separate queries for one ID each. The resolver code barely changes, it's still calling load(post.authorId) per post; the batching happens underneath without touching the resolver's shape at all (GraphQL.js documentation).

javascript
const authorLoader = new DataLoader(async (authorIds) => {
 const authors = await db.authors.findByIds(authorIds);
 const byId = new Map(authors.map(a => [a.id, a]));
 return authorIds.map(id => byId.get(id)); // must match input order
});

// inside the Post.author resolver:
function resolvePostAuthor(post, args, context) {
 return context.authorLoader.load(post.authorId);
}

One detail interviewers specifically probe: the batch function has to return results in the exact same order as the keys array it received, not just the same set of results. Get the ordering wrong and DataLoader silently attaches the wrong author to the wrong post.

It fixes the specific failure mode of duplicate per-item queries within one request, but it doesn't fix everything people assume it does. It caches per request only (a fresh instance per incoming request, so one user's cached data never leaks into another's), which means it does nothing for repeat requests across users or across time. It also doesn't fix a slow batchLoadFn itself: if the underlying query is missing an index, batching 50 lookups into one slow query is still a slow query, just one slow query instead of fifty. My honest take: DataLoader is necessary but it gets treated like a complete performance story, and it's really just the first fix. Teams that stop at "we added DataLoader everywhere" and never check their actual database query plans are leaving a lot of the real win on the table.

A few layered approaches, since none fully replaces REST's URL-based HTTP caching on its own. Client-side normalized caches (Apollo Client's InMemoryCache, the Relay store) key every object by its typename plus id and de-duplicate overlapping data across queries, so asking for the same Book twice on two screens doesn't re-fetch it if it's already in the store and fresh. Persisted queries register a query's text ahead of time so the client can send a short hash instead of the full string, which, turned into a GET request, makes CDN-level caching possible again. Field-level directives, Apollo Server's @cacheControl being the common example, let a schema author declare how long a specific field is safe to cache.

Schema stitching manually merges multiple independent GraphQL schemas into one gateway schema, and whoever does the stitching has to write and maintain the logic connecting types across schema boundaries by hand, which gets brittle fast as more services join. Federation, Apollo's version is what most teams mean by the word, instead lets each service, called a subgraph, declare which types it owns and which fields of another subgraph's type it can extend, using directives like @key to mark the field that identifies an entity across subgraphs. A gateway process composes those subgraphs into one supergraph automatically, no human hand-writing the merge logic.

I don't have hands-on experience running federation at genuine multi-team scale myself, only smaller setups, so take operational-complexity claims from vendor docs with some skepticism until you've felt them firsthand. What I'll say with more confidence: for a single team owning one schema, federation is overhead you don't need yet. It earns its cost once multiple teams would otherwise be stepping on each other's schema changes.

@defer marks part of a query, usually a fragment, as acceptable to arrive later than the rest of the response, so the server can send the fast fields immediately and stream slower ones as a follow-up patch over the same connection. @stream does the equivalent for list fields specifically, sending items as each one resolves instead of waiting for the entire list to finish.

Both require the transport to support multipart mixed responses rather than a single flat JSON body, a client library that knows how to reassemble incremental chunks back into one logical result, and resolvers actually structured so the deferred data fetching happens independently of the fast path. If the deferred fragment sits on the same underlying data source as the fast fields and effectively blocks on the same query anyway, you've gained nothing but added response-parsing complexity, since there was no real concurrency to gain in the first place.

Depth limiting only caps how deeply a query nests, and all 500 aliased calls sit at depth one, so depth limiting never even sees the problem. A naive complexity calculator often scores based on the shape of the query tree rather than counting repeated invocations of the same field at the same level, so a flat list of 500 aliases can still look reasonable to a scorer that isn't specifically summing cost across siblings.

graphql
query {
 a1: expensiveField { id }
 a2: expensiveField { id }
 a3: expensiveField { id }
}

The actual defenses: sum total complexity across the whole document rather than per-branch, so 500 cheap-looking siblings add up to something real. Rate-limit by cost consumed per time window per client or token, rather than relying entirely on a hard per-request ceiling. And specifically detect and cap alias count on any single field name within one document, since legitimate use of aliasing rarely needs hundreds of repeats of the identical field.

Subscriptions run over a long-lived WebSocket connection, and load balancers doing round-robin routing or connection draining during deploys will happily forward the initial upgrade handshake, but many default to an idle timeout, often 60 seconds, that silently tears down a connection sitting quiet with only occasional ping frames. In dev there's no load balancer in the path at all, so the problem never shows up until production.

The deeper issue when you're running multiple subscription server instances behind that same load balancer: if there's no sticky session and no shared pub/sub backplane like Redis pub/sub or a message broker, the server instance that eventually publishes an event isn't necessarily the same instance holding that particular client's live socket. The event gets published successfully, but the one process actually holding the subscriber's connection never hears about it, so from the client's perspective the subscription just silently goes quiet.

The fix is two things together, keepalive pings sent more frequently than the load balancer's idle timeout, and a shared pub/sub layer so any server instance can fan events out to any connected client regardless of which instance the originating mutation happened to land on.

The core discipline is additive-only changes to any part of the schema old clients still touch. New fields are safe. What you never do is change a field's type, tighten its nullability, remove it outright, or change what an enum value semantically means, because unlike a web frontend, you can't force-redeploy an app already installed on someone's phone.

In practice, when a field genuinely needs to change shape, price migrating from a plain Float to a proper Money type for instance, you add priceV2 alongside the still-fully-supported price field, deprecate the old one, and track field-level usage analytics broken down by client app version so you know exactly when the last old build stops querying it before you ever consider removal.

Some teams go further and pair this with a persisted-query allowlist scoped by app version, so an old binary's known query shapes keep resolving correctly indefinitely, since the persisted query has the old field selection baked in explicitly, independent of whatever the schema looks like today.

A monolithic schema means one deploy, one on-call rotation, and a stack trace that stays inside one process, which is genuinely easier to debug and reason about. The cost is that every team's changes ship through the same release train, and a bad resolver from one team can degrade queries for every other team's fields sitting in the same response.

A federated gateway lets teams own and deploy their own subgraph independently, which is the entire point at scale, but now debugging a slow or broken field means figuring out which of several subgraphs is actually responsible, correlating gateway-side traces with per-subgraph traces, and treating partial failure semantics, does one dead subgraph null out just its own fields or fail the whole request, as an explicit design decision instead of something you get for free.

The operational cost that catches teams off guard is that the gateway itself becomes a new single point of failure and a new latency hop. Every query now pays for query planning plus N subgraph round trips instead of one resolver call straight through, so a federation migration that isn't paired with real subgraph-level caching and gateway query plan caching can genuinely end up slower in production than the monolith it replaced.

Each subgraph declares which fields form the @key for a type it owns.

graphql
type Product @key(fields: "id") {
 id: ID!
 name: String!
}

Another subgraph extends that same type by referencing the key without owning the base fields.

graphql
extend type Product @key(fields: "id") {
 id: ID! @external
 reviews: [Review!]!
}

When the gateway needs to resolve a query touching both, it splits the query into a plan with sub-requests per subgraph. It first calls the subgraph that owns the base Product fields, then for the extension fields it calls a special _entities resolver on the second subgraph, passing an array of representations like { __typename: "Product", id: "123" }. That subgraph resolves reviews given nothing but the key, and the gateway stitches the two partial results back together by matching on it.

The real complexity isn't the happy path, it's when one subgraph is slow or down mid-plan. The gateway has to decide whether to partially fail, returning everything it has plus a field-level error for the missing subgraph's data, or fail the whole request outright, and that's a configuration decision with real UX consequences that teams often don't think through until it happens in production.

How to prepare for a GraphQL interview in 2026

Skip another slide deck comparing GraphQL to REST in the abstract. Stand up a small schema, two or three types, a handful of resolvers, against a real database, seed it with a few hundred rows, and turn on query logging. Ask for a list of authors with their books, watch the log light up with one query per author, then reach for DataLoader and watch it collapse back to two queries. Reading about N+1 is nothing like watching your own terminal fill up with the same query fifty times in a row.

Across mock interviews run through LastRoundAI tagged backend, full-stack, or API design, the N+1 and DataLoader question trips up more candidates than schema design questions do, even though most prep material spends far more time on schema. My guess is that schema design feels like the "GraphQL" part of GraphQL, the syntax is new and interesting to study, while N+1 looks like ordinary backend performance work wearing a GraphQL costume, so people under-prep it. We don't have a clean percentage to put on that pattern, only that it shows up often enough in review to flag here.

LastRound data

Debug your own answers before an interviewer does it for you

Reading a correct answer is not the same as defending it once an interviewer changes one detail on you: adds a fourth level of nesting, asks you to redesign the schema around a union instead of an interface, points at your DataLoader code and asks what happens if two different requests share a cache. LastRoundAI's mock interview mode runs backend and API-design rounds with follow-up questions that adapt to what you actually said instead of a fixed script, and the free plan includes 15 credits a month that reset monthly rather than piling up. Starter is $19/mo if fifteen sessions a month isn't enough runway.

If the harder part of the job hunt right now is finding enough backend or full-stack roles that actually mention GraphQL, rather than passing the interview once you land one, Auto-Apply queues tailored applications for your review, 10 a month on the free plan, up to 400 a month on the Ultimate plan, and nothing goes out until you approve it.

Questions about either product go to contact@lastroundai.com. That's the only inbox we check.

How this list was built

Worth being straight about where these questions come from, because plenty of pages in this category are not. The set was compiled from a research pass across official documentation, vendor release notes, published engineering writing and public discussion of hiring processes, then cross-checked against the current version of each technology so nothing here describes behaviour that has since changed.

What that means in practice: these are the questions the material supports as reasonable and current for this role, not a transcript of any one company's loop. We have not sat in on your interview and we are not going to claim we have. Treat the list as well-sourced preparation rather than a leaked question bank, and expect your panel to phrase things their own way.

If you spot something out of date, tell us at contact@lastroundai.com and we will fix it.

Frequently asked questions

Is GraphQL still worth learning in 2026?

For interview purposes the question is really whether the teams you are targeting use it, which is worth checking against their actual job postings rather than general popularity rankings. Where it is in use it tends to be deeply embedded and slow to replace.

Should I memorise GraphQL syntax for the interview?

Rarely worth it. Most interviewers care that you know what to reach for and why, and will not fail you for forgetting an exact flag. Being confidently wrong about behaviour costs far more than admitting you would check the documentation.

What is the most common mistake in GraphQL interviews?

Answering the question that was asked and stopping there. The strongest candidates add the trade-off or the failure mode without being prompted, which is what signals real use rather than revision.

How long does it take to prepare for a GraphQL interview?

If you already work with GraphQL day to day, a focused week on the areas you avoid in practice is usually enough. Coming in cold, expect three to four weeks. The gap is rarely knowledge; it is being able to explain something you normally just use.

What GraphQL topics come up most often?

Interviewers concentrate on the parts that cause production incidents rather than the parts that are pleasant to learn. Expect the fundamentals to be assumed and the follow-up questions to sit one layer below what a tutorial covers.

Leave a Reply

Your email address will not be published. Required fields are marked *