Skip to content
Docs

Solving the GraphQL N+1 Query Problem

The N+1 query problem is the most common performance issue in GraphQL APIs. BifrostQL solves it at the architecture level — no DataLoader, no manual batching, no per-resolver wiring.

When a GraphQL resolver fetches a list and then resolves a related field for each item, the database sees 1 query for the list plus N queries for the related rows.

{
orders(limit: 50) {
data {
orderId
__join {
customers {
data { name }
}
}
}
}
}

A naive resolver executes:

  1. SELECT * FROM orders LIMIT 50 — 1 query
  2. SELECT * FROM customers WHERE customerId = @id — repeated 50 times

That’s 51 database round-trips for a single GraphQL request. Add another joined table and it doubles. Nest three levels deep and it compounds further.

The cost isn’t just latency. Each round-trip carries connection overhead, query parsing, and execution plan generation. Under load, N+1 patterns exhaust connection pools and saturate database CPU.

The standard approach, introduced by Facebook. DataLoader collects all keys requested during a single tick of the event loop, then issues one batched query per relationship depth.

Tick 1: SELECT * FROM orders LIMIT 50 → 1 query
Tick 2: SELECT * FROM customers WHERE id IN (...) → 1 query (batched)

This reduces 51 queries to 2. But each nesting level adds another tick and another round-trip. A four-level deep query still makes four separate database calls. And every resolver that touches a database needs a DataLoader wired up — it’s manual work that scales with your schema.

Tools like Hasura and PostGraphile take the opposite approach. They inspect the full GraphQL AST and compile it into a single SQL query with JOINs before any execution happens.

This eliminates N+1 entirely for supported databases, but ties you to a specific database engine and a specific query strategy. Complex queries can produce deeply nested SQL JOINs that the query planner struggles to optimize.

ORMs like Prisma offer eager loading (include()) and relation load strategies. Entity Framework has .Include(). These help but depend on the developer choosing the right loading strategy for each query path, and they don’t integrate with GraphQL resolvers automatically.

BifrostQL uses a query-per-table approach with single round-trip execution. It sits between DataLoader-style batching and full query compilation, combining the strengths of both.

Before executing anything, BifrostQL parses the entire GraphQL request into a GqlObjectQuery tree structure. This gives it visibility into every table, join, filter, and sort across the entire request.

Step 2: Generate one SQL statement per table

Section titled “Step 2: Generate one SQL statement per table”

For each table referenced in the query, BifrostQL generates a single parameterized SQL statement. Join queries use correlated subqueries to scope child rows to their parents:

-- Query 1: Parent table
SELECT [orderId], [customerId] FROM [orders]
WHERE ... ORDER BY ... OFFSET 0 ROWS FETCH NEXT 50 ROWS ONLY;
-- Query 2: Count
SELECT COUNT(*) FROM [orders] WHERE ...;
-- Query 3: Joined table (customers scoped to parent rows)
SELECT [a].[JoinId] [src_id], [b].[customerId], [b].[name]
FROM (SELECT DISTINCT [customerId] AS [JoinId] FROM [orders] WHERE ...)
[a] INNER JOIN [customers] [b] ON [a].[JoinId] = [b].[customerId];

The subquery SELECT DISTINCT [customerId] AS [JoinId] FROM [orders] ensures only the customers referenced by the parent result set are fetched — no over-fetching, no under-fetching.

Step 3: Execute all statements in one round-trip

Section titled “Step 3: Execute all statements in one round-trip”

All generated SQL statements are concatenated and sent to the database as a single command:

command.CommandText = string.Join(";\r\n", sqlStatements);
using var reader = command.ExecuteReader();

The database returns multiple result sets from one ExecuteReader call. BifrostQL reads each result set sequentially and maps it back to the corresponding table in the query tree.

The ReaderEnum class stitches result sets together, matching child rows to parent rows using the src_id column generated in the join query. The final GraphQL response is assembled without any additional database calls.

For the query at the top of this page, BifrostQL generates 2 SQL statements (orders + customers) plus a count query, all in 1 database round-trip.

Nesting depthTables queriedNaive resolversDataLoaderBifrostQL
1 levelorders → customers1 + N2 round-trips1 round-trip
2 levelsorders → customers → addresses1 + N + N²3 round-trips1 round-trip
3 levelsorders → customers → addresses → cities1 + N + N² + N³4 round-trips1 round-trip

The number of SQL statements scales with the number of tables in the query, not the number of rows. And all statements execute in a single round-trip regardless of depth.

ToolPlatformN+1 StrategySetup requiredDB round-tripsTrade-offs
DataLoaderAny (JS, .NET, etc.)Batch keys per resolver tickOne DataLoader per relationship1 per nesting levelManual wiring; still multiple round-trips for deep queries
HasuraHaskell / DockerCompile full GraphQL AST to single SQL with JOINsZero (schema-driven)1PostgreSQL-centric; complex queries produce deeply nested JOINs
PostGraphileNode.jsLook-ahead (V4) / Grafast planning (V5)Zero (schema-driven)1PostgreSQL-only; V5 is a significant rewrite
Join MonsterNode.jsSchema annotations generate SQL JOINs from ASTMedium — annotate every type1JS-only; large JOINs risk memory issues; community-maintained
PrismaNode.jsInternal DataLoader + optional join strategyLow-Medium — use include()1-2Tied to Prisma ORM; join strategy is newer
Hot Chocolate.NETDataLoader + ProjectionsMedium — register DataLoaders1 per nesting levelDataLoaders and projections don’t fully integrate (#6191)
BifrostQL.NETQuery-per-table, single round-trip batchZero — reads DB schema1SQL Server, PostgreSQL, MySQL; .NET ecosystem

DataLoader is the standard N+1 solution, but it requires explicit setup. Every relationship needs a DataLoader registered, keys defined, and batch functions written. Miss one and you’re back to N+1. DataLoader also can’t eliminate the multiple-round-trip cost of deeply nested queries — each level of nesting is a separate database call.

BifrostQL requires no DataLoader layer at all. The query tree is analyzed upfront, and all SQL is generated and executed before any resolver runs.

Hasura and PostGraphile take a similar philosophy — compile GraphQL to SQL from the database schema. The key differences:

  • Database support: Hasura and PostGraphile are PostgreSQL-first. BifrostQL supports SQL Server, PostgreSQL, and MySQL.
  • Query strategy: Hasura compiles to a single SQL statement with nested JOINs. BifrostQL generates separate statements per table (avoiding deeply nested JOINs that can confuse query planners) but sends them all in one round-trip.
  • Platform: Hasura is a standalone Docker service. PostGraphile is Node.js. BifrostQL is a .NET library that embeds into your ASP.NET Core app.

Hot Chocolate is the most popular GraphQL library in .NET. It provides DataLoader support through GreenDonut and projections through IQueryable. But:

  • DataLoaders must be manually registered for each relationship
  • Projections and DataLoaders don’t fully integrate — you often end up choosing one or the other
  • Each nesting level is still a separate database round-trip

BifrostQL is zero-configuration. Point it at a database and the entire API, including optimized query execution, is generated automatically.

When query-per-table is the right approach

Section titled “When query-per-table is the right approach”

BifrostQL’s strategy works best when:

  • You need zero-config GraphQL from an existing database
  • Your queries join 2-5 tables at moderate depth — the common case for application APIs
  • You want predictable database load — the number of SQL statements is determined by schema structure, not data volume
  • You’re in the .NET ecosystem and want a library, not a separate service

For extremely wide queries that touch dozens of tables in a single request, the number of SQL statements grows linearly. In practice, real application queries rarely exceed 5-6 joined tables, making this a non-issue.