skip to content

Explain how deferred resolution lets Spring for GraphQL batch dependent fetches into a single call. What triggers the dispatch, and what are the ordering and caching semantics?

level: principalimportance: should knowfreq 20%

answer

  1. load = enqueue + incomplete future
  2. dispatch when level makes no more progress
  3. dispatchAll() at instrumentation boundary
  4. chain batches per hop (books→authors→publishers)
  5. per-request cache + dedup, never block

basics

~20 s

Each field resolver returns an incomplete future after registering its key instead of loading immediately. The GraphQL engine keeps resolving fields at the current level; once it can make no more progress it dispatches every DataLoader once with all queued keys, runs each batch function a single time, and completes the futures. Keys are deduplicated and cached per request.

solid answer

~50 s

Batching hinges on deferred resolution. When a resolver calls dataLoader.load(key), the DataLoader queues the key and returns a not-yet-completed CompletableFuture; the resolver returns that future rather than a value. graphql-java continues resolving all sibling fields at the same execution level, so every parent's key accumulates. The engine dispatches when it reaches a point where no field can make further synchronous progress (level exhausted) — Spring uses instrumentation to trigger DataLoader.dispatchAll(). Each registered batch function then runs exactly once with the full, de-duplicated key list. The DataLoader also caches by key within the request, so repeated keys share one future. Because dispatch is level-by-level, a chain like books→author→publisher batches at each hop: all author keys in one call, then all publisher keys of those authors in the next. Scope is per-request; nothing carries over between operations.

go deeper

for a junior

Beyond junior depth; only needs the headline that loads are deferred and batched.

for a middle

Should grasp that load() returns a future and dispatch runs the batch once.

for a senior

Explains the dispatch trigger, per-request cache/dedup, and the no-blocking rule.

for a principal

Reasons about chained per-level batching, depth-vs-breadth cost, cache-consistency tradeoffs, and DataLoaderOptions tuning as an architectural concern.

## The mechanism, step by step 1. **Load = queue, not fetch.** `DataLoader.load(key)` **enqueues** `key` and immediately returns a **`CompletableFuture<V>` that is not yet complete**. Crucially, the field resolver **returns that future** to the engine — it does not compute a value. 2. **The engine keeps working the level.** graphql-java resolves fields **breadth-first-ish per level**: it fires the `author` resolver for every `Book` in the list before it needs any author value. Each fires `load()`, so keys `[a1, a2, … aN]` pile up in the DataLoader's queue. 3. **Dispatch trigger.** When the engine can make **no more synchronous progress** at the current level (every pending value is now a DataLoader future), it **dispatches**. Spring for GraphQL registers a `DataLoaderDispatcherInstrumentation`-style hook that calls **`dispatchAll()`** on the request's `DataLoaderRegistry` at that boundary. 4. **Batch function runs once.** Each DataLoader invokes its batch function with the **entire queued, de-duplicated key list**, returning `Map<K,V>` (mapped) or ordered `List<V>` (positional). The DataLoader then **completes each queued future** with the correct value. 5. **Downstream levels repeat.** Now the author values exist; if the query also asks `author { publisher { name } }`, resolving `publisher` on each author queues **publisher** keys, which dispatch as **one more batched call**. So a dependent chain `books → authors → publishers` is **N+1+1**-collapsed into roughly **3** calls — 'deferred resolution batching dependent fetches into one call' at **each hop**. ## Caching semantics - The DataLoader keeps a **per-request cache** keyed by the load key. Calling `load(5L)` twice returns the **same future** and fetches once. This also means within a request a stale value could be reused — usually fine, occasionally a gotcha (mutations changing the same entity mid-request). - Cache and batch queue are **request-scoped**: Spring builds a fresh `DataLoaderRegistry` per execution, so **nothing leaks across requests**. There is **no** cross-request/global caching by default. - Caching can be disabled or customized via `DataLoaderOptions` when registering through `BatchLoaderRegistry`. ## Ordering semantics - **Mapped** batch functions correlate by **map key** — order of the returned map is irrelevant. - **Positional** batch functions must return values in **exactly the key order** the DataLoader passed in; the DataLoader de-duplicates keys, so your list length must match the **distinct** key list it hands you. ## Why level-by-level matters Batching only merges loads that are **in flight simultaneously**. Two `load()` calls separated by an `await`/blocking boundary land in **different dispatch windows** and won't batch. This is exactly why you must **never block** on a load future inside a resolver — blocking collapses the batch window to one key. ## Reactive & threading Batch functions typically return `Mono`/`Flux`; Spring bridges these to the DataLoader's `CompletionStage`. Blocking repository work should run on a bounded scheduler (`Schedulers.boundedElastic()`), not the event loop, to avoid starving dispatch. ## Design implications (principal lens) - **Chained batching** makes GraphQL depth cheaper than it looks, but each level still costs a round trip — very deep queries still multiply round trips linearly with depth (not with breadth). Combine with query-depth/complexity limits. - **Per-request caching** is a correctness lever: it guarantees a consistent view of an entity within one operation, but you must invalidate deliberately if a mutation and a read touch the same key in one request. - **Positional vs mapped** is a reliability decision; mapped is the safe default for anything backed by a query that can filter/reorder. - Choosing `@BatchMapping` (declarative) vs `BatchLoaderRegistry` (explicit) trades convenience for control over options, naming, and loader sharing.

  • For a query `books { author { publisher { name } } }`, roughly how many data-source calls with batching, and why?
    About three: one for books, one batched call for all authors, one batched call for all publishers. Each level defers and dispatches once, so cost scales with query depth (number of hops), not with the breadth N at any level.
  • Two load() calls for the same entity but separated by a blocking await land in different dispatch windows. What's the effect?
    They won't be batched together — each dispatch window sees only the keys queued before it dispatches. Blocking between loads splits the batch, which is why resolvers must return futures and never block. Note the per-request cache can still dedupe an identical key if the future is still cached.
  • How does per-request caching interact with a mutation that updates an entity read later in the same operation?
    The DataLoader may return the cached pre-mutation future for that key, yielding a stale read within the request. If that matters you must clear/prime the loader's cache for the affected key, or disable caching via DataLoaderOptions.

saying these in an interview costs you the question

  • Thinking DataLoader provides a global/cross-request cache
  • Believing dispatch happens per load() call rather than per level boundary
  • Claiming deep queries are free because batching flattens everything into one call
  • Assuming positional batch results can be any order

context