Skip to main content

Performance and DataLoader (7 Questions)

Duration: ~20 minutes | Difficulty: Intermediate | Prerequisites: Schema Design (8 Questions)


Performance questions in GraphQL interviews separate candidates who have shipped production APIs from candidates who have only worked through tutorials. The N+1 problem is easy to define; finding it in a heap of JPA query logs and fixing it without introducing stale-data bugs is a different skill. The questions in this chapter test both the conceptual understanding and the operational fluency.


Question 1: Walk me through diagnosing and fixing an N+1 problem in Spring GraphQL.​

Short answer: Enable SQL query logging (spring.jpa.show-sql=true) and watch the console during a query that traverses a list. N+1 shows as a burst of nearly identical single-row queries after the initial list query. Fix it with @BatchMapping for simple parent-to-child relationships, or with an explicit DataLoader for more complex batch logic.

Why this is right / what interviewers want to hear:

This question is asking you to walk through the problem-solving process, not just state the solution. The interviewer wants to hear diagnosis, not just prescription.

Step 1: Recognize the symptom. The first sign of an N+1 problem is a response that feels slower than it should be given the size of the result set, combined with query log output that shows a large number of small, repetitive database queries. If your movies query returns 50 movies and you see 51 SQL queries in the log, one SELECT * FROM movies followed by 50 SELECT * FROM directors WHERE id = ?, you have found the N+1 problem. The ? changes each time, which is the tell: the resolver is executing one query per row instead of batching.

Step 2: Find the resolver. The 50 individual queries are being issued by one resolver: whatever method is mapped to Movie.director. In Spring GraphQL, this is the @SchemaMapping or @QueryMapping method that returns the director for a given movie. Typically it calls directorRepository.findById(movie.getDirectorId()), which issues one SELECT per call.

Step 3: Apply the fix. In Spring GraphQL, the simplest fix is to replace the @SchemaMapping method with @BatchMapping. The @BatchMapping method takes a List<Movie> (all the parent objects in the current batch) and returns a Map<Movie, Director> (a mapping from each parent to its resolved value). Spring GraphQL registers this as a DataLoader automatically. The DataLoader defers all the individual loads until the current async unit completes, collects all the movie IDs, and calls the batch method once. The batch method can then execute one SELECT * FROM directors WHERE id IN (...) for all the IDs.

