skills/graphql-n-plus-one-dataloader/SKILL.md
Diagnose and eliminate the GraphQL N+1 resolver explosion using Facebook DataLoader's per-request batching, plus the second-order defenses (persisted queries, depth limits, cost analysis). Use when a GraphQL query that returns N items in a list triggers N+1 database round trips, when designing nested resolvers that load associations, when a list query times out under load, or when you need to budget query cost before resolvers run. NOT for REST-style endpoint design (use api-versioning-strategy), generic database query optimization (use database-query-optimizer), or schema design (use graphql-server-architect).
npx skillsauth add curiositech/windags-skills graphql-n-plus-one-dataloaderInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
The signature performance pathology of GraphQL: a query returning N items in a list, each resolving a nested association field, fires 1 query for the list + N queries for the children = N+1 round trips. Facebook's dataloader library is the canonical fix — it coalesces all .load(key) calls within a single tick of the event loop into a single batchLoadFn(keys) call, collapsing 1+N → at most 2 round trips per request, regardless of nesting depth.
flowchart TD
A[Query returns list with nested field] --> B{Nested field hits a datastore?}
B -->|No| Z[No N+1 risk]
B -->|Yes| C{Resolver loads parent-by-parent?}
C -->|Yes| D[N+1 problem confirmed]
C -->|No - already batched| E[Already fixed]
D --> F{Same key across resolvers?}
F -->|Yes| G[Wrap with DataLoader<br/>per-request instance]
F -->|No - unique key per call| H[Refactor to batchable<br/>SELECT ... WHERE id IN (...)]
G --> I{Total cost still high?}
I -->|Yes| J[Add cost analysis<br/>+ depth limit + APQ]
I -->|No| K[Ship + monitor]
H --> G
Given:
query {
authors { # 1 query: SELECT * FROM authors
name
address { # N queries: SELECT * FROM addresses WHERE author_id = ?
country
}
}
}
Without DataLoader: 1 + N round trips (50 authors → 51 queries).
With DataLoader: 2 round trips, always (1 for authors, 1 batched WHERE id IN (...) for addresses).
Shopify's exact framing: "if there were fifty authors, then it would make fifty-one round trips for all the data. It should be able to fetch all the addresses together in a single round trip, so only two round trips to datastores in total, regardless of the number of authors." (Shopify Engineering, 2018)
Why GraphQL makes it worse than REST: "In REST, costs are predictable because there's one trip per endpoint requested. In GraphQL, there's only one endpoint, and it's not indicative of the potential size of incoming requests."
new DataLoader(batchLoadFn [, options])
"A batch loading function accepts an Array of keys, and returns a Promise which resolves to an Array of values or Error instances." (graphql/dataloader README)
"The Array of values must be the same length as the Array of keys. Each index in the Array of values must correspond to the same index in the Array of keys."
If your backend returns rows in a different order than the keys came in (SELECT * FROM users WHERE id IN (1, 2, 3) does NOT guarantee row order), you must reorder before resolving the Promise. DataLoader matches by index, not by id.
// BUG: rows might come back as [id=2, id=3, id=1]
const userLoader = new DataLoader(async (ids) => {
return db.users.where({ id: ids });
});
// CORRECT: reorder by key
const userLoader = new DataLoader(async (ids) => {
const rows = await db.users.where({ id: ids });
const byId = new Map(rows.map((r) => [r.id, r]));
return ids.map((id) => byId.get(id) ?? new Error(`User ${id} not found`));
});
"DataLoader will coalesce all individual loads which occur within a single frame of execution (a single tick of the event loop) and then call your batch function with all requested keys."
Every .load() in the same microtask flushes together. This works recursively — every wave of resolvers gets its own batch.
"DataLoader caching does not replace Redis, Memcache, or any other shared application-level cache. DataLoader is first and foremost a data loading mechanism, and its cache only serves the purpose of not repeatedly loading the same data in the context of a single request." "Avoid multiple requests from different users using the DataLoader instance, which could result in cached data incorrectly appearing in each request."
The cache is request-scoped memoization, not an application cache. Construct in your context factory:
const server = new ApolloServer({
schema,
context: () => ({
loaders: {
user: new DataLoader(batchUsers),
address: new DataLoader(batchAddresses),
},
}),
});
// In resolver:
Author: {
address: (author, _, { loaders }) => loaders.address.load(author.id),
}
const userLoader = new DataLoader(...) declared at module scope, imported into resolvers.context: () => ({ loaders: { ... } }).batchLoadFn returns db.users.where({ id: keys }) directly.WHERE id IN (1,2,3) returns rows in (1,2,3) order. SQL has no such guarantee.dataloader README leads with it.Map(rows.map((r) => [r.id, r])) and keys.map((k) => byId.get(k)).userLoader.prime(id, user) called with results from Redis; expectation that it persists across requests.validationRules: [depthLimit(7)], no cost analysis.users(first: 10000) { posts(first: 10000) { ... } } — depth 2, but cartesian explosion at the database. Server times out / OOMs.users(first: 1) from users(first: 10000) — both are depth 1. Cost analysis with multipliers handles list-arg explosion.graphql-cost-analysis became standard.depthLimit(7, { ignore: ['__schema', '__type'] }) — introspection queries are deeper than your data queries.type Author { id: ID! name: String! address: Address }
type Address { id: ID! country: String! city: String! }
type Query { authors: [Author!]! }
const resolvers = {
Query: {
authors: () => db.authors.all(), // 1 query
},
Author: {
address: (author) => db.addresses.findByAuthorId(author.id), // N queries
},
};
// batch function — same length, same order
async function batchAddressesByAuthorId(authorIds) {
const rows = await db.addresses.where({ author_id: authorIds });
const byAuthor = new Map(rows.map((r) => [r.author_id, r]));
return authorIds.map((id) => byAuthor.get(id) ?? null);
}
// per-request context
const server = new ApolloServer({
schema,
context: () => ({
loaders: { addressByAuthor: new DataLoader(batchAddressesByAuthorId) },
}),
});
const resolvers = {
Query: { authors: () => db.authors.all() }, // 1 query
Author: {
address: (author, _, { loaders }) =>
loaders.addressByAuthor.load(author.id), // batched: 1 query total
},
};
Result: 50 author rows → still 2 round trips. 5,000 author rows → still 2 round trips. The collapse is constant in N.
// Trace queries fired during a single GraphQL request
db.on('query', (q) => process.stdout.write(`SQL: ${q}\n`));
await graphql({
schema, source: `query { authors { name address { country } } }`,
});
// Expect: exactly 2 lines printed.
DataLoader fixes round-trip count. These fix payload size, abuse, and CDN cacheability:
"As query strings become larger, increased latency and network usage can noticeably degrade client performance. A persisted query is a query string that's cached on the server side, along with its unique identifier (always its SHA-256 hash). Clients can send this identifier instead of the corresponding query string, thus reducing request sizes dramatically." (Apollo APQ docs)
Three-step protocol:
extensions={"persistedQuery":{"version":1,"sha256Hash":"ecf4..."}}.PERSISTED_QUERY_NOT_FOUND. Client retries with both query string AND hash. Server stores mapping.CDN bonus: hashed queries can be GET requests; mutations stay POST. Configure with createPersistedQueryLink({ sha256, useGETForHashedQueries: true }).
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
schema,
validationRules: [depthLimit(7)], // reject queries with depth > 7
});
Validation runs before execution — abusive queries die at parse-time, not after the database is hammered.
Note: the original
stems/graphql-depth-limitrepo is gone (404). The npm package still installs but is unmaintained. Modern replacements:@graphile/depth-limit(addsmaxListDepth,maxSelfReferentialDepth),@envelop/depth-limit, GraphQL ArmorMaxDepth.
type Query {
posts(first: Int): [Post]
@cost(multipliers: ["first"], useMultipliers: true, complexity: 2)
}
"If the parameter is an array, its multiplier value will be the length of the array." (graphql-cost-analysis)
posts(first: 100) → cost = 100 × 2 = 200. maximumCost: 1000 rejects anything past 5 such fields.
Why cost > depth: depth-1 is not safety. users(first: 10000) is depth 1.
batchLoadFn returns an array keys.length long, in keys order, with null or Error for missing keysdb query log emits exactly 2 lines for a list-of-N query (not 1+N)__schema, __type) are exempted from depth limitsThis skill should NOT be used for:
api-versioning-strategydatabase-query-optimizer or postgres-performance skillgraphql-server-architectserver-sent-events-vs-websockets@cost directive, multipliers, max cost enforcementstems/graphql-depth-limit is gone)maxListDepthdata-ai
license: Apache-2.0 NOT for unrelated tasks outside this domain.
development
Use when designing caching strategies (cache-aside, write-through, write-behind), implementing distributed locks, building rate limiters, leaderboards, real-time streams (XADD/consumer groups), pub/sub, or tuning eviction policies. Triggers: thundering-herd on cache miss, dogpile on key expiry, Redlock vs SET-NX-PX choice, sliding-window rate limiter, hot-key on a single cluster slot, big-key blowup, MULTI/EXEC across slots, KEYS in production. NOT for Redis Cluster operations/admin (different domain), embedded KV (SQLite, leveldb), in-process LRU caches, or Memcached.
tools
Drawing the `'use client'` boundary correctly in React Server Components apps (Next.js App Router, RSC frameworks) — leaf-pushing, slot composition, serialization rules, and environment poisoning prevention. Grounded in react.dev and Next.js 16 docs.
development
Use when designing rate limiting for an API, choosing between token bucket / sliding window / leaky bucket / fixed window, implementing it in Redis, deciding edge (Cloudflare/Upstash) vs origin enforcement, sizing per-user vs per-IP vs per-endpoint quotas, returning the right 429 response with Retry-After, or fixing the boundary-burst bug in fixed-window limiters. Triggers: 429 too many requests, INCR + EXPIRE, ZADD + ZREMRANGEBYSCORE + ZCARD, X-RateLimit-Remaining header, Cloudflare WAF rate limiting rules, Upstash @upstash/ratelimit, leaky bucket shaping vs policing, distributed rate limiter consistency. NOT for DDoS mitigation specifically (different scale), CAPTCHA / bot management, full WAF design, or per-user quota billing.