DataLoader and the N+1 Problem — Batching Database Queries in Node.js

Sanjeev SharmaSanjeev Sharma
7 min read

Advertisement

Introduction

The N+1 query problem silently degrades API performance: a list endpoint fetches 50 records, then fires 50 individual queries to load related data — 51 database round-trips instead of 2. DataLoader solves this through automatic batching within a single request lifecycle, collapsing N individual loads into one batched query without changing how resolvers or handlers are written.

Understanding the N+1 Problem

The problem appears in GraphQL resolvers, REST handlers, and any code that loops over records and loads related data one at a time:

// N+1 pattern: 1 query for posts + N queries for authors
async function getPostsWithAuthors() {
  const posts = await db.query("SELECT * FROM posts LIMIT 50")  // 1 query
 
  for (const post of posts) {
    // 50 separate queries — one per post
    post.author = await db.query(
      "SELECT * FROM users WHERE id = $1",
      [post.author_id]
    )
  }
  return posts
}
// 51 total queries for 50 posts

With DataLoader, this collapses to 2 queries regardless of how many posts exist:

import DataLoader from 'dataloader'
 
const userLoader = new DataLoader(async (userIds) => {
  // Batch: one query for ALL user IDs collected in this tick
  const users = await db.query(
    "SELECT * FROM users WHERE id = ANY($1)",
    [userIds]
  )
  // Return results in the same order as input keys
  return userIds.map(id => users.find(u => u.id === id) || null)
})
 
async function getPostsWithAuthors() {
  const posts = await db.query("SELECT * FROM posts LIMIT 50")
 
  // These look like individual loads but DataLoader batches them
  const authorsPromises = posts.map(p => userLoader.load(p.author_id))
  const authors = await Promise.all(authorsPromises)
  return posts.map((p, i) => ({ ...p, author: authors[i] }))
}
// 2 total queries regardless of post count

DataLoader uses Node.js microtask scheduling: calls to .load() in the same event loop tick are queued, then the batch function fires once with all collected keys.

Per-Request DataLoader Instances

The most common mistake is creating DataLoader as a singleton. A singleton accumulates cache entries across requests, causing memory leaks and stale data:

// WRONG: singleton leaks cache across all users' requests
const globalUserLoader = new DataLoader(batchFn)
 
// CORRECT: fresh instance per request
function createLoaders() {
  return {
    user: new DataLoader(async (ids) => {
      const users = await db.query(
        "SELECT * FROM users WHERE id = ANY($1)", [ids]
      )
      return ids.map(id => users.find(u => u.id === id) || null)
    }),
    post: new DataLoader(async (ids) => {
      const posts = await db.query(
        "SELECT * FROM posts WHERE id = ANY($1)", [ids]
      )
      return ids.map(id => posts.find(p => p.id === id) || null)
    }),
    commentsByPost: new DataLoader(async (postIds) => {
      const comments = await db.query(
        "SELECT * FROM comments WHERE post_id = ANY($1)", [postIds]
      )
      return postIds.map(pid => comments.filter(c => c.post_id === pid))
    })
  }
}
 
// Express middleware: create loaders per request
app.use((req, res, next) => {
  req.loaders = createLoaders()
  next()
})

In GraphQL, pass the loaders through context so every resolver in the same request shares the same instances:

const server = new ApolloServer({
  schema,
  context: ({ req }) => ({
    loaders: createLoaders(),
    user: req.user
  })
})

Correct Key Ordering in Batch Functions

The batch function must return results in the exact same order as the input keys. If the database returns rows in a different order, you will serve wrong data silently:

// WRONG: returns rows in database order, not input key order
const brokenLoader = new DataLoader(async (ids) => {
  const rows = await db.query("SELECT * FROM users WHERE id = ANY($1)", [ids])
  return rows  // order is undefined!
})
 