Step 4: Verify the fix. With SQL logging enabled, rerun the same query. You should now see two queries: the initial list query and one IN-clause query for all the directors. If you still see multiple queries, check whether the batch method is actually being registered as a DataLoader (Spring's @BatchMapping requires the GraphQL mapping infrastructure to be properly configured) or whether a separate code path is bypassing it.

A detail that demonstrates production experience: in JPA, there is a second mechanism that tempts developers, the Hibernate fetch join. Annotating the @OneToMany relationship with EAGER fetch or adding a fetch join to the JPQL query eliminates the N+1 for that specific query, but it couples the database fetching strategy to the schema structure. A GraphQL query that does not select the cast field will still issue the join, loading data the client never asked for. DataLoader is the right tool precisely because it respects the selection set: the batch is only issued for the fields the client actually requested.

Lesser-known fact

The term "N+1 problem" predates GraphQL by roughly two decades and is well-known across the ORM world, Rails/ActiveRecord, Hibernate, and Entity Framework communities have all wrestled with it. The Bullet gem, which detects N+1 patterns at runtime in Rails apps, was first released in 2009. Rails 3.0 (released August 2010) introduced ActiveRecord::Relation's chainable query API including .includes for eager loading; the older :include option on find had existed in Rails 2.x. Some interviewers will probe whether candidates know this. A candidate who treats N+1 as a GraphQL-specific concept has usually only seen the GraphQL version of the problem. A candidate who has met N+1 in a Hibernate-with-lazy-loading or ActiveRecord context will recognise the GraphQL framework's solution, DataLoader and @BatchMapping, as the same underlying idea: defer plus batch. If an interview question on N+1 leads to "how does this generalise?", recognising the cross-framework lineage is a useful signal of breadth.


Question 2: What is the difference between @BatchMapping and a custom DataLoader?​

Short answer: @BatchMapping is Spring GraphQL's declarative shortcut that automatically registers a method as a DataLoader. A custom DataLoader is the lower-level API from the graphql-java DataLoader library, giving you more control over the batch function, key extraction, caching strategy, and error handling per individual key.

Why this is right / what interviewers want to hear:

Both solve the N+1 problem via batching, but they operate at different abstraction levels, and the right choice depends on the complexity of the batching logic.

@BatchMapping works when the batch is a straightforward one-to-one or one-to-many mapping from parent to child, matched on a single scalar property of the parent. The framework extracts the parent objects, calls your method with the full list, and expects a Map<Parent, Child> or Map<Parent, List<Child>> back. Spring handles the DataLoader registration, the key extraction, and the Promise resolution automatically. This covers perhaps 80 percent of the N+1 scenarios in a typical schema.

The remaining 20 percent are where a custom DataLoader earns its complexity. The cases include:

  • Multi-key batching: the batch looks values up by two fields instead of one (e.g., fetching a relationship that depends on both a parent ID and a type discriminator).
  • Conditional caching: some IDs should be cached for the request lifetime; others are so volatile that caching them even for 50 milliseconds is unsafe.
  • Per-key error handling: the batch function may succeed for some keys and fail for others. A custom DataLoader can resolve individual keys to exceptions, so resolver A gets a value while resolver B gets an error, without the entire batch failing.
  • Cross-type batching: occasionally a schema has two different types that should be loaded together in one batch (e.g., loading both Director and Producer records from a single query against a persons table). A custom DataLoader can express this logic; @BatchMapping cannot, because it is tied to a specific return type.

In practice, the recommendation in interviews is to start with @BatchMapping and reach for a custom DataLoader only when the behavior you need cannot be expressed in the declarative model. Custom DataLoaders require manual registration via DataLoaderRegistrar beans, explicit key type management, and careful handling of the promise lifecycle, none of which is difficult, but all of which adds code that @BatchMapping would have hidden.


Question 3: How does DataLoader caching work within a request, and what are its limits?​

Short answer: Within a single request, DataLoader caches loaded values by key. If two different parts of a query ask for the same entity by the same ID, DataLoader loads it once and returns the cached value to both. The cache is discarded at the end of the request. DataLoader does not cache across requests, does not automatically invalidate for writes within the same request, and is not a substitute for a distributed cache.

Why this is right / what interviewers want to hear:

The caching behavior of DataLoader is a source of subtle bugs in production, and the interviewer is checking whether you understand the boundaries.

The per-request cache works by storing a mapping from key to Promise. When resolver A calls loader.load(42) and resolver B also calls loader.load(42) in the same request, the second call does not add 42 to the batch, it returns the same Promise that resolver A already got. If the batch function has already completed, resolver B gets the value immediately from the cache. If the batch is still pending, resolver B's Promise is added to the same resolution. The result is that entity 42 is loaded at most once per request regardless of how many resolvers ask for it.

The first limit: DataLoader does not know about writes in the same request. If a mutation in a single request updates movie 42 and then a query in the same request tries to fetch movie 42, the DataLoader cache may return the pre-update value. This is rarely a problem in practice because mutations and subsequent queries are not typically in the same request, but it is a correctness concern to be aware of. The fix is to explicitly call loader.clear(key) after a mutation that modifies the cached entity, or to use a new DataLoader instance for the query phase of a mutation request.

The second limit: DataLoader does not cache across requests. If 100 requests per second all ask for the director of movie 42, DataLoader will load director 42 once per request, 100 times per second. For data that changes rarely, this is wasteful. The fix is a layer above DataLoader: a server-side cache (Redis, Caffeine, Spring's @Cacheable) on the service method that the DataLoader's batch function calls. The DataLoader deduplicates within a request; the service cache deduplicates across requests.

The third limit: DataLoader does not provide built-in invalidation. If the underlying data changes between requests, the per-request cache will return fresh data (because it is created fresh per request), but any upstream cache (if you have added one) needs to be invalidated explicitly when mutations occur.


Question 4: What JPA pitfalls occur in GraphQL resolver chains, and how do you avoid them?​

Short answer: The two main pitfalls are lazy loading triggering outside the persistence context (a LazyInitializationException) and eager loading fetching data the client never requested (a performance regression). Avoid both by using DataLoader batch functions that operate within a fresh @Transactional scope, and by never relying on JPA's relationship traversal to satisfy GraphQL field resolution.

Why this is right / what interviewers want to hear:

This question targets Spring-specific production knowledge, and it is a good discriminator between candidates who have shipped Spring GraphQL in production and candidates who have only worked through the tutorial.

The lazy loading problem: JPA entities have lazy-loaded relationships by default. When you load a Movie entity and then, in a separate resolver called 50 milliseconds later, try to access movie.getDirector(), the JPA persistence context that loaded the Movie may have closed. You get a LazyInitializationException at runtime, not a compilation error or a schema validation error, it only shows up when the specific query path is exercised. The fix is to never rely on relationship traversal in GraphQL resolvers. Instead, treat every field as independently loaded: if Movie.director is a field in the schema, write a resolver for it that explicitly loads the director from the repository, or use a DataLoader batch function that loads directors by ID.

The eager loading problem: the opposite mistake is to annotate JPA relationships with FetchType.EAGER or to use @EntityGraph to fetch related entities eagerly in the repository method. This fixes the lazy loading problem, but it loads data for every query that loads the Movie entity, regardless of whether the client's query selected the director field. A query that only asks for { movies { id title } } will still issue a JOIN to the directors table because the repository was told to always fetch eagerly. This is a silent performance regression that becomes visible only when the dataset grows.

The correct approach: treat the JPA entity model and the GraphQL resolver model as two separate layers. The JPA repository loads the data that the SQL query asked for, typically a flat Movie with its scalar fields only. The GraphQL schema maps additional fields to separate resolvers (or @BatchMapping methods) that issue separate queries. DataLoader ensures those separate queries are batched. The coupling between JPA relationship annotations and GraphQL field resolution is the root of both problems: break the coupling and both problems disappear.


Question 5: How would you implement server-side caching for a hot GraphQL resolver?​

Short answer: Apply @Cacheable from Spring Cache on the service method that the resolver delegates to, backed by Caffeine (for in-process, single-node caching) or Redis (for distributed, multi-instance caching). Use the input parameters of the service call as the cache key, and invalidate explicitly on mutations that modify the cached data.

Why this is right / what interviewers want to hear:

GraphQL makes caching more nuanced than REST because REST URLs are natural cache keys and HTTP caching infrastructure (CDN, browser cache) can be used transparently. In GraphQL, every request is a POST /graphql with a query body, and the cache key is the content of the query plus the variables plus the auth context, which does not map well to HTTP caching infrastructure.

Server-side caching lives at the service layer, not the GraphQL layer. The @Cacheable annotation on the service method fills the cache the first time a given method call runs with given arguments. Later calls with the same arguments return the cached value, and the method body never runs. That holds whether the caller is a GraphQL resolver, a REST endpoint or a background job.

The cache key design is critical. For a method like movieService.findTopRatedMovies(genre, limit), the key is {genre, limit}, the arguments. If the method's result also depends on the current user's region, the region must be part of the key. If the result depends on the user's subscription level, that must also be part of the key. Leaving a relevant dimension out of the key causes cache pollution, where one user's cached result is served to another user who should see different data.

Invalidation strategy: when movieService.createMovie(input) runs, it should call cacheManager.evict("moviesByGenre", ...) (or use @CacheEvict) to clear any cached results that might now be stale. The challenge is knowing which cache entries to evict. In practice, the safest approach for frequently-written data is to use a short TTL (time-to-live) on the cache instead of explicit eviction, accepting a brief window of stale data. For data that changes rarely (reference data like genres, studios), explicit eviction on mutation is appropriate.

The distributed vs. in-process choice: Caffeine is faster (no network round-trip) and simpler to operate but does not scale horizontally, two instances of the service will have separate caches that can diverge. Redis adds network latency but provides a single shared cache that all instances read and write, ensuring consistency across nodes.


Question 6: How do persisted queries improve performance beyond security?​

Short answer: Persisted queries reduce HTTP request size, because the client sends a hash instead of the full query text. They let server-side query parsing happen once at registration time instead of per request, and they allow CDN caching of query results, using the hash as a stable URL-like key.

Why this is right / what interviewers want to hear:

Persisted queries are often discussed only in the context of security (operation safelisting, blocking arbitrary queries from being executed), but the performance benefits are real and sometimes the primary motivation for deploying the feature.

Payload size reduction: a complex GraphQL query can be hundreds of bytes of query text. In a mobile application that makes many GraphQL requests per page load, sending the full query text on each request is wasteful. With automatic persisted queries (APQ), the client sends only a hash (typically 64 hex characters for SHA-256) on subsequent requests after the query has been registered. The server looks up the hash in its query registry and executes the stored query. The difference in bytes per request is small for a single query but adds up across a session.

Parse and validate caching: parsing a GraphQL query document into an AST and validating it against the schema takes CPU time. For a server handling thousands of requests per second, this CPU cost is non-trivial. Persisted queries allow the server to parse and validate once, store the parsed AST, and skip parsing for subsequent requests with the same hash. This is a meaningful optimization for high-throughput services.

CDN caching: the most powerful performance benefit. Once queries are persisted and clients send only hashes, the query + hash pair becomes effectively a stable read key. A GET request to /graphql?operationName=Movies&extensions={"persistedQuery":{"version":1,"sha256Hash":"abc123"}} is cacheable by a CDN because the URL and its parameters are stable. The CDN can serve the response directly for subsequent requests with the same hash and same variables (if variables are included in the cache key), bypassing the origin server entirely. This is the GraphQL equivalent of CDN caching for REST endpoints, it was not possible with arbitrary query bodies, but it is possible with stable persisted query identifiers.


Question 7: How do you benchmark a GraphQL API? What metrics matter?​

Short answer: Use a tool like k6 or Gatling to send realistic query shapes at varying concurrency levels, targeting your p99 latency and throughput limits. The metrics that matter most are latency distribution (p50, p95, p99), database query count per request (to catch undetected N+1 regressions), and resolver duration per field (to find which resolvers dominate cost).

Why this is right / what interviewers want to hear:

Benchmarking a GraphQL API is different from benchmarking a REST API because the same endpoint behaves completely differently depending on the query shape. A benchmark that sends only simple queries will not reveal the performance characteristics of complex queries. A realistic benchmark requires a portfolio of query shapes that represents the actual distribution of queries your production traffic sends.

The query portfolio: start by sampling production queries (anonymized, removing any sensitive variable values) to understand what shapes are actually sent. If you do not have production data yet, cover the expected cases: single-entity lookups, list queries with and without pagination, queries that traverse relationships, and queries that hit slow fields. A benchmark that only tests { movies { id title } } is not a benchmark of your GraphQL API's performance; it is a benchmark of your list resolver.

Latency distribution: average latency is nearly useless as a production metric because it smooths over the tail. Your p50 latency (median) tells you what the typical user experiences. Your p99 latency tells you what the worst 1% of requests experience, and in a production system serving millions of requests per day, 1% is hundreds of thousands of requests. p99 spikes often reveal N+1 regressions for edge-case query shapes that the p50 never triggers.

Database query count: instrument your benchmark to capture the number of SQL queries issued per GraphQL request (Spring Boot Actuator with a custom metric, or query log analysis). A query count above a small threshold (2-5 queries for a typical list query) is a signal to investigate. A drift in query count between benchmark runs signals a DataLoader regression.

Resolver duration per field: Spring GraphQL's tracing integration (available via Micrometer and Spring Actuator) captures per-resolver timing. Running a load test and inspecting the resolver duration histogram reveals which fields dominate request time. A field that averages 200ms per resolution is a candidate for caching, batching optimization, or query rewriting.

One practical note: benchmarks should be run against a production-realistic data set, not against a dev database with 50 rows. N+1 problems are often invisible on small data sets and become catastrophic at production scale.


Further Reading​

  • Performance on graphql.org frames the N+1 problem the way this chapter does, from the execution model outward.
  • Caching covers response and object caching, and the identifier questions that come with it.
  • The DataLoader repository documents the batching and caching contract every language port implements, and its README is the source for the request-scoping rule.
  • Spring for GraphQL's batch loading reference is the Java side: @BatchMapping, the registry, and the per-request lifecycle.