GraphQL interviews cover its schema-first model and the tradeoffs — caching, the N+1 problem — that come with letting clients shape their own queries.
GraphQL is a query language and runtime for APIs, created by Facebook in 2012 and open-sourced in 2015, that lets clients specify exactly what data they need in a single request. REST exposes multiple fixed endpoints like `/users` and `/users/1/posts`, each returning a predetermined shape of data, whereas GraphQL exposes a single endpoint backed by a strongly typed schema that clients query against. Because the client defines the shape of the response using a query document, you avoid the over-fetching and under-fetching problems that are common with REST. GraphQL also has a single, introspectable schema that serves as living documentation, whereas REST contracts are usually documented separately via something like OpenAPI. Under the hood GraphQL still typically runs over HTTP POST, so it's less a transport-layer replacement for REST and more an alternative way of querying and shaping data.
A GraphQL schema is written in Schema Definition Language and defines every type, field, and relationship the API exposes, along with three root operation types: `Query`, `Mutation`, and `Subscription`. `Query` is for read operations and is expected to be side-effect free, `Mutation` is for operations that create, update, or delete data and are executed serially field by field, and `Subscription` is for long-lived operations that push real-time updates to clients over something like WebSockets. Types are built from scalars like `String`, `Int`, `Boolean`, `ID`, and `Float`, plus object types, enums, interfaces, unions, and input types for structuring mutation arguments. The schema is strongly typed and statically validated, so a query that asks for a field that doesn't exist, or passes the wrong argument type, fails before it ever reaches a resolver. This schema-first contract is also what powers tooling like GraphiQL, code generation, and introspection.
A resolver is a function attached to a field in the schema that's responsible for returning that field's value. When a query comes in, the GraphQL execution engine walks the query's selection set and calls the resolver for each requested field, passing it four conventional arguments: `parent`, `args`, `context`, and `info`. `parent` is the value returned by the resolver of the parent field, `args` holds the arguments passed to that field in the query, `context` is a shared object usually used for things like the authenticated user or a database connection, and `info` carries metadata about the query itself. If a field doesn't have an explicit resolver defined, most implementations fall back to a default resolver that just reads a same-named property off the parent object. Because resolvers run per field and can be nested arbitrarily deep, a naive implementation where each one does its own database call is exactly what leads to the N+1 problem.
The N+1 problem shows up when you resolve a list of items and each item has a nested field that triggers its own database query, so fetching one list plus N related records ends up firing N+1 separate queries instead of one batched query. For example, resolving 50 blog posts and then each post's `author` field naively causes 1 query for the posts and 50 more individual queries for authors. DataLoader solves this by batching and caching: within a single tick of the event loop it collects all the individual `.load(id)` calls made during that request, then fires one batched call like a `WHERE id IN (...)` query and distributes the results back to each waiting caller. It also caches results per request so the same key isn't fetched twice during that request's lifecycle, though that cache is typically scoped per-request to avoid serving stale data across requests. You instantiate a `DataLoader` per data type, wire it into the `context`, and call it from resolvers instead of hitting the database or a REST client directly.
Over-fetching happens when a REST endpoint returns more data than the client actually needs, like hitting `/users/1` and getting back forty fields when the mobile app only renders a name and avatar. Under-fetching is the opposite problem, where a single REST call doesn't return enough nested data, forcing the client to make several round trips, such as fetching a user, then their posts, then each post's comments. GraphQL solves both by letting the client describe the exact shape of the response it wants in the query itself, so a single request can pull a user's name and avatar alongside their posts and comments in one round trip, no more and no less. This is arguably the primary reason GraphQL exists, and it's especially valuable for mobile clients on constrained networks where minimizing both payload size and number of round trips matters. The tradeoff is that the server now has to do more work per request to assemble a response shaped by an arbitrary client-defined query rather than a fixed, cacheable payload.
In REST, resources typically map to URLs, so you end up with a growing collection of endpoints like `/users`, `/users/:id/posts`, and `/posts/:id/comments`, each with its own routing, versioning, and response shape. GraphQL instead exposes one endpoint, conventionally `/graphql`, and the query itself is what determines what data comes back, so the resource-to-URL mapping is replaced by a resource-to-type mapping inside a single schema. This means a client can traverse relationships across what would be several REST resources in one request, and the server can evolve field by field without needing new endpoints or a `/v2` prefix. It also simplifies gateway routing and API versioning strategy, since you generally add new fields and deprecate old ones with the `@deprecated` directive rather than standing up parallel versioned routes. The downside is that this single endpoint gives up some of the simplicity REST gets from HTTP semantics, since every request is typically a POST regardless of whether it's a read or a write.
A fragment is a reusable, named selection set of fields that you can share across multiple queries or mutations instead of repeating the same fields over and over. You define it with `fragment UserFields on User { id name email }` and then include it in a query with the spread syntax `...UserFields`. This is especially useful in component-based frontends like React, where a fragment can live alongside a specific component and declare exactly the fields that component needs, a pattern popularized by Relay and also used with Apollo Client. Fragments also support conditional spreading on interfaces and unions using `... on SomeType`, which lets you select type-specific fields when a field's return type could be one of several concrete types. Beyond reducing duplication, fragments make queries easier to maintain because a change to a shared set of fields only needs to happen in one place.
Unlike REST, where a failure usually means a 4xx or 5xx status code and no body, GraphQL almost always returns an HTTP 200 and puts errors in a top-level `errors` array alongside whatever `data` did succeed. This is because a single GraphQL query can touch many resolvers, and it's entirely possible for some fields to resolve successfully while a sibling field throws, so the response ends up with partial `data` and a corresponding entry in `errors` describing what failed and its path in the query. Each error object typically includes a `message`, a `locations` array pointing to where in the query the error occurred, and a `path` array identifying which field failed, plus an `extensions` object where you can attach custom metadata like an error code. If a non-nullable field's resolver throws, the error propagates upward and nulls out the nearest nullable parent, which can wipe out more of the response than you'd expect if your schema overuses non-null types. Because of all this, clients need to check the `errors` array explicitly rather than relying on HTTP status codes the way they would with REST.
REST gets HTTP caching essentially for free because a GET request to a stable URL like `/users/1` can be cached by browsers, CDNs, and proxies using standard headers like `ETag` and `Cache-Control`. GraphQL breaks that model because most requests go through a single endpoint via POST, and the same URL can return wildly different data depending on the query body, so there's no stable URL-to-response mapping for infrastructure-level HTTP caches to key off of. Some setups work around this using GET requests with persisted queries, where the query is stored server-side and referenced by a hash, making the request cacheable again at the CDN layer. On the client side, tools like Apollo Client and Relay solve this differently with a normalized in-memory cache, where every object is stored by its `id` and `__typename` and queries are resolved by matching against that cache rather than against a URL. Server-side, you typically still rely on tools like DataLoader for per-request caching and a separate layer like Redis for cross-request caching of expensive resolver results.
Apollo Server is a GraphQL server library, most commonly used with Node.js, that takes your schema and resolvers and handles parsing, validating, and executing incoming GraphQL operations, plus adds plugins for caching, tracing, and error formatting. Apollo Client is the corresponding frontend library that sends queries and mutations to a GraphQL endpoint and manages a normalized in-memory cache so that identical data referenced from multiple queries stays consistent without requiring a network round trip. On the client, you typically define operations with the `gql` tag and execute them through hooks like `useQuery` and `useMutation` in a React app, and Apollo automatically tracks loading and error state for you. Apollo Client's cache also enables optimistic UI updates, where a mutation's expected result is written to the cache immediately before the server responds, then reconciled once the real response comes back. Together they form one of the two dominant GraphQL client-server stacks, the other being Relay, which is more opinionated and tightly coupled to its own conventions.
Authentication is usually handled the same way it would be in any HTTP API, commonly a JWT or session token sent in an `Authorization` header, which gets verified once per request and the resulting user object is attached to the shared `context` that every resolver receives. Authorization, deciding what an authenticated user is allowed to see or do, is typically enforced inside individual resolvers or through a directive like a custom `@auth(role: "ADMIN")` applied directly in the schema, so access control lives close to the data it protects. Because a single query can request many different types and fields at once, field-level authorization matters more in GraphQL than in REST, where you might only need to guard a handful of endpoints. A common pattern is to keep resolvers thin and delegate the actual permission check to a service layer, throwing a `ForbiddenError` that surfaces in the response's `errors` array rather than silently returning null. For subscriptions, you also need to authenticate the WebSocket connection itself during the handshake, since the usual per-request HTTP header flow doesn't apply the same way.
For simple CRUD services with a small, stable set of resources, REST is often less overhead since you don't need to build and maintain a schema, resolvers, and a query execution layer just to expose a handful of endpoints. GraphQL also struggles with things REST gets natively from HTTP, like file uploads and straightforward URL-based caching by CDNs and browsers, both of which need extra tooling or workarounds in GraphQL. If your API is mostly public and consumed by many unknown third-party clients, REST's uniform, resource-oriented interface and out-of-the-box HTTP caching tend to be easier to document, rate-limit, and reason about at scale. GraphQL also adds real operational cost around query complexity, since a client can write a deeply nested or expensive query that hammers your database, which means you need query cost analysis, depth limiting, or timeouts that a REST endpoint typically doesn't need. So it's less that GraphQL is strictly better or worse, and more that its flexibility around data-fetching shape is a poor tradeoff when your data needs are simple, your clients are external and undifferentiated, or file transfer and cacheability are primary concerns.
Queries in GraphQL are expected to be read-only and side-effect free, and their top-level fields are resolved in parallel since there's no risk of one read affecting another's outcome. Mutations, by contrast, are meant for writes, and the GraphQL spec requires that top-level mutation fields execute serially, one after another, precisely because writes can have side effects that depend on ordering. Mutation naming and structure is a convention rather than an enforced rule, but idiomatic GraphQL mutations take a single `input` object argument and return a payload type that includes both the changed data and often a list of user-facing errors. Nested fields within a single query or mutation's selection set can still resolve concurrently once you're past the top level, it's specifically the top-level mutation fields that are guaranteed sequential. This distinction matters in practice when a client sends multiple mutations in one request expecting them to run in order, like creating a record and then referencing it in a subsequent field.
Introspection is GraphQL's built-in ability to query the schema itself, letting a client ask what types, fields, arguments, and directives exist without needing separate documentation. It works through special meta-fields like `__schema` and `__type`, which every standards-compliant GraphQL server exposes unless explicitly disabled. Tools like GraphiQL, GraphQL Playground, and code generators rely entirely on introspection to power autocomplete, inline documentation, and generating typed client code from the live schema. Because introspection also exposes your full schema to anyone who can hit the endpoint, it's common practice to disable it in production for internal or sensitive APIs, while leaving it enabled for public APIs that want to encourage exploration. It's a genuinely distinctive feature compared to REST, where there's no standardized, machine-readable way to ask an API what it supports.
Subscriptions are GraphQL's mechanism for real-time updates, letting a client subscribe to an event and receive a stream of results pushed from the server whenever that event occurs, rather than polling. Unlike queries and mutations, which are simple request-response, subscriptions need a persistent connection, so they're almost always implemented over WebSockets using a protocol like `graphql-ws`, though older setups used `subscriptions-transport-ws`. On the server, a subscription resolver typically returns an `AsyncIterator`, often backed by a pub-sub system like `graphql-subscriptions` in-memory or Redis pub-sub for a multi-instance deployment, and the execution engine calls that field's paired `resolve` function each time a new value is published. A common use case is something like a live chat message feed or a live order status, where the client subscribes once and gets pushed every new message as it happens instead of re-querying on an interval. Subscriptions add real infrastructure complexity around connection state, scaling stateful WebSocket connections across servers, and reauthentication, so teams often only reach for them when polling genuinely isn't good enough.