Count the Resolver, Not the Query, to Catch a GraphQL N+1
You will catch a GraphQL N+1 much earlier by counting how many times your batch loader is called against a temporary mock server than by measuring response time, because latency tests hide the problem behind small local datasets, in-memory caches, and a developer machine that is simply too fast to notice. The classic mistake is to add a deeply nested field, run one query that returns three posts, see a snappy response, and ship. That query may call the author loader three times instead of once, but each call is so cheap that nothing complains until the same shape runs in production with fifty posts, remote storage, and a cold cache. So the rule here is not about rendering the graph or optimizing resolvers; the rule is about observing a number that should stay flat. When your schema says a post has an author and an author has posts, any query that returns ten posts and then asks for each author should produce exactly one author batch of size ten, not ten batches of size one. If your mock GraphQL server can record the size of every batch and fail when the pattern violates that expectation, you have a regression gate that does not depend on the model, the network, or your intuition about what is fast enough. The setup is deliberately small. Start with a resolver map that treats authors as a remote resource. The get_author_batch function receives a list of keys and records its length every time it is called, then returns fake records for those keys. The rest of the server is just a GraphQL schema with a Post type, an Author type, and a nested posts field on author so the query can loop back through the same resolver. The point of the nesting is not to create a realistic API; it is to create a shape that can be traversed more than one way. Here is a tiny Python fixture with a counter, not a framework: batch_sizes = [] def get_author_batch(keys): batch_sizes.append(len(keys)) return [{"id": key, "name": f"author-{key}"} for key in keys] The assertion is equally small: after executing a query, find the largest batch size for a given loader and compare it with the number of unique keys in the response. If the query asks for ten distinct authors, the largest batch should be ten, and the total number of calls should be one. If you see ten calls of size one, you have found the N+1 before any data actually gets slow. This is where the free model can be useful without being allowed to write your resolver. Disclosure: This article was prepared as part of MonkeyCode's product outreach. You can give the model the schema and ask it to produce a set of query documents that exercise the same nested relationship in different ways: aliases for repeated fields, fragments that re-include the author, pagination arguments that change the list length, and cycles that go two or three levels deep. The model does not decide whether a result is correct; it only writes GraphQL strings that your existing mock runner executes. MonkeyCode's free model access is enough for this kind of corpus generation because the task is narrow and the output is easy to review, and the free server option gives you a disposable HTTP host on which to run the counting mock without committing to extra infrastructure just to test a field. The reason this beats a normal integration test is that a human writing the test tends to stop at the obvious query. A model asked to vary the traversal will produce the awkward case where a fragment expands the same author through two different aliases and your loader gets called once for each alias even though the underlying keys are identical. It will also produce a case where a nested query asks for posts { author { posts { author } } } and your batching strategy suddenly has to re-enter the same loader before the first batch has finished. Those are the cases where a correct but inefficient resolver hides behind a tiny fixture. The runner can be a simple loop over the generated documents, and the free server only needs an endpoint that exposes the per-request batch log. You can run it in CI by starting the server once for the whole test file, executing the corpus, and checking that the recorded call pattern obeys a rule such as โno loader called more than once for the same set of keys in one request.โ That rule will not be perfect, and it is not meant as a proof of performance. It is a heuristic that catches the most common structural bug. There are real limitations. A batching layer may legitimately call the loader multiple times when the query requires several passes, and a strict call-count assertion will then be a false positive. If your dataloader deduplicates keys across the entire request, the batch size may be smaller than the number of unique keys because some authors were already loaded by a sibling field. The model has no knowledge of those caching details, so you must keep the assertions aligned with the actual contract of your loader rather than treating the generated queries as a one-size-fits-all benchmark. Teams whose GraphQL layer is fully auto-batched and deduplicated may gain little from this; the technique is most useful when you maintain a hand-written resolver that can accidentally introduce a nested loop. Do not use this to tune latency or to prove that an endpoint is fast. It will not tell you anything useful about query cost if your backend is intentionally doing one round trip per key because the resource cannot be batched. The value is confined to finding places where the shape of the query forces repeated work that should have been grouped, and that is exactly the class of bug that normal testing tends to miss until production. Start with ten generated query documents, one counter inside the mock loader, and a rule you can explain in one sentence; if the number of batches surprises you, the resolver is trying to tell you something. Top comments (0)
Comments
No comments yet. Start the discussion.