The standard cure for the N+1 query problem in GraphQL is a DataLoader: instead of resolving each child field with its own round-trip to the backend, you hand the loader a key, it collects every key requested during a tick, and fetches them all in one batch. I recently came across a resolver that used a DataLoader perfectly correctly — and still fired one query per parent. Here it is:
@SchemaMapping
fun metrics(
campaign: Campaign,
@Argument input: DateRangeInput?,
dataLoader: DataLoader<CampaignWithDateRangeInput, CampaignMetrics>,
): CompletableFuture<CampaignMetrics> =
dataLoader.load(
CampaignWithDateRangeInput(campaign, bookingService.findByCampaignId(campaign.id.toIntegerId()), input?.toDateRange()),
)
At a glance this looks right. metrics is a field on Campaign, and instead of computing the value inline it defers to dataLoader.load(...) and returns a CompletableFuture. When a query asks for metrics on fifty campaigns, the framework calls this resolver fifty times, collects fifty keys, and the batch loader resolves all fifty CampaignMetrics in a single round-trip. No N+1 there.
Where the N+1 hides
Look at how the key is built: bookingService.findByCampaignId(campaign.id.toIntegerId()). That is a REST call — and a fairly slow one — and it runs synchronously, inside the resolver, once for every campaign, before the DataLoader ever gets a chance to batch anything. The loader dutifully batches the CampaignWithDateRangeInput → CampaignMetrics step, but assembling the fifty keys has already cost fifty booking REST calls. The N+1 didn’t disappear; it moved one line up, into the key construction, where it is easy to miss.
That is what makes this one sneaky. The DataLoader is real, the batching is real, and if you go looking at the batched CampaignMetrics fetch you will see a single call and conclude all is well. The slow calls are hiding under a different method name — findByCampaignId — and they fire during key assembly, so nothing about the DataLoader itself looks wrong. A request trace is what gives it away: N calls to the bookings service, one per campaign.
The fix
The rule of thumb: a DataLoader key must be cheap to construct. It should carry only data you already have in hand and must never trigger I/O to build. Anything that needs a round-trip belongs inside the batch loader, where it can be batched along with everything else.
So drop the booking lookup from the key — CampaignWithDateRangeInput now carries just the campaign and the date range — and the resolver does no I/O at all:
@SchemaMapping
fun metrics(
campaign: Campaign,
@Argument input: DateRangeInput?,
dataLoader: DataLoader<CampaignWithDateRangeInput, CampaignMetrics>,
): CompletableFuture<CampaignMetrics> =
dataLoader.load(
CampaignWithDateRangeInput(campaign, input?.toDateRange()),
)
Those REST calls move into a mapped batch loader, registered for the same type pair, where they can be made once for the whole batch instead of once per campaign. It receives the whole collection of keys the framework gathered during the tick:
batchLoaderRegistry
.forTypePair(CampaignWithDateRangeInput::class.java, CampaignMetrics::class.java)
.registerMappedBatchLoader { campaignsWithDateRange, _ ->
mono(Dispatchers.IO + headersAsCoroutineContext()) { forCampaignsGrouped(campaignsWithDateRange) }
}
And the grouping function does the fetch once for the entire batch — a single findByCampaignIds and a single getForBookings — then maps each key back to its own campaign’s metrics:
private fun forCampaignsGrouped(
campaignsWithDateRange: Collection<CampaignWithDateRangeInput>,
): Map<CampaignWithDateRangeInput, CampaignMetrics> {
val allBookings = bookingService.findByCampaignIds(campaignsWithDateRange.map { it.campaign.id.toIntegerId() })
val dateRange = campaignsWithDateRange.first().input
val metricsByBookingId = metricsService.getForBookings(allBookings, dateRange)
return campaignsWithDateRange.associateWith { campaignWithDateRange ->
val campaignId = campaignWithDateRange.campaign.id
val campaignBookingIds = allBookings.filter { it.campaignId == campaignId.toIntegerId() }.map { it.id }
val campaignMetrics = metricsByBookingId
.filterKeys { campaignBookingIds.contains(it.toIntegerId()) }
.values
// ... combine this campaign’s per-booking metrics into a single CampaignMetrics
}
}
Now a query over fifty campaigns makes two batched calls — one for the bookings, one for the metrics — instead of fifty-plus.
Takeaway
A DataLoader only batches the work you actually put inside it. If you make a backend call while building the key, you have reintroduced the very N+1 the loader was meant to prevent — and because the loader still looks correct, the only reliable way to catch it is to watch the outgoing calls, not the code. Whenever you write dataLoader.load(...), check that every argument to the key is already in memory.

Leave a Reply