GraphQL and Flexible Query APIs Questions
Designing schema-driven query APIs where clients specify exactly the data they need. Covers types/queries/mutations/subscriptions, resolvers, the N+1 problem and batching/dataloaders, over-fetching vs. under-fetching trade-offs against REST, and schema federation. Includes when a flexible query layer is warranted versus a fixed-endpoint API.
You are asked to design a GraphQL API. Explain the N+1 query problem and how data loader patterns and batching solve it. Describe additional mitigations such as persisted queries, query complexity analysis, depth limiting, and caching best-practices for GraphQL endpoints.
Sample Answer
The N+1 problem: when a GraphQL resolver fetches a list (N items) and for each item triggers another DB/API call, you do 1 query for the parent + N queries for children → O(N) extra calls, high latency and DB load. Example: query posts { id, author { id, name } } where author resolver queries DB per post.
Solution — DataLoader / batching:
- Batch requests within a single tick or request lifecycle.
- Cache results per-request to avoid duplicate loads.
Example (JavaScript using facebook/dataloader):
const DataLoader = require('dataloader')
// batch load function receives array of keys and returns Promise of values in same order
const userLoader = new DataLoader(async (ids) => {
const users = await db('users').whereIn('id', ids) // single DB call
const usersById = new Map(users.map(u => [u.id, u]))
return ids.map(id => usersById.get(id) || null)
})
// in resolvers, use loader from context
const resolvers = {
Post: {
author(post, args, ctx) {
return ctx.loaders.userLoader.load(post.authorId)
}
}
}
Why it works: converts N independent loads into one batched query and caches within request.
Additional mitigations:
- Persisted queries: store allowed query hashes on server so clients send short hashes, reducing parsing overhead and preventing arbitrary heavyweight queries.
- Query complexity analysis: compute a cost metric (fields × multipliers for expensive fields) and reject queries exceeding threshold to prevent abuse.
- Depth limiting: cap AST depth to stop extremely nested queries that multiply work.
- Caching best-practices:
- Use per-field caching where safe (idempotent fields).
- Use HTTP caching for persisted queries and GET requests; add Cache-Control, ETag.
- Use CDN for public data; use short-lived caches for user-specific data and invalidate on writes.
- Combine DataLoader (per-request cache) with a shared read-through cache (Redis) for expensive resolved entities.
Trade-offs: aggressive caching and complexity rules can block legitimate queries; tune thresholds and provide monitoring/metrics (resolver call counts, DB query counts, latency).
Design a GraphQL schema for an e-commerce product listing that supports filtering (category, price range), sorting, cursor-based pagination, and nested reviews. Explain how you'd prevent N+1 queries on nested fields and how you'd balance GraphQL flexibility with server-side performance constraints.
Sample Answer
Requirements (brief): product listing with filters (category, price range), sorting, cursor-based pagination, nested reviews.
Schema (SDL):
type Query {
products(filter: ProductFilter, sort: ProductSort, first: Int = 20, after: String): ProductConnection!
}
input ProductFilter {
categoryIds: [ID!]
minPrice: Float
maxPrice: Float
search: String
}
input ProductSort {
field: ProductSortField = RELEVANCE
direction: SortDirection = DESC
}
enum ProductSortField { RELEVANCE NAME PRICE CREATED_AT }
enum SortDirection { ASC DESC }
type ProductConnection {
edges: [ProductEdge!]!
pageInfo: PageInfo!
totalCount: Int
}
type ProductEdge {
cursor: String!
node: Product!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
type Product {
id: ID!
name: String!
price: Float!
category: Category!
reviews(first: Int = 5, after: String): ReviewConnection!
}
type Category { id: ID! name: String! }
type ReviewConnection { edges: [ReviewEdge!]! pageInfo: PageInfo! totalCount: Int }
type ReviewEdge { cursor: String! node: Review! }
type Review { id: ID! author: String! rating: Int comment: String createdAt: String }
Why cursor pagination: stable ordering and efficient continuation for large datasets.
Preventing N+1:
- Use DataLoader-style batching at resolver layer: batch load categories and reviews by product IDs in single DB queries.
- For reviews pagination, push cursor/limit into DB query (SELECT ... WHERE product_id IN (...) AND cursor > X ORDER BY ... LIMIT N) to fetch all needed rows in one request per page.
- When resolving nested lists for many products, fetch reviews grouped by product_id and attach results; avoid calling DB per product.
Balancing GraphQL flexibility vs server performance:
- Enforce query complexity and depth limits; set reasonable max first (e.g., 100).
- Require/encourage server-side defaults and hard caps on 'first' and search complexity.
- Use persisted queries or whitelisting for expensive clients.
- Add analytics and rate limiting; cache common filter+sort results at CDN/DB layer.
- Deny or rewrite queries that request large nested pages; provide bulk endpoints for admin/bulk consumers.
- Document recommended queries and provide server-side profiles to guide clients.
Trade-offs: strict limits restrict client freedom but protect DB; batching and DB-side pagination keep latency low.
Implement a simple DataLoader in Node.js that batches fetches for user data by id and caches results for the duration of a GraphQL request. Provide the DataLoader constructor signature, batchLoadFn behavior, load(id) returning Promise, and how to integrate it in a request context to avoid cross-request caching leaks.
Sample Answer
To implement a simple DataLoader-like utility that batches and caches per-request, we can create a class with:
- constructor(batchLoadFn, options) where batchLoadFn(ids: Array) => Promise<Array>
- load(id) => Promise resolving to item or error
- clear and prime helpers
- per-request instance stored on GraphQL context to avoid cross-request caching
Approach: accumulate ids in a tick, call batchLoadFn once per microtask, map results back to Promises, and keep a Map cache for the lifetime of the instance.
// simple-dataloader.js
class SimpleDataLoader {
/**
* @param {function(Array<any>): Promise<Array<any>>} batchLoadFn
* @param {{cache?: boolean}} options
*/
constructor(batchLoadFn, { cache = true } = {}) {
if (typeof batchLoadFn !== 'function') throw new TypeError('batchLoadFn required');
this.batchLoadFn = batchLoadFn;
this.cacheEnabled = cache;
this.cache = new Map(); // id -> Promise of value
this.queue = []; // pending ids
this.promises = new Map(); // id -> {resolve,reject}
this.scheduled = false;
}
load(id) {
if (id === null || id === undefined) return Promise.reject(new TypeError('id required'));
if (this.cacheEnabled && this.cache.has(id)) return this.cache.get(id);
const p = new Promise((resolve, reject) => {
if (!this.promises.has(id)) {
this.promises.set(id, []);
this.queue.push(id);
}
this.promises.get(id).push({ resolve, reject });
});
if (this.cacheEnabled) this.cache.set(id, p);
if (!this.scheduled) {
this.scheduled = true;
// schedule at end of microtask to batch multiple load() calls
Promise.resolve().then(() => this._dispatch());
}
return p;
}
async _dispatch() {
this.scheduled = false;
const ids = this.queue;
this.queue = [];
let results;
try {
results = await this.batchLoadFn(ids);
// ensure results align with ids: either array of same length or map-like object
if (!Array.isArray(results) || results.length !== ids.length) {
// allow map/object keyed by id
const lookup = results && typeof results === 'object' && !Array.isArray(results)
? results
: null;
ids.forEach((id, idx) => {
const handlers = this.promises.get(id) || [];
const value = lookup ? lookup[id] : (Array.isArray(results) ? results[idx] : undefined);
if (value instanceof Error) handlers.forEach(h => h.reject(value));
else handlers.forEach(h => h.resolve(value));
});
} else {
ids.forEach((id, idx) => {
const handlers = this.promises.get(id) || [];
const value = results[idx];
if (value instanceof Error) handlers.forEach(h => h.reject(value));
else handlers.forEach(h => h.resolve(value));
});
}
} catch (err) {
// batch failed: reject all
ids.forEach(id => {
const handlers = this.promises.get(id) || [];
handlers.forEach(h => h.reject(err));
if (this.cacheEnabled) this.cache.delete(id);
});
} finally {
// cleanup per-id promise arrays
ids.forEach(id => this.promises.delete(id));
}
}
clear(id) {
this.cache.delete(id);
}
prime(id, value) {
if (!this.cache.has(id)) this.cache.set(id, Promise.resolve(value));
}
}
module.exports = SimpleDataLoader;
Integration in GraphQL request context (avoid cross-request cache leaks):
- Create one DataLoader instance per request and attach to context.
Example with apollo-server/express:
const express = require('express');
const { ApolloServer } = require('apollo-server-express');
const SimpleDataLoader = require('./simple-dataloader');
const fetchUsersByIds = async (ids) => {
// DB call returning array aligned to ids
};
const server = new ApolloServer({
typeDefs, resolvers,
context: ({ req }) => {
return {
loaders: {
user: new SimpleDataLoader(fetchUsersByIds)
},
reqId: req.headers['x-request-id']
};
}
});
Then in resolvers: return context.loaders.user.load(userId).
Complexity:
- Batch: single DB call for N ids => O(N) work.
- load per id: amortized constant; space O(N) for cache within request.
Edge cases & recommendations:
- Ensure batchLoadFn returns results aligned to requested ids or a lookup map.
- Handle missing ids (return null) and errors per id.
- Disable cache for mutations or long-running contexts.
- For production use, prefer Facebook’s dataloader library for proven features (cacheKeyFn, maxBatchSize, caching strategies).
Architect a gateway that accepts GraphQL queries and composes data by calling multiple backend REST services. Explain how to analyze the GraphQL AST to group field fetches for batching, apply caching at resolver granularity, implement fallbacks or partial responses when backends fail, and how to surface errors according to GraphQL error semantics.
Sample Answer
Requirements & constraints:
- Accept GraphQL queries, compose from multiple REST backends, support batching, per-resolver caching, partial responses on backend failure, and proper GraphQL error semantics (data + errors) with low latency and high throughput.
High-level architecture:
- Ingress GraphQL Gateway → Parser/Validator → AST Analyzer & Planner → Executor (batcher + cache layer + fallback handler) → REST Clients + Circuit Breakers → Response Assembler → Client.
AST analysis & grouping for batching:
- Parse query into AST. Walk selections grouped by parent type and field: collect resolver calls by identical REST endpoint + identical parameterization (e.g., same userId). For list fields, detect N+1 patterns: when field resolves over a list of parent IDs, emit a single batched REST request (e.g., /users?ids=1,2,3).
- Build execution plan: nodes with inputs, batching keys, dependency edges (parallelize independent branches).
Resolver-granularity caching:
- Use cache keyed by (resolver-name, args-hash, selection-set-hash). Selection-set hashing ensures different sub-selections don’t mix. Support TTLs and cache invalidation hooks (via events from backends) and stale-while-revalidate to reduce latency.
- For batched requests, cache at individual item granularity when possible (e.g., per-id), and assemble from cache before making network calls.
Fallbacks & partial responses:
- Wrap each resolver call with circuit breaker and timeout. On failure:
- If non-critical field: return null for that field and add an error entry.
- If critical (configured): return default/fallback value or trigger alternative backend.
- Support partial responses by continuing execution for other fields; assemble data where available.
Error surfacing per GraphQL spec:
- Return "data" with whatever succeeded; include "errors" array with entries containing message, path (field path from root), locations (from original query), and optional extensions {code, backend, retryable}.
- Map HTTP failures to codes (e.g., 5xx → INTERNAL, 4xx → BAD_USER_INPUT) and include safe details in extensions. Ensure sensitive backend internals are not leaked.
Scalability & trade-offs:
- Use per-request execution plan caching to avoid repeated AST walks.
- Trade-offs: aggressive batching reduces requests but may increase payload sizes and complexity; caching reduces latency but needs strong invalidation strategy.
Observability & testing:
- Trace each resolver with request-id, metrics for latency/hits/misses/errors, and add end-to-end tests for partial-failure scenarios.
This design yields efficient batching, safe caching, graceful degradation on failures, and GraphQL-compliant error reporting.
Design a GraphQL schema for an e-commerce product API where clients can request varying levels of detail (product summary vs full variant and inventory details), support cursor pagination, and preserve strong typing for client code generation. Explain how your schema choices affect performance, cacheability, and developer ergonomics.
Sample Answer
Approach: use a strongly-typed schema that exposes both compact and full product shapes (so clients can request exactly what they need), implement cursor-based connection types for pagination (Relay-style) to allow stable cursors and efficient forward/back navigation, and keep variant/inventory behind a separate type to avoid overfetching and keep caches effective.
Schema (excerpt):
# Basic product summary used by list endpoints (small footprint)
type ProductSummary {
id: ID!
sku: String!
title: String!
price: Float!
thumbnailUrl: String
}
# Detailed product with variants and inventory
type Variant {
id: ID!
sku: String!
title: String!
price: Float!
# inventory can be nulled if client didn't request it
inventory: Inventory @deprecated(reason: "Prefer InventoryCount for cheaper payload")
}
type Inventory {
available: Int!
location: String
}
type ProductDetails {
id: ID!
sku: String!
title: String!
description: String
images: [String!]!
variants: [Variant!]!
metadata: JSON
}
# Connection types for cursor pagination
type ProductEdge {
cursor: String!
node: ProductSummary!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type ProductConnection {
edges: [ProductEdge!]!
pageInfo: PageInfo!
totalCount: Int
}
type Query {
products(first: Int, after: String, filter: ProductFilter): ProductConnection!
product(id: ID!): ProductDetails
productSummary(id: ID!): ProductSummary
}
Why these choices
- Strong typing & codegen: separate ProductSummary and ProductDetails produce concrete types in generated code rather than optional giant objects. Clients calling products get ProductSummary nodes, so codegen yields small, predictable models; requesting product(id) returns ProductDetails.
- Avoiding overfetch: list queries return ProductSummary only; expensive fields (variants, inventory) live on ProductDetails or specific subfields so clients opt-in.
- Cursor pagination: Relay-style connections allow stable pagination even with inserts/deletes and work well with DB queries using (created_at, id) or indexed sort keys.
- Cacheability: normalized clients (Apollo/Relay) can cache ProductSummary separately from ProductDetails. Smaller objects improve cache hit rates. Using global IDs and stable cursors helps cache normalization.
- Performance & backend implications: keep resolvers granular. Products resolver should return lightweight rows; variants/inventory resolvers should use batching (DataLoader) and filters to avoid N+1. Consider resolver cost limits and field-level deprecation (Inventory -> InventoryCount) to migrate clients to cheaper payloads.
- Developer ergonomics: explicit endpoints (productSummary vs product) reduce confusion and prevent fragile client fragments. Named types improve generated typings and editor autocompletion. Provide clear deprecation paths and documentation for heavy fields.
Trade-offs - More types = slightly more schema surface but yields safer client contracts.
- If clients frequently need both summary and details, consider adding a single product query with conditional fetching via fragments; still encourage list endpoints to return only summaries to prevent overfetch.
Unlock Full Question Bank
Get access to all 9 GraphQL and Flexible Query APIs interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.