// CORRECT: always map input keys to results
const correctLoader = new DataLoader(async (ids) => {
  const rows = await db.query("SELECT * FROM users WHERE id = ANY($1)", [ids])
  const map = new Map(rows.map(r => [r.id, r]))
  return ids.map(id => map.get(id) || new Error(`User ${id} not found`))
})

Return an Error object (not throw) for missing keys — DataLoader will reject that specific .load() promise while resolving others normally.

Cache Priming and Invalidation

DataLoader caches results within the request lifecycle. Use .prime() to pre-populate the cache with data you already have, avoiding redundant loads:

async function getPostWithRelated(postId, loaders) {
  const post = await loaders.post.load(postId)
 
  // We already have the author from a previous query — prime the cache
  if (post.authorData) {
    loaders.user.prime(post.author_id, post.authorData)
  }
 
  // This hits cache, no DB query
  const author = await loaders.user.load(post.author_id)
  return { ...post, author }
}

Clear specific cache entries when data changes:

async function updateUser(userId, data, loaders) {
  await db.query("UPDATE users SET name = $1 WHERE id = $2", [data.name, userId])
  loaders.user.clear(userId)  // invalidate this entry
  // Next load will re-fetch from DB
}

Batching Strategies for Complex Relationships

Some relationships do not batch by a single ID. Comments grouped by post, orders grouped by user, tags grouped by article — all need a different batch shape:

// One-to-many: batch by parent ID, return arrays
const commentsByPostLoader = new DataLoader(async (postIds) => {
  const comments = await db.query(`
    SELECT * FROM comments
    WHERE post_id = ANY($1)
    ORDER BY created_at ASC
  `, [postIds])
 
  // Return array of comments per post ID (maintaining input order)
  return postIds.map(pid => comments.filter(c => c.post_id === pid))
})
 
// Many-to-many: batch through join table
const tagsByArticleLoader = new DataLoader(async (articleIds) => {
  const rows = await db.query(`
    SELECT at.article_id, t.*
    FROM article_tags at
    JOIN tags t ON t.id = at.tag_id
    WHERE at.article_id = ANY($1)
  `, [articleIds])
 
  return articleIds.map(aid => rows.filter(r => r.article_id === aid))
})

Monitoring DataLoader Effectiveness

Add batch size tracking to verify DataLoader is actually batching and to detect accidentally-serialized loads:

function createMonitoredLoader(name, batchFn) {
  return new DataLoader(async (keys) => {
    const start = Date.now()
    const results = await batchFn(keys)
    const duration = Date.now() - start
 
    if (keys.length === 1) {
      console.warn(`[DataLoader:${name}] Batch size 1 — possible serialization issue`)
    }
    console.log(`[DataLoader:${name}] batch=${keys.length} time=${duration}ms`)
 
    return results
  })
}
 
// Usage: wrap your batch functions
const userLoader = createMonitoredLoader('user', async (ids) => {
  const rows = await db.query("SELECT * FROM users WHERE id = ANY($1)", [ids])
  return ids.map(id => rows.find(r => r.id === id) || null)
})

A batch size of 1 repeatedly means something is awaiting loads sequentially instead of concurrently — check for await loader.load(id) inside a loop.

Key Takeaways

  • The N+1 problem multiplies with scale: 50 posts at 5 RPM becomes 250 extra queries/minute; at 500 RPM it is 25,000
  • DataLoader batches all .load() calls in the same event loop tick into one batch function invocation
  • Always create fresh DataLoader instances per request — a singleton leaks cache across users and creates memory bloat
  • The batch function must return results in the same order as input keys; use a Map to guarantee correct ordering
  • Return Error objects from batch functions for missing keys rather than throwing — this rejects individual .load() promises correctly
  • Use .prime() to pre-populate cache with data already fetched, avoiding redundant round-trips
  • Monitor batch sizes in development: repeated batch size 1 indicates serialized loads that defeat batching
  • One-to-many relationships batch by parent ID and return arrays; ensure the return type matches the relationship cardinality

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading