The 60 GraphQL questions interviewers actually ask, with direct answers, runnable queries and schemas, and what the interviewer is listening for. Grouped for freshers, intermediate, and experienced rounds.
60 questions with answersKey Takeaways
GraphQL is a query language for APIs and a server-side runtime that resolves those queries against a strongly typed schema. Facebook built it internally in 2012, open-sourced it in 2015, and it now sits under the GraphQL Foundation. Instead of many fixed endpoints, a GraphQL API exposes a single endpoint whose schema describes every type and field a client can ask for. The client sends a query naming exactly the fields it wants, and the server returns a JSON shape that matches. That single idea removes over-fetching (getting fields you don't need) and under-fetching (needing several round trips), which is the problem REST hits as UIs grow. In interviews, GraphQL questions probe the schema and type system, how resolvers turn a query into data, the N+1 query trap and how DataLoader fixes it, and the honest trade-offs against REST. The official introduction from the GraphQL Foundation at graphql.org/learn is the canonical starting point, and this page collects the 60 questions that come up most, each with a direct answer and runnable query or schema. Increasingly the first round is a live AI coding interview, so pair this bank with our AI interview preparation guides for the format side.
Watch: Learn GraphQL In 40 Minutes
Video: Learn GraphQL In 40 Minutes (Web Dev Simplified, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your GraphQL certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
GraphQL is a query language for APIs and a runtime that resolves those queries against a typed schema. Facebook built it in 2012 and open-sourced it in 2015. Instead of many fixed endpoints, one endpoint serves a schema, and the client asks for exactly the fields it needs.
It was created to solve over-fetching and under-fetching in mobile apps: fixed REST endpoints either returned too much data or forced several round trips. Letting the client shape the response fixed both.
Key point: Lead with 'query language plus runtime,' then the problem it solves. Interviewers open with this to hear how you frame an answer.
Watch a deeper explanation
Video: GraphQL Explained in 100 Seconds (Fireship, YouTube)
REST exposes many endpoints, each with a server-decided response shape. GraphQL exposes one endpoint and a query language, so the client decides which fields come back. That removes over-fetching and the extra round trips REST needs as a UI grows.
The cost is that GraphQL loses easy per-URL HTTP caching and pushes more logic to the server. Neither is strictly better; they trade client flexibility against server and caching simplicity.
| Aspect | GraphQL | REST |
|---|---|---|
| Endpoints | One | Many |
| Response shape | Client-chosen fields | Fixed by server |
| Round trips | Often one | Often several |
| Caching | Client or normalized store | HTTP and CDN, per URL |
Key point: End with 'when would you still use REST?'. Naming a case (small stable API, file downloads) indicates judgment, not GraphQL fandom.
Watch a deeper explanation
Video: GraphQL Crash Course #1 - What is GraphQL? (Net Ninja, YouTube)
The schema is the contract of a GraphQL API. It defines every type, every field, and the entry-point operations (Query, Mutation, Subscription). Written in the Schema Definition Language, it tells clients exactly what they can ask for and what shape comes back.
Because the schema is strongly typed, tools can validate queries before running them and generate documentation and client code automatically.
type User {
id: ID!
name: String!
email: String
}
type Query {
user(id: ID!): User
users: [User!]!
}They're the three root operation types a schema can expose. Queries read data, mutations change it, and subscriptions stream real-time updates over a persistent connection. Every request you send is one of these three, and each maps to a root type in the schema (Query, Mutation, Subscription) that holds its entry-point fields.
By convention queries have no side effects, mutations do, and subscriptions push new data to the client when a server-side event fires, usually over WebSockets.
query { user(id: 1) { name } }
mutation { addUser(name: "Asha") { id } }
subscription { userAdded { id name } }Key point: The follow-up is 'what transport does a subscription use?'. WebSockets is the common answer; server-sent events also work.
A resolver is a function that returns the value for one field. When a query arrives, the server calls a resolver for each requested field, and the results assemble into the response.
A resolver receives four arguments: the parent value, the field's arguments, a shared context object (auth, loaders), and info about the query. Fields without an explicit resolver fall back to reading the property of the same name off the parent.
const resolvers = {
Query: {
user: (parent, args, context) => db.findUser(args.id),
},
User: {
fullName: (user) => `${user.first} ${user.last}`,
},
};One endpoint means the schema, not the URL, describes what's available. Clients send every query and mutation to the same URL (commonly /graphql) and select fields inside the request body instead of choosing among many routes.
This is what makes client-driven field selection possible: there's no fixed resource URL that pins the response shape.
Over-fetching is getting back more data than the screen needs, wasting bandwidth on fields you'll never render. Under-fetching is the opposite: a single request doesn't return enough, so the client makes extra round trips to other endpoints to fill in the gaps. REST tends to cause both because each endpoint has one fixed response shape.
REST hits both because each endpoint has a fixed shape. GraphQL avoids both because the client lists the exact fields it wants in one request.
Key point: Give a concrete example: a profile card that needs a name and avatar but the REST /user endpoint returns thirty fields. Concreteness lands better than the definition alone.
Fields can take arguments in parentheses, like user(id: 1) or posts(first: 10). Arguments filter, paginate, or shape what a field returns, and the schema declares each argument's name and type so the server can validate them. If a required argument is missing or the type is wrong, the query fails validation before any resolver runs.
Any field can have arguments, not just top-level ones, which is how you page a nested list inside a larger query.
query {
user(id: 1) {
name
posts(first: 3) {
title
}
}
}Variables let you parameterize a query instead of hardcoding values into the query string. You declare them with a $ name and a type, then pass a separate variables object at request time.
This keeps the query static and reusable, avoids string concatenation, and lets the server parse and cache the query text once.
query GetUser($id: ID!) {
user(id: $id) {
name
}
}
# variables: { "id": "1" }A fragment is a named, reusable set of fields you can spread into multiple queries with the ... syntax. It removes duplication when several queries need the same slice of a type.
Fragments also keep large queries readable and let UI frameworks colocate the data a component needs next to that component.
fragment UserCard on User {
id
name
avatarUrl
}
query {
user(id: 1) { ...UserCard }
users { ...UserCard }
}GraphQL ships with five scalars: Int, Float, String, Boolean, and ID. Scalars are the leaf values in a query, so every field path eventually resolves to one of them. You can't return a scalar-less object; a query keeps drilling into object fields until each branch ends at a scalar that holds an actual value.
ID is a String under the hood but signals a unique identifier and is serialized as a string. You can also define custom scalars like Date or Email with your own validation.
| Scalar | Represents |
|---|---|
| Int | 32-bit signed integer |
| Float | Double-precision floating point |
| String | UTF-8 text |
| Boolean | true or false |
| ID | Unique identifier, serialized as a string |
A trailing ! marks a field or argument as non-null: the server promises it won't return null there. Square brackets make a list type, so [String] is a list of strings.
The two combine, and position matters. [String!]! is a non-null list of non-null strings: the list itself can't be null, and no element can be null either.
type Post {
id: ID! # never null
tags: [String!]! # non-null list of non-null strings
author: User # may be null
}Key point: The gotcha follow-up: if a non-null field resolves to null, the error bubbles up to the nearest nullable parent. Knowing that shows you've read the spec.
Introspection lets a client query the schema itself: what types exist, what fields they have, and what arguments each takes. You call it with the special __schema and __type fields.
It powers tooling: GraphiQL autocomplete, client code generation, and API docs all read the schema through introspection. Many teams disable it in production to reduce the API surface exposed to outsiders.
query {
__schema {
types { name }
}
}An alias renames a field in the response. You need one when a query requests the same field twice with different arguments, because a JSON object can't have two keys with the same name.
Aliases also let a client shape the response keys to match its own model without changing the schema.
query {
first: user(id: 1) { name }
second: user(id: 2) { name }
}No. GraphQL is a layer over your existing data, not a storage engine. Resolvers can pull from a SQL database, a REST API, a cache, a file, or several sources at once inside one query.
This is a common misconception. GraphQL describes what clients can ask for; how each field gets its data is entirely up to your resolvers.
Key point: If you can name three different backing sources a single query might touch, you've shown you understand the resolver model.
Object types have fields and describe the shape of an entity, like User or Post. Scalar types are the concrete leaf values, like String or Int, that those fields eventually resolve to.
Every query is a tree that starts at an object type and drills down until every branch ends in a scalar. You can't return an object type without selecting fields on it.
The response is JSON with a top-level data key whose shape mirrors the query, plus an optional errors array. Ask for name and email and you get back exactly a name and email under data.
Because the response shape follows the query, clients know the structure in advance and don't parse unpredictable payloads.
{
"data": {
"user": { "name": "Asha", "email": "asha@example.com" }
}
}Input types are object-like types used only for arguments, especially in mutations. You can't pass a regular object type as an argument; you define an input type with the input keyword instead.
Grouping many arguments into one input object keeps mutation signatures clean and makes them easier to evolve.
input CreateUserInput {
name: String!
email: String!
}
type Mutation {
createUser(input: CreateUserInput!): User
}An enum restricts a field to a fixed set of named values, like a Role of ADMIN, EDITOR, or VIEWER. The server validates that only those values are used, in queries and in responses.
Enums document intent and prevent typos: an invalid value fails validation before any resolver runs.
enum Role {
ADMIN
EDITOR
VIEWER
}
type User {
role: Role!
}GraphiQL is an in-browser IDE for exploring a GraphQL API. It gives you autocomplete, inline documentation, and a query editor with live results, all built on introspection of the schema. You type a query on one side, run it, and see the JSON response on the other, which makes it the fastest way to learn an unfamiliar API.
It's the tool most people first meet GraphQL through. In an interview, it matters.
Use description strings: text in double quotes placed right above a type, field, or argument in the SDL. These descriptions show up in GraphiQL and generated docs, so the schema documents itself.
This is different from a # comment, which is ignored and never surfaced. Descriptions travel with the schema through introspection.
"""A person who can sign in."""
type User {
"""Primary email, used for login."""
email: String!
}Yes. An argument can declare a default with = in the schema, so clients that omit it get the default. This is common for pagination arguments like first: Int = 10.
Defaults keep queries short and give the server a sane fallback without forcing every caller to specify every argument.
type Query {
posts(first: Int = 10): [Post!]!
}__typename is a built-in field available on every object type that returns the name of that type as a string. You can add it to any selection, and it comes back without being defined in the schema.
It's most useful with interfaces and unions, where the client needs to know which concrete type a result is. Normalized caches also read it, combined with the id, to key each object.
query {
search(q: "graphql") {
__typename
... on Book { title }
... on Author { name }
}
}Queries can be long and are sent in the request body, which fits POST. GET works for simple queries by putting the query in the URL, but bodies avoid URL length limits and are the common default.
The side effect is that per-URL HTTP caching doesn't apply, since every request hits the same endpoint with a body. That's why GraphQL caching usually lives in the client instead.
Key point: Connecting POST to the caching trade-off, rather than stopping at 'it uses POST,' is what separates a fresher who memorized from one who understood.
For candidates with working experience: resolver mechanics, the N+1 problem, caching, and the questions that separate users from understanders.
A resolver gets parent (the value from the resolver one level up), args (the field's arguments), context (a per-request object holding auth, loaders, and shared services), and info (details about the query, like the field name and path).
The context is the one to know well: it's how you pass the authenticated user and DataLoaders into every resolver without global state.
const resolvers = {
Query: {
me: (parent, args, context, info) => context.currentUser,
},
};Key point: If asked 'where does the logged-in user come from?', the technical answer is context, populated once per request before resolvers run.
When a query returns a list and each item resolves a related field, a naive server makes one query for the list and then one more per item: N+1 database calls. Fetch 100 posts and their authors and you've fired 101 queries.
It happens because resolvers run per field per item, with no built-in batching. It's the single most common GraphQL performance bug.
// N+1: this author resolver runs once per post
Post: {
author: (post) => db.getUser(post.authorId),
}
// 1 query for posts + N queries for authorsKey point: This is the setup for the DataLoader question. Interviewers almost always chain them, so have the fix ready.
Watch a deeper explanation
Video: GraphQL Course for Beginners (freeCodeCamp.org, YouTube)
DataLoader batches and caches. Instead of loading each item immediately, resolvers call loader.load(id), and DataLoader collects all the ids requested in one tick, then hands them to a single batch function that fetches them in one query. It also caches by key within the request, so repeated ids don't refetch.
So N+1 collapses to two calls: one for the list, one batched call for all related items.
const userLoader = new DataLoader(async (ids) => {
const users = await db.getUsersByIds(ids); // one query
return ids.map((id) => users.find((u) => u.id === id));
});
// resolver
author: (post) => userLoader.load(post.authorId),How DataLoader batches a request
Create a fresh DataLoader per request so its cache never leaks data between users.
Per-URL HTTP caching mostly doesn't apply, because queries POST to one endpoint. Caching moves to three other places: client-side normalized caches (Apollo Client, Relay) that store objects by id, server-side response or resolver caching, and persisted queries that let a GET-cacheable hash stand in for a full query.
The key idea for interviews: GraphQL trades REST's simple HTTP caching for a normalized client cache keyed by object identity.
| Layer | Example | What it caches |
|---|---|---|
| Client normalized | Apollo Client, Relay | Objects by type and id |
| Server response | Response or resolver cache | Whole or partial results |
| Persisted queries | Hashed query + GET | Enables CDN and HTTP caching |
An interface is an abstract type listing fields that several object types must implement. A SearchResult query might return anything implementing a Node interface with an id field, and each concrete type adds its own fields.
Clients query shared interface fields directly and use inline fragments (... on Book) to pull type-specific fields. Your schema must provide a way to resolve which concrete type each value is.
interface Node {
id: ID!
}
type Book implements Node {
id: ID!
title: String!
}
type Author implements Node {
id: ID!
name: String!
}A union says a field can return one of several object types that share no required fields, like SearchResult = Book | Author. Unlike an interface, a union declares no common fields, so clients must use inline fragments for every branch.
Rule of thumb: use an interface when the types share fields you'll query in common, and a union when they don't.
union SearchResult = Book | Author
query {
search(q: "graphql") {
... on Book { title }
... on Author { name }
}
}Key point: The distinction the question needs: interface = shared fields, union = no shared fields. Say that first, then show the inline-fragment syntax.
A directive is an annotation, marked with @, that changes execution or schema behavior. The two built-in query directives are @include(if:) and @skip(if:), which conditionally add or drop a field based on a variable. @deprecated marks a schema field as deprecated with a reason.
You can also define custom directives for cross-cutting concerns like authorization or formatting, applied in the schema or the query.
query GetUser($withEmail: Boolean!) {
user(id: 1) {
name
email @include(if: $withEmail)
}
}A GraphQL response can carry both data and an errors array, and partial success is normal: if one field fails, its resolver's error lands in errors while other fields still return under data. Most servers respond with HTTP 200 even when errors are present.
Because HTTP status codes don't map cleanly to field errors, teams add structured error codes inside errors[].extensions so clients can branch on a stable code rather than parse messages.
{
"data": { "user": null },
"errors": [
{ "message": "Not found", "extensions": { "code": "NOT_FOUND" } }
]
}Take a single input type argument, and return a payload type rather than a bare entity. The payload includes the affected object plus room for a status or errors field, so one mutation can report both success data and validation problems.
Returning the mutated object lets the client update its cache from the response without a second query, which is why normalized clients rely on it.
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
}
type CreateUserPayload {
user: User
userErrors: [UserError!]!
}Two common styles. Offset pagination uses limit and offset (or page numbers): simple but drifts when rows are inserted mid-scroll. Cursor-based pagination passes an opaque cursor marking your position, which stays stable under inserts and is the model behind the Relay Connections spec.
Cursor pagination is preferred for large or fast-changing lists; offset is fine for small, stable data.
| Style | Arguments | Best for |
|---|---|---|
| Offset | limit, offset / page | Small, stable lists |
| Cursor | first, after (cursor) | Large or changing lists, infinite scroll |
It's a convention for cursor-based pagination. A connection wraps results in edges, each with a node and a cursor, plus a pageInfo object holding hasNextPage and endCursor. It standardizes how lists paginate so tooling can rely on the shape.
You don't have to use Relay the client to adopt the pattern; many schemas follow the connection shape regardless of which client consumes them.
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}A subscription opens a long-lived connection, and the server pushes a new payload whenever a matching event fires: a new message, a price change. The client subscribes once and receives a stream, unlike a query that returns once.
The common transport is WebSockets, usually via the graphql-ws protocol; server-sent events are an alternative for one-way streams. Under the hood you publish events to a pub/sub system that the subscription resolver listens to.
type Subscription {
messageAdded(channelId: ID!): Message!
}Schema-first: you write the SDL by hand as the source of truth, then implement resolvers to match. Code-first: you define types in your programming language (with a library like Nexus or Pothos or type-graphql) and the SDL is generated from that code.
Schema-first reads clearly and keeps the contract front and center; code-first keeps types and resolvers together and gives you the type safety of your language. Teams pick based on which drift they'd rather avoid.
| Approach | Source of truth | Trade-off |
|---|---|---|
| Schema-first | Hand-written SDL | Clear contract, resolvers can drift from it |
| Code-first | Program code | Type-safe, SDL is generated not authored |
GraphQL lets clients build deeply nested queries, which can be abused. Defenses stack: cap query depth, assign each field a cost and reject queries over a complexity budget, set a timeout, and paginate all lists so nothing returns unbounded.
For public APIs, persisted queries go further by only allowing a pre-approved allowlist of queries, so arbitrary expensive shapes never reach the server.
Mark a field non-null only when you can truly guarantee it. A non-null field that resolves to null triggers an error that bubbles up to the nearest nullable parent, which can wipe out a whole branch of an otherwise good response.
The safe default for fields that depend on external data (a joined record, a remote call) is nullable, so a single missing value degrades gracefully instead of erroring the parent object.
Key point: Saying 'I default to nullable and earn non-null' signals you've been burned by a non-null field nulling out a parent in production.
A normalized client caches objects by type and id. Fragments that always include the id let the client match a returned object to its cache entry and update every query that referenced that object, no refetch needed.
So fragments aren't only about reuse in query text; they help the cache stay consistent because the same fields (including id) come back the same way everywhere.
Top-down and depth-first, following the query tree. A field's resolver runs, its return value becomes the parent for its child resolvers, and so on down the selection. Sibling fields at the same level can resolve in parallel since they don't depend on each other.
This ordering is why the parent argument matters: a child resolver reads from whatever its parent resolver returned.
Context carries request-specific state: the authenticated user and per-request caches. If you share one DataLoader across requests, its cache would serve user A's data to user B, which is a data leak, and stale entries would never clear.
A fresh context and fresh loaders per request scope the cache to that request, so batching helps within a request and nothing bleeds across users.
GraphQL avoids URL versions like /v2. Instead you evolve the schema in place: add new fields and types freely (they're backward-compatible), and retire old ones with @deprecated plus a reason so clients migrate before you remove them.
Because clients request only the fields they use, adding fields never breaks existing queries. Removing or changing a field is the breaking act, and deprecation is how you stage it.
type User {
fullName: String!
name: String! @deprecated(reason: "Use fullName")
}advanced rounds probe execution internals, federation, design judgment, and production scars. Expect every answer here to draw a follow-up.
Three phases. Parse turns the query string into an abstract syntax tree. Validate checks that AST against the schema: do the fields exist, do the types line up, are required arguments present. Execute walks the tree, calling resolvers field by field and assembling the result, then serializes it to JSON.
Senior-level extras: validation is where malformed or invalid queries fail fast before any resolver runs, and execution is where batching (DataLoader) and cost limits hook in.
The query execution pipeline
Invalid queries die in validation, before any resolver or database call runs.
Federation composes one supergraph from many independently owned subgraph services. Each team owns a slice of the schema, and a gateway plans and stitches queries across subgraphs, so a client still sees a single unified graph.
It solves the monolith problem: without federation, one giant schema and resolver layer becomes a shared bottleneck. Federation lets teams ship their part of the graph on their own cadence while types can reference each other across service boundaries.
Key point: The follow-up is 'how does a type get extended across services?'. Mention entity keys and the @key directive, which is how one subgraph resolves fields the owning subgraph defined.
Both combine multiple schemas into one. Schema stitching does it at the gateway by merging schemas and writing delegation logic centrally, so the gateway holds the knowledge of how services connect. Federation inverts that: each subgraph declares its own relationships with directives, and the gateway composes them, so ownership stays with the teams.
Federation is the more maintainable pattern for many teams because coordination lives in the subgraphs, not in one central stitching layer that every change has to touch.
| Aspect | Schema stitching | Federation |
|---|---|---|
| Where relationships live | Central gateway | Each subgraph declares its own |
| Team ownership | Gateway team coordinates | Subgraph teams stay independent |
| Cross-service types | Manual delegation | Entity keys and directives |
A persisted query registers a query text on the server ahead of time and gives it a hash. Clients then send the hash (and variables) instead of the full query string. Automatic persisted queries do this on the fly: send the hash, and only send the full text if the server hasn't seen it yet.
The wins: smaller requests over the wire, a GET-cacheable request that CDNs can cache, and a security allowlist, because the server can refuse any query hash it didn't register. That turns off arbitrary expensive queries on public APIs.
The big ones: malicious deep or wide queries that exhaust resources, introspection exposing your full schema to attackers, batching and aliasing used to amplify a single request into thousands of operations, and field-level authorization gaps where one query reaches data a user shouldn't see.
Defenses: depth and cost limits, disable introspection in production or gate it, rate-limit and cap batch/alias counts, and enforce authorization per field or per object rather than only at the endpoint.
Per-endpoint metrics don't help when everything hits /graphql, so you instrument at the field and operation level: track latency and error rate per resolver, per operation name, and per client. Tracing (Apollo tracing, OpenTelemetry spans per resolver) shows which field in a query is slow.
Operation names and persisted queries make this practical: you can attribute load to specific queries and specific teams, then find the N+1 or the slow downstream call by resolver, not by URL.
Layer it. Unit-test resolvers as plain functions with a mocked context. Integration-test by executing real queries against the schema (in-process, no HTTP) and asserting on data and errors. End-to-end test the deployed endpoint for auth and transport. Add schema checks in CI to catch breaking changes before they ship.
The schema-diff check is the one people forget: comparing the proposed schema against the current one flags removed or changed fields that would break live clients.
const res = await server.executeOperation({
query: "query { user(id: 1) { name } }",
});
expect(res.body.singleResult.errors).toBeUndefined();
expect(res.body.singleResult.data.user.name).toBe("Asha");DataLoader batches at the resolver layer, but other tactics help. Look-ahead resolution reads the query info to see which nested fields are requested and issues a single joined query up front. A projection or a query builder can turn a known selection set into one SQL statement. And for read-heavy graphs, a well-placed cache on hot entities cuts repeated loads.
The senior instinct is to fetch by the shape of the whole query, not field by field, when the data source supports it.
A custom scalar (DateTime, Email, JSON) needs three functions: serialize (internal value to the client), parseValue (variable input to internal), and parseLiteral (inline literal to internal). You define validation in those functions so bad values fail at the boundary.
The risk to name is JSON as a scalar: it escapes the type system entirely, so the schema stops describing that data and clients lose validation and tooling. Use typed structures instead of a JSON blob unless the data is genuinely unstructured.
const DateTime = new GraphQLScalarType({
name: "DateTime",
serialize: (v) => v.toISOString(),
parseValue: (v) => new Date(v),
parseLiteral: (ast) => new Date(ast.value),
});Facebook's mobile apps were straining against REST: fetching a news feed meant many round trips and either too much or too little data on slow networks. They needed the client to describe its data needs in one request that mapped to a rich, connected object graph.
GraphQL replaced that fan-out of REST calls with a single declarative query. Knowing the origin story matters because it explains every design choice: one endpoint, client-chosen fields, a strong type system, all aimed at data fetching for product UIs.
Watch a deeper explanation
Video: React.js Conf 2015 - Data fetching for React applications at Facebook (Meta Developers, YouTube)
Design the schema around the client's view of the domain, not your database tables. Prefer clear entity types with real relationships over flat CRUD. Make mutations specific and intent-revealing (publishPost, not a generic update). Default fields to nullable unless you can guarantee them, and use input and payload types so mutations can evolve.
The recurring senior theme: the schema is a long-lived public contract, so optimize it for the consumers and for change, not for whatever's convenient on the server today.
You avoid breaking changes when you can and stage them when you can't. Add the new field alongside the old, deprecate the old with a reason, and use analytics on field usage (many servers track which fields each client queries) to confirm no one still calls the old one before you remove it.
Schema registries and CI schema checks gate merges that would break known operations. The whole point is that removal is a controlled event driven by real usage data, not a guess.
Key point: field-usage analytics as the trigger for safe removal is the answer that matters.
A normalized cache (Apollo, Relay) stores each object once by type and id, so updating one object updates every query showing it, and refetches shrink. The cost is real complexity: the cache needs stable ids everywhere, list updates after a mutation need explicit cache logic, and pagination merging is fiddly.
The judgment call is when it earns that complexity. For a data-heavy app with lots of shared entities across screens, it pays off; for a simple app, a plain fetch cache may be enough.
The execution engine can run sibling resolvers in parallel, reorder within the tree's constraints, and (with tools) batch or cache field results. If a query resolver writes data or depends on execution order, those optimizations become bugs, and the same query returns different results run to run.
Side effects belong in mutations, which the spec runs serially precisely because order matters for writes. Keeping queries pure is what makes the engine's freedom safe.
Reach for GraphQL when many clients need different slices of a rich, connected graph, when mobile and web share a backend and want to minimize round trips, or when frontend teams need to iterate on data needs without waiting on new endpoints. Stay with REST for small stable APIs, heavy file transfer, when strong HTTP and CDN caching is a hard requirement, or when the team has no appetite for the server-side machinery GraphQL asks for.
The honest coverage names the cost: GraphQL moves complexity to the server (resolvers, N+1 guards, cost limits, caching you build yourself). It's a real trade, not a free upgrade.
Key point: The technical decision depends on whether you pick on the requirements in front of you, not on hype.
Clarify first: which services, read-heavy or write-heavy, one team or many. Then design the schema around the client's domain, not the underlying services, and write resolvers that call each REST API or database, wrapping every downstream call in a DataLoader to batch and prevent N+1. Put auth on the context, add depth and cost limits, and cache hot reads at the resolver layer.
If many teams own different services, propose federation so each owns its subgraph and a gateway composes the supergraph. Close on the operational edges: per-resolver tracing to find slow downstreams, timeouts on every external call, and a schema registry with CI checks so the contract evolves safely.
const resolvers = {
Query: {
order: (_, { id }, ctx) => ctx.loaders.order.load(id),
},
Order: {
// batched, so N orders don't fire N calls
customer: (order, _, ctx) => ctx.loaders.customer.load(order.customerId),
},
};Key point: Structuring the answer clarify, schema, resolvers with DataLoader, auth and limits, then operations is what the question is really scoring, more than any single tool.
Watch a deeper explanation
Video: #1 What is GraphQL? | Build a Complete App with GraphQL, Node.js, MongoDB and React.js (Academind, YouTube)
GraphQL and REST solve the same job, fetching data over HTTP, with opposite defaults. REST gives you many endpoints with fixed response shapes, so the server decides what you get. GraphQL gives you one endpoint and a query language, so the client decides what it gets. That flexibility removes over-fetching and extra round trips, but it moves complexity to the server: you write resolvers, guard against expensive nested queries, and lose easy HTTP caching. Neither wins outright. GraphQL shines when many clients need different slices of a rich, connected data graph. REST stays simpler for small, stable APIs and file-style resources. Saying that trade-off out loud is itself an interview signal.
| Concern | GraphQL | REST |
|---|---|---|
| Endpoints | One endpoint, typed schema | Many endpoints, one per resource |
| Data shape | Client picks exact fields | Server fixes the response shape |
| Over/under-fetching | Avoided by design | Common as UIs grow |
| HTTP caching | Hard (usually POST, one URL) | Easy (GET, per-URL, CDN-friendly) |
| Error signaling | 200 with an errors array | HTTP status codes (404, 500) |
Prepare in layers, and practice out loud. Most GraphQL rounds move from schema and query concepts to writing resolvers to a design or debugging discussion, so rehearse each stage rather than only reading answers.
The typical GraphQL interview flow
Earlier rounds increasingly run as AI coding interviews. The concepts on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These GraphQL questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works