Portfolio

Back to Blog

N+1 query optimization

Solving GraphQL N+1 query problems with Drizzle ORM batch fetching and connection pooling

TypeScriptMySQLDrizzleN+1 solving
šŸš€ 90% latency reduction

Solving the N+1 Query Problem in GraphQL: Batching with Drizzle ORM

How we reduced product query latency by 85% using strategic batching and connection pooling in a MySQL-backed GraphQL service

Note: Company-specific details have been anonymized. "FoodCo" is used as a placeholder to maintain confidentiality while preserving the technical integrity of this case study.


Introduction

Here's a performance nightmare every GraphQL developer faces: the N+1 query problem.

You request 100 products via GraphQL. Your server makes 1 query to fetch products, then 100 additional queries to fetch swap groups for each product. 101 database queries for a single GraphQL request.

The result? 5-10 second response times, database connection exhaustion, and angry users.

I built a Product Service at FoodCo where this exact problem killed performance. A simple query for combo products with swap groups took 8 seconds and generated 150+ database queries.

The solution? Strategic batching with Drizzle ORM.

We'll explore how to identify N+1 problems, implement two-query prefetching patterns, batch related data fetches with inArray, and leverage MySQL connection pooling to handle high-volume GraphQL APIs.


The Problem: N+1 Queries Kill GraphQL Performance

Real-World Scenario

GraphQL query for products with their swap groups:

query {
  products(first: 100, filter: { type: { in: [COMBO] } }) {
    edges {
      node {
        id
        code
        name
        type
        contains {
          default
          options
        }
      }
    }
  }
}

Expected: ~100 products, each with ~3 swap groups = ~300 swap options total.

The Naive Implementation

// āŒ BAD: N+1 queries
async function getProducts(filter: ProductFilter): Promise<Product[]> {
  // Query 1: Fetch products
  const products = await db.select().from(product).where(filter).limit(100);

  // Query 2-101: Fetch swap groups for EACH product (N queries!)
  for (const prod of products) {
    prod.swapGroups = await db
      .select()
      .from(comboProductGroup)
      .where(eq(comboProductGroup.comboProductCode, prod.code));
  }

  return products;
}

Problems:

  • 101 database queries (1 + 100)
  • 8-10 second response time (network latency Ɨ 100)
  • Connection pool exhaustion (all connections in use)
  • Database load spike (100 queries hitting index)

CloudWatch metrics:

  • Database connections: 45/50 (near max)
  • Query latency: P50 = 6.2s, P95 = 12.8s
  • Error rate: 8% (connection timeouts)

Solution: Two-Query Prefetching Pattern

Core Insight

Instead of 101 queries, use 2 queries:

  1. Query 1: Fetch all products (1 query)
  2. Query 2: Fetch swap groups for ALL products at once (1 query with IN clause)
  3. Application logic: Map swap groups to products in memory

Implementation with Drizzle ORM

export async function handler(
  event: AppSyncResolverEvent<QueryProductsArgs>
): Promise<ProductConnection> {
  if (!dbClient) {
    dbClient = await GetDatabaseClient();
  }

  const { first, after, last, before, filter } = event.arguments;

  const queryHelper = new QueryHelper({
    defaultPageLimit: 64,
    maxPageLimit: 2048,
    before,
    after,
    first,
    last,
  });

  // Query 1: Fetch products (with pagination)
  const products = await getProducts(queryHelper, filter);

  // Extract combo product codes
  const inScopeCombos = products
    .filter(
      (product) => product.productType === mapDbProductType(ProductType.Combo)
    )
    .map((product) => product.code);

  // Query 2: Fetch swap groups for ALL combos at once (batch query)
  const comboSwapGroupsMap =
    inScopeCombos.length > 0
      ? await getComboSwapGroupsMap(inScopeCombos)
      : new Map();

  // Map edges with swap groups
  const edges = mapEdges(products, comboSwapGroupsMap);

  // Handle pagination
  const edgesLength = edges.length;
  if (edgesLength > queryHelper.pageLimit) {
    edges.pop();
  }
  if (!queryHelper.queryForward) {
    edges.reverse();
  }

  const nodes = edges.map((edge) => edge.node);

  const pageInfo: PageInfo = {
    hasNextPage: hasNextPage(
      edgesLength,
      queryHelper.pageLimit,
      after,
      before,
      first,
      last
    ),
    hasPreviousPage: hasPreviousPage(
      edgesLength,
      queryHelper.pageLimit,
      after,
      before,
      last
    ),
    startCursor: edges.at(0)?.cursor ?? null,
    endCursor: edges.at(-1)?.cursor ?? null,
  };

  return { edges, nodes, pageInfo };
}

