CtrlK
BlogDocsLog inGet started
Tessl Logo

testland/graphql-n-plus-one-remediation

Traces a GraphQL resolver tree to locate the N+1 pattern (one parent query returns N rows, then a child field resolver fires once per row), classifies every child field resolver as safe or N+1 risk, and applies one of three fixes: per-request DataLoader batching, eager projection in the parent resolver, or selection-set-aware prefetch. Use when a list-returning resolver is added or changed in review, when a connection-pool exhaustion or slow-query alert traces back to GraphQL traffic, or when a resolver trace shows a child field resolved once per parent row.

80

Quality

100%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

Overview
Quality
Evals
Security
Files

examples.mdreferences/

Worked examples

Two scenarios that exercise the Step 2 classification and the Step 3 fix choice end to end.

Worked example 1: ORM lazy-loading, the silent case

Input resolver:

Post: {
  comments: (post) => post.comments,  // looks like a passthrough
}

This matches the "safe passthrough" row in Step 2 only if the parent query loaded comments. Prisma returns related records only when the query asks via include or select (prisma.io), so check the parent query first. If Query.posts has no include: { comments: true }, this is the lazy-load N+1 case.

Confirm with the query log rather than by reading:

const prisma = new PrismaClient({ log: ["query"] });
// Run the failing query, then count emitted statements.
// N statements of the form SELECT ... FROM Comment WHERE postId = ?
// confirms the fan-out; one SELECT ... WHERE postId IN (...) is the fix.

Report:

**Pattern:** silent N+1 via ORM lazy-loading.

**Location:** `resolvers/post.ts:67`

`post.comments` is not present on the parent result, so accessing it
triggers a separate query per post.

**Fix:** B if every `posts` query needs comments: add
`include: { comments: true }` to the `Query.posts` resolver. A if
`comments` is also reached from other parents, or if the comment list per
post is large enough that eager joining causes duplication of post
columns across comment rows.

Worked example 2: one HTTP call per row

Input resolver:

User: {
  paymentMethod: (user) => paymentClient.fetchById(user.paymentMethodId),
}

Report:

**Pattern:** N+1 via per-row HTTP call to the payment service.

**Location:** `resolvers/user.ts:34`

Worse than a database N+1: every invocation pays full network round-trip
latency, and the calls contend for the HTTP client's connection pool.

**Fix:** A (DataLoader) wrapping a batch endpoint.

```typescript
const paymentLoader = new DataLoader<string, PaymentMethod | null>(
  async (ids) => {
    const found = await paymentClient.fetchMany([...ids]);   // batch endpoint
    return ids.map(id => found.find(p => p.id === id) ?? null);
  }
);
```

The re-map is required: the batch endpoint may return rows in any order and
may omit unknown ids, and DataLoader requires the values array to match the
keys array in both length and index order
([github.com/graphql/dataloader](https://github.com/graphql/dataloader)).

If the payment service has no batch endpoint, the fix is cross-team: add
the batch endpoint first, then wrap it. Fixes B and C do not apply, since
the data lives outside the database the parent query reads.

SKILL.md

tile.json