A Fast GraphQL API With Fastify and Yoga · Part 4 of 8 in the series
Part 4: Batching With DataLoader
Collect the lookups of a request with DataLoader and answer them with one query per table, so the quest board needs three SQL queries instead of sixteen.

Quest objectives
- Understand how DataLoader batches and caches
- Write batch functions with Drizzle and inArray
- Create fresh loaders for every request
- Load one-to-many relations in batches
- Prime a loader with rows you already have
The resolvers from the last part each fetch their own row. That is easy to write and terribly slow. DataLoader keeps the easy part and fixes the slow one.
How DataLoader works
A loader has one method that matters, load(key). It returns a promise, but does not fetch anything yet. It notes the key and waits until the code that is running right now has finished. GraphQL calls the resolvers of all ten quests one after the other in that time, so by then the loader holds every guild id they asked for. It calls your batch function once with all of them, and that function answers with one query.
Two rules make it work. The batch function returns exactly one value per key, in the order of the keys. And the loader remembers every key it has seen, so the same guild is fetched once, however many quests belong to it.
The loaders
$ pnpm add dataloaderdependencies:+ dataloader 2.2.3Done in 98ms using pnpm v12.16.1
Create src/loaders.ts:
import DataLoader from 'dataloader';
import { inArray } from 'drizzle-orm';
import type { Database } from './db/index.ts';
import { adventurers, guilds, quests } from './db/schema.ts';
/** One row per id, in the order of the ids, or an error where a row is missing. */
function one<T extends { id: number }>(ids: readonly number[], rows: T[], type: string) {
const byId = new Map(rows.map((row) => [row.id, row]));
return ids.map((id) => byId.get(id) ?? new Error(`${type} ${id} not found`));
}
/** All rows per key, in the order of the keys, and an empty list where there are none. */
function many<T>(keys: readonly number[], rows: T[], keyOf: (row: T) => number | null) {
const groups = Map.groupBy(rows, keyOf);
return keys.map((key) => groups.get(key) ?? []);
}
export function createLoaders(db: Database) {
const guild = new DataLoader(async (ids: readonly number[]) => {
const rows = await db.select().from(guilds).where(inArray(guilds.id, [...ids]));
return one(ids, rows, 'Guild');
});
const adventurer = new DataLoader(async (ids: readonly number[]) => {
const rows = await db.select().from(adventurers).where(inArray(adventurers.id, [...ids]));
return one(ids, rows, 'Adventurer');
});
const adventurersByGuild = new DataLoader(async (guildIds: readonly number[]) => {
const rows = await db
.select()
.from(adventurers)
.where(inArray(adventurers.guildId, [...guildIds]))
.orderBy(adventurers.id);
return many(guildIds, rows, (row) => row.guildId);
});
const questsByGuild = new DataLoader(async (guildIds: readonly number[]) => {
const rows = await db
.select()
.from(quests)
.where(inArray(quests.guildId, [...guildIds]))
.orderBy(quests.id);
return many(guildIds, rows, (row) => row.guildId);
});
const questsByAssignee = new DataLoader(async (adventurerIds: readonly number[]) => {
const rows = await db
.select()
.from(quests)
.where(inArray(quests.assigneeId, [...adventurerIds]))
.orderBy(quests.id);
return many(adventurerIds, rows, (row) => row.assigneeId);
});
return { guild, adventurer, adventurersByGuild, questsByGuild, questsByAssignee };
}
export type Loaders = ReturnType<typeof createLoaders>;The query in each batch function asks for all keys at once with inArray, so it becomes where id in (…). The database answers in any order and leaves out ids it does not have, so the two helpers put the rows back in the order of the keys:
oneis for loading by id. A missing row becomes anErrorin its place, and only theloadfor that id fails.manyis for lists, such as the adventurers of a guild. It groups the rows withMap.groupByand gives a guild without adventurers an empty list.
One set of loaders per request
The cache of a loader never forgets. A loader that lived as long as the server would hand out the guild it loaded on Monday for the rest of the week, and it would share data between users who must not see each other's. So every request gets its own loaders, which is cheap, and their cache lives exactly as long as the request. Put them in the context:
import type { FastifyReply, FastifyRequest } from 'fastify';
import type { YogaInitialContext } from 'graphql-yoga';
import { db, type Database } from './db/index.ts';
import { createLoaders, type Loaders } from './loaders.ts';
export interface ServerContext {
req: FastifyRequest;
reply: FastifyReply;
}
export interface Context extends YogaInitialContext, ServerContext {
db: Database;
loaders: Loaders;
}
export function createContext(): Pick<Context, 'db' | 'loaders'> {
return { db, loaders: createLoaders(db) };
}Then let the field resolvers load through them. Replace everything from Guild to the end of src/resolvers.ts:
Guild: {
adventurers: (guild, _args, { loaders }) => loaders.adventurersByGuild.load(guild.id),
quests: async (guild, { status }, { loaders }) => {
const guildQuests = await loaders.questsByGuild.load(guild.id);
return status ? guildQuests.filter((quest) => quest.status === status) : guildQuests;
},
},
Adventurer: {
guild: (adventurer, _args, { loaders }) => loaders.guild.load(adventurer.guildId),
quests: (adventurer, _args, { loaders }) => loaders.questsByAssignee.load(adventurer.id),
},
Quest: {
guild: (quest, _args, { loaders }) => loaders.guild.load(quest.guildId),
assignee: (quest, _args, { loaders }) =>
quest.assigneeId === null ? null : loaders.adventurer.load(quest.assigneeId),
},
};Delete findGuild and findAdventurer, they are not needed any more, and with them the imports of and, Database and adventurers. Guild.quests still has its status argument. The loader is keyed by guild alone, so the resolver loads all quests of the guild and filters them. A loader can also be keyed by guild and status together, with a cacheKeyFn that turns both into one key, but for the few quests of a guild this is simpler.
The quest board again
Send the same quest board query as before. The answer is the same, and this is the log:
Query: select "id", "title", "reward", "status", "guild_id", "assignee_id" from "quests" order by "quests"."id" limit $1 -- params: [20]Query: select "id", "name", "motto" from "guilds" where "guilds"."id" in ($1, $2, $3) -- params: [1, 2, 3]Query: select "id", "name", "class", "level", "guild_id" from "adventurers" where "adventurers"."id" in ($1, $2, $3, $4, $5) -- params: [1, 2, 5, 4, 7][15:20:50.428] INFO (141): Request processed durationMs: 34
Three queries: the list, all guilds, all adventurers. The list of ids in each one is every key the resolvers asked for, without the repeats. With 50 quests on the page it would still be three.
And the big guild query from the last part:
$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ guilds { name adventurers { name quests { title } } quests { title assignee { name } } } }"}'Query: select "id", "name", "motto" from "guilds" order by "guilds"."id"Query: select "id", "name", "class", "level", "guild_id" from "adventurers" where "adventurers"."guild_id" in ($1, $2, $3) order by "adventurers"."id" -- params: [1, 2, 3]Query: select "id", "title", "reward", "status", "guild_id", "assignee_id" from "quests" where "quests"."guild_id" in ($1, $2, $3) order by "quests"."id" -- params: [1, 2, 3]Query: select "id", "title", "reward", "status", "guild_id", "assignee_id" from "quests" where "quests"."assignee_id" in ($1, $2, $3, $4, $5, $6, $7, $8, $9) order by "quests"."id" -- params: [1, 2, 3, 4, 5, 6, 7, 8, 9]Query: select "id", "name", "class", "level", "guild_id" from "adventurers" where "adventurers"."id" in ($1, $2, $3, $4, $5) -- params: [1, 2, 5, 4, 7]
21 queries became 5, one for each level of the query and table. The number no longer depends on how many rows there are, only on how deep the query goes.
Priming
Look at the last two queries. The second one loads all nine adventurers of the guilds, and the fifth loads five of them again, by id, for the assignees of the quests. The loaders do not know about each other. But the rows are already there, so hand them over: a loader's prime puts a value in its cache without loading anything. In createLoaders, change adventurersByGuild:
const adventurersByGuild = new DataLoader(async (guildIds: readonly number[]) => {
const rows = await db
.select()
.from(adventurers)
.where(inArray(adventurers.guildId, [...guildIds]))
.orderBy(adventurers.id);
// These adventurers need no query of their own any more in this request.
for (const row of rows) adventurer.prime(row.id, row);
return many(guildIds, rows, (row) => row.guildId);
});Run the guild query once more:
Query: select "id", "name", "motto" from "guilds" order by "guilds"."id"Query: select "id", "name", "class", "level", "guild_id" from "adventurers" where "adventurers"."guild_id" in ($1, $2, $3) order by "adventurers"."id" -- params: [1, 2, 3]Query: select "id", "title", "reward", "status", "guild_id", "assignee_id" from "quests" where "quests"."guild_id" in ($1, $2, $3) order by "quests"."id" -- params: [1, 2, 3]Query: select "id", "title", "reward", "status", "guild_id", "assignee_id" from "quests" where "quests"."assignee_id" in ($1, $2, $3, $4, $5, $6, $7, $8, $9) order by "quests"."id" -- params: [1, 2, 3, 4, 5, 6, 7, 8, 9]
Four queries. The assignees came from the cache that the second query filled.
Reading is fast now. In the next part the board starts to change: mutations post, accept and complete quests, and subscriptions tell every open board about it.