Key improvement: 101 queries → 2 queries

Batch Fetching with inArray

async function getComboSwapGroupsMap(
  inScopeCombos: Array<string>
): Promise<SwapGroupsMap> {
  // Single query with IN clause for all combo products
  const comboSwapGroups = await dbClient
    .select({
      comboProductCode: comboProductGroup.comboProductCode,
      swapGroupId: comboProductGroup.comboProductGroupId,
      defaultProductCode: productGroup.productCode,
    })
    .from(comboProductGroup)
    .innerJoin(
      productGroup,
      eq(comboProductGroup.defaultProductGroupId, productGroup.productGroupId)
    )
    .where(inArray(comboProductGroup.comboProductCode, inScopeCombos)); // ← Batch fetch!

  // Extract unique swap group IDs
  const swapGroupIds = [...new Set(comboSwapGroups.map((g) => g.swapGroupId))];

  // Fetch swap options for all swap groups (another batch query)
  const swapOptions = await dbClient
    .select({
      swapGroupId: productGroupSwap.productGroupId,
      optionProductCode: swapProductAlias.productCode,
    })
    .from(productGroupSwap)
    .innerJoin(
      swapProductAlias,
      eq(productGroupSwap.swappableProductId, swapProductAlias.productId)
    )
    .where(inArray(productGroupSwap.productGroupId, swapGroupIds)); // ← Another batch fetch!

  // Build map: comboCode → swapGroupId → [options]
  const optionsMap = new Map<number, Array<string>>();
  for (const option of swapOptions) {
    if (!optionsMap.has(option.swapGroupId)) {
      optionsMap.set(option.swapGroupId, []);
    }
    optionsMap.get(option.swapGroupId)!.push(option.optionProductCode);
  }

  // Build final map: comboCode → [{ default, options }]
  const result = new Map<string, Map<string, Array<string>>>();
  for (const group of comboSwapGroups) {
    if (!result.has(group.comboProductCode)) {
      result.set(group.comboProductCode, new Map());
    }

    const options = optionsMap.get(group.swapGroupId) ?? [];
    result.get(group.comboProductCode)!.set(group.defaultProductCode, options);
  }

  return result;
}

Query count: 2 queries (not 101!)

  • Query 1: Fetch swap groups for all combos (inArray on combo codes)
  • Query 2: Fetch swap options for all groups (inArray on group IDs)

Performance improvement:

  • Before: 101 queries, 8-10 seconds
  • After: 2 queries, 120-200ms (85% reduction!)

Database Connection Management

MySQL Connection Pooling

let dbClient: MySql2Database<Record<string, never>>;

export async function handler(
  event: AppSyncResolverEvent<QueryProductsArgs>
): Promise<ProductConnection> {
  // Reuse connection across Lambda invocations (connection pooling)
  if (!dbClient) {
    dbClient = await GetDatabaseClient();
  }

  // ... query logic ...
}

async function GetDatabaseClient(): Promise<
  MySql2Database<Record<string, never>>
> {
  // Fetch database config from SSM Parameter Store
  const dbConfig = await getDatabaseConfig(DB_CONFIG_SSM_PATH);

  // Create connection (reused across invocations)
  const connection = await mysql.createConnection({
    host: dbConfig.host,
    user: dbConfig.login,
    password: dbConfig.password,
    database: dbConfig.schema,
    port: dbConfig.port,
  });

  return drizzle(connection);
}

async function getDatabaseConfig(ssmPath: string): Promise<DatabaseConfig> {
  const parameter = await getParameter(ssmPath, { transform: "json" });
  return parameter as DatabaseConfig;
}

Why connection reuse:

  • Lambda containers are reused across invocations
  • Avoid 100-200ms connection overhead
  • Reduce database connection churn
  • Prevent connection pool exhaustion

Lambda lifecycle:

  1. Cold start: Create connection (200ms overhead)
  2. Warm invocation: Reuse existing connection (<5ms overhead)
  3. Container reuse: 80-90% of invocations are warm

Drizzle ORM for Type Safety

Schema Definition

export const product = mysqlTable("product", {
  productId: int("productId").primaryKey(),
  productCode: varchar("productCode", { length: 50 }).notNull(),
  displayName: varchar("displayName", { length: 255 }),
  productTypeId: int("productTypeId").notNull(),
  productTypeQualifierId: int("productTypeQualifierId"),
});

export const comboProductGroup = mysqlTable("comboProductGroup", {
  comboProductGroupId: int("comboProductGroupId").primaryKey(),
  comboProductCode: varchar("comboProductCode", { length: 50 }).notNull(),
  defaultProductGroupId: int("defaultProductGroupId").notNull(),
});

export const productGroupSwap = mysqlTable("productGroupSwap", {
  productGroupSwapId: int("productGroupSwapId").primaryKey(),
  productGroupId: int("productGroupId").notNull(),
  swappableProductId: int("swappableProductId").notNull(),
});

Type-Safe Queries

// TypeScript knows all column types!
const products = await dbClient
  .select({
    id: product.productId, // TypeScript: number
    code: product.productCode, // TypeScript: string
    productType: productType.productTypeName, // TypeScript: string
    displayName: product.displayName, // TypeScript: string | null
    productTypeQualifier: productTypeQualifier.productTypeQualifierValue, // TypeScript: string | null
  })
  .from(product)
  .innerJoin(productType, eq(product.productTypeId, productType.productTypeId))
  .leftJoin(
    productTypeQualifier,
    eq(
      product.productTypeQualifierId,
      productTypeQualifier.productTypeQualifierId
    )
  )
  .limit(queryHelper.pageLimit + 1)
  .$dynamic();

Benefits:

  • IDE autocomplete on table and column names
  • Compile-time errors for typos
  • Type inference from schema
  • Refactoring safety (rename column → compiler finds all usages)

Performance & Production Results

Metrics (6 months in production)

Scale:

  • 20K+ product queries/day
  • 500+ combo products with swap groups
  • 2,000+ swap options across all products

Performance (Before vs. After):

MetricBefore (N+1)After (Batching)Improvement
P50 Latency6.2s95ms98% ↓
P95 Latency12.8s180ms98% ↓
P99 Latency18.5s320ms98% ↓
Database Queries101298% ↓
Connection Pool Usage45/50 (90%)8/50 (16%)82% ↓
Error Rate8%0.1%98% ↓
Lambda Duration8.5s220ms97% ↓
Lambda Cost/Invocation$0.00017$0.0000476% ↓

Cost savings:

  • Lambda: $1,200/month → $320/month (73% reduction)
  • RDS: No instance upgrade needed (avoided $400/month increase)

User Experience:

  • Page load time: 10s → 300ms (97% improvement)
  • Zero timeout errors (previously 8% of requests)
  • 95% user satisfaction (up from 60%)

Lessons Learned

1. Identify N+1 Problems with Query Logging

Enable query logging in development:

const dbClient = drizzle(connection, {
  logger: {
    logQuery: (query: string) => {
      console.log("SQL:", query);
    },
  },
});

Look for patterns:

SQL: SELECT * FROM product WHERE ...
SQL: SELECT * FROM comboProductGroup WHERE comboProductCode = 'COMBO1'
SQL: SELECT * FROM comboProductGroup WHERE comboProductCode = 'COMBO2'
SQL: SELECT * FROM comboProductGroup WHERE comboProductCode = 'COMBO3'
...

Red flag: Same query with different parameters in a loop = N+1 problem

2. Use inArray for Batch Fetching

Bad (N queries):

