Repository navigation
Dataloader dispatch never triggers inside @defer when multiple deferred fragments exist #4269
Description
Title
Dataloader dispatch never triggers inside @defer when multiple deferred fragments exist
Labels
bug
Body
Describe the bug
When a query contains multiple @defer fragments at the same level, dataloaders invoked inside those deferred fragments are never dispatched. The deferred payloads hang until the request times out.
This was introduced in #3980 ("make dataloader work inside defer blocks"), which shipped in v25.0.
Root Cause
In DeferredExecutionSupportImpl.createDeferredFragmentCall(), the AlternativeCallContext is constructed with deferredFields.size() — the total field count across all @defer fragments — instead of the field count for the specific fragment being created:
private DeferredFragmentCall createDeferredFragmentCall(DeferredExecution deferredExecution) {
int level = parameters.getPath().getLevel() + 1;
// BUG: deferredFields.size() is the total across ALL @defer fragments
AlternativeCallContext alternativeCallContext = new AlternativeCallContext(level, deferredFields.size());
// But mergedFields is only the fields for THIS specific fragment
List<MergedField> mergedFields = deferredExecutionToFields.get(deferredExecution);
...
}AlternativeCallContext.fields is used by both dispatch strategies to decide when to trigger dispatch:
PerLevelDataLoaderDispatchStrategy.fieldFetched() (line ~362):
if (happenedFirstLevelFetchCount == callStack.expectedFirstLevelFetchCount) {
dispatch(level, callStack); // NEVER reached when expectedFirstLevelFetchCount is inflated
}ExhaustedDataLoaderDispatchStrategy.deferFieldFetched() (line ~174):
if (deferredFragmentRootFieldsCompleted == parameters.getDeferredCallContext().getFields()) {
decrementObjectRunningAndMaybeDispatch(callStack); // NEVER reached
}Each DeferredFragmentCall only invokes its own fields, so the counter will only reach the count of fields in that fragment, never the inflated total.
Example: 2 @defer fragments with 1 field each → expectedFirstLevelFetchCount = 2, but each fragment only fetches 1 field → dispatch condition 1 == 2 is never satisfied → dataloaders hang.
To Reproduce
query {
shops {
id
name
... @defer(label: "deferred1") {
departments { # uses a dataloader
name
}
}
... @defer(label: "deferred2") {
expensiveDepartments { # uses a dataloader
name
}
}
}
}With the existing test infrastructure (BatchCompareDataFetchers), both departments and expensiveDepartments use batch-loaded data fetchers. The query hangs and never delivers the deferred payloads.
A single @defer fragment with multiple fields (e.g., both departments and expensiveDepartments in one ... @defer {} block) works correctly, because in that case deferredFields.size() happens to equal mergedFields.size().
Mixed deferred and non-deferred fields
The fix is safe when a selection set contains a mix of deferred and non-deferred fields. The constructor in DeferredExecutionSupportImpl (lines 86–95) cleanly partitions the selection set: fields with any non-deferred usage are routed into nonDeferredFieldNames and never added to deferredExecutionToFields. So deferredExecutionToFields.get(deferredExecution) (which gives mergedFields) only ever contains fields belonging to that specific @defer fragment:
mergedSelectionSet.getSubFields().values().forEach(mergedField -> {
if (mergedField.getFieldsCount() > mergedField.getDeferredExecutions().size()) {
nonDeferredFieldNamesBuilder.add(mergedField.getSingleField().getResultKey());
return; // non-deferred fields are excluded from deferredExecutionToFields
}
mergedField.getDeferredExecutions().forEach(de -> {
deferredExecutionToFieldsBuilder.put(de, mergedField);
deferredFieldsBuilder.add(mergedField);
});
});For example, a query with non-deferred departments and a deferred expensiveDepartments correctly produces an AlternativeCallContext with fields=1 for the single deferred fragment.
Expected behavior
Both deferred fragments should resolve and deliver their incremental payloads promptly.
Suggested Fix
Pass mergedFields.size() (the per-fragment field count) instead of deferredFields.size() (the total across all fragments):
private DeferredFragmentCall createDeferredFragmentCall(DeferredExecution deferredExecution) {
int level = parameters.getPath().getLevel() + 1;
- AlternativeCallContext alternativeCallContext = new AlternativeCallContext(level, deferredFields.size());
-
- List<MergedField> mergedFields = deferredExecutionToFields.get(deferredExecution);
+ List<MergedField> mergedFields = deferredExecutionToFields.get(deferredExecution);
+ AlternativeCallContext alternativeCallContext = new AlternativeCallContext(level, mergedFields.size());Versions
- graphql-java: 25.0 (also present on
masterat bd87652) - Affects all three dispatch strategy modes: default (
PerLevel),ENABLE_DATA_LOADER_CHAINING, andENABLE_DATA_LOADER_EXHAUSTED_DISPATCHING
Activity
github-actions commentedon Apr 26, 2026
Hello, this issue has been inactive for 60 days, so we're marking it as stale. If you would like to continue this discussion, please comment within the next 30 days or we'll close the issue.
dondonz commentedon Apr 27, 2026
Fixed in #4270