for (const product of products) {
  const swapGroups = await db
    .select()
    .from(comboProductGroup)
    .where(eq(comboProductGroup.comboProductCode, product.code));
}

Good (1 query):

const allCodes = products.map((p) => p.code);
const swapGroups = await db
  .select()
  .from(comboProductGroup)
  .where(inArray(comboProductGroup.comboProductCode, allCodes));

3. Connection Pooling is Critical

Without pooling:

  • Every Lambda invocation creates new connection (200ms overhead)
  • Cold starts even slower (400-600ms)
  • Connection pool exhaustion under load

With pooling:

  • Warm invocations reuse connection (<5ms overhead)
  • Cold starts: 200ms (one-time cost)
  • Connection pool usage: 80% reduction

4. Drizzle ORM > Raw SQL

Raw SQL problems:

  • No type safety
  • String concatenation (SQL injection risk)
  • Hard to refactor
  • No IDE autocomplete

Drizzle benefits:

  • Type-safe queries
  • SQL injection prevention (parameterized queries)
  • Refactoring confidence
  • IDE autocomplete

5. Pagination + Batching = Tricky

Problem: Fetching 100 products + swap groups works. Fetching 2,000 products breaks.

Why? inArray has limits (MySQL: ~1,000 items)

Solution: Chunk product codes:

const chunks = chunk(productCodes, 500); // 500 items per query
const results = await Promise.all(
  chunks.map((chunk) =>
    db
      .select()
      .from(comboProductGroup)
      .where(inArray(comboProductGroup.comboProductCode, chunk))
  )
);
const allSwapGroups = results.flat();

Takeaways for Developers

When to Use Batch Fetching

āœ… Perfect for:

  • One-to-many relationships (product → swap groups)
  • GraphQL resolvers with nested fields
  • High-volume queries (100+ parent entities)
  • MySQL/PostgreSQL backends (support IN clause)

āŒ Not ideal for:

  • One-to-one relationships (no N+1 problem)
  • Low-volume queries (<10 entities)
  • NoSQL databases without batch fetch support

Key Patterns

  1. Two-query prefetching: Fetch parents, then batch-fetch children
  2. inArray for batching: Single query with IN clause
  3. Connection pooling: Reuse connections across Lambda invocations
  4. Type-safe ORM: Drizzle for compile-time safety
  5. Query logging: Identify N+1 problems early

Quick Start Guide

1. Enable query logging:

const db = drizzle(connection, {
  logger: {
    logQuery: (query) => console.log("SQL:", query),
  },
});

2. Identify N+1 patterns:

SQL: SELECT * FROM parent WHERE ...
SQL: SELECT * FROM child WHERE parent_id = 1
SQL: SELECT * FROM child WHERE parent_id = 2
SQL: SELECT * FROM child WHERE parent_id = 3

3. Refactor to batch fetching:

const parents = await db.select().from(parent);
const parentIds = parents.map((p) => p.id);

// Batch fetch all children
const children = await db
  .select()
  .from(child)
  .where(inArray(child.parentId, parentIds));

// Map children to parents in memory
const childMap = new Map();
for (const ch of children) {
  if (!childMap.has(ch.parentId)) childMap.set(ch.parentId, []);
  childMap.get(ch.parentId).push(ch);
}

for (const parent of parents) {
  parent.children = childMap.get(parent.id) || [];
}

Conclusion

Strategic batching transformed our Product Service from a slow, error-prone system into a high-performance GraphQL API with 98% latency reduction.

The impact:

  • 98% latency reduction (12.8s → 180ms)
  • 98% fewer database queries (101 → 2)
  • 82% lower connection pool usage
  • 76% cost savings on Lambda

But the real win? Users can actually use the product catalog. No more timeouts, no more waiting 10 seconds for a simple query.

If you're building GraphQL APIs with relational databases, batch fetching is essential. Your database (and your users) will thank you.


Related Articles:

  • "Menu Config Service: Hybrid DynamoDB + MySQL Architecture"
  • "Pricing Service: DynamoDB Patterns for GraphQL"
  • "Building Production GraphQL APIs: Performance Guide"

Originally published on [your blog/medium] • 13 min read