// LOADING
// LOADING
// LOADING_ARTICLE
An API endpoint responds in 20ms during development with five test records. In production with 500 records it takes four seconds. The logs show hundreds of nearly identical database queries fired one after another. That is the N+1 problem — and it is almost always introduced by code that looks completely reasonable.
You fetch N records and then, for each of those N records, you fire another query to fetch a related resource. Total queries: N+1 (one for the list, one per item).
js// This looks innocent but fires N+1 queries const posts = await Post.find({ published: true }) // 1 query const enriched = await Promise.all( posts.map(async (post) => { const author = await User.findById(post.authorId) // N queries return { ...post.toObject(), author } }) )
With 100 posts you execute 101 database queries. With 500 posts: 501 queries. Each query has network round-trip overhead, connection acquisition time, and index lookup cost. Even fast queries compound badly at scale.
Enable MongoDB's slow query log or use an APM tool (Datadog, New Relic, or the free MongoDB Atlas Query Profiler). Look for the same query pattern repeated in rapid succession with slightly different parameter values — that is the N+1 signature.
In development, log all queries:
js// Mongoose: turn on debug mode during development if (process.env.NODE_ENV === 'development') { mongoose.set('debug', true) }
Then hit the endpoint and count the database calls in your terminal output. This is the fastest way to catch N+1 before it reaches production.
For MongoDB, collect all the IDs you need and fetch them in a single query using $in:
js// One query for posts, one query for all needed authors const posts = await Post.find({ published: true }).lean() const authorIds = [...new Set(posts.map(p => p.authorId.toString()))] const authors = await User.find({ _id: { $in: authorIds } }).lean() // Build a lookup map — O(1) access per post const authorMap = Object.fromEntries( authors.map(a => [a._id.toString(), a]) ) const enriched = posts.map(post => ({ ...post, author: authorMap[post.authorId.toString()] ?? null }))
Two queries regardless of how many posts exist. This is the most impactful fix and works with any database. Note the .lean() call on Mongoose queries — it returns plain JavaScript objects instead of Mongoose documents, which is faster and uses less memory when you do not need Mongoose instance methods.
If you are already inside an aggregation pipeline, use $lookup to join at the database level rather than fetching separately in application code:
jsconst enriched = await Post.aggregate([ { $match: { published: true } }, { $lookup: { from: 'users', localField: 'authorId', foreignField: '_id', as: 'author' } }, { $unwind: { path: '$author', preserveNullAndEmpty: false } }, { $project: { title: 1, slug: 1, publishedAt: 1, 'author.name': 1, 'author.email': 1 } } ])
This is one round trip to the database. The aggregation pipeline guide covers $lookup and $unwind in depth, including performance considerations for large collections.
N+1 is often compounded by over-fetching. If you only need author.name for a list view, do not fetch the entire user document:
js// Only fetch the fields actually used in the response const authors = await User.find( { _id: { $in: authorIds } }, { name: 1, avatar: 1 } // projection ).lean()
Smaller documents travel faster over the network, take less memory, and are cheaper to serialize. Combined with a covered query, the database may not even touch document storage.
If your application uses GraphQL or has many independent resolvers that each fetch users, the DataLoader pattern batches and caches all requests that occur within the same event loop tick:
jsimport DataLoader from 'dataloader' const userLoader = new DataLoader(async (ids) => { const users = await User.find({ _id: { $in: ids } }).lean() const userMap = Object.fromEntries(users.map(u => [u._id.toString(), u])) // Return results in the same order as the requested ids return ids.map(id => userMap[id.toString()] ?? null) }) // Each resolver calls userLoader.load(post.authorId) // DataLoader batches all calls in the same tick into one $in query const author = await userLoader.load(post.authorId)
DataLoader also memoizes results within a request — if two posts share the same author, the user is only fetched once.
Even with batching, fetching a list of 10,000 items to enrich with related data is expensive. Always paginate upstream. Cursor-based pagination limits working sets to a manageable page size, which caps the maximum N in any N+1 scenario and is the correct architectural pairing with batching.
In SQL databases (Postgres, MySQL), the fix is usually a proper JOIN:
js// Using a query builder (Knex.js) const posts = await db('posts') .join('users', 'posts.author_id', 'users.id') .where('posts.published', true) .select('posts.*', 'users.name as author_name', 'users.email as author_email')
The same principle applies: one query with a join is almost always faster than N queries for related data, assuming the join column is indexed.
For the error handling around these database calls, see error handling patterns in Node.js APIs. For how to structure the code so that data-access concerns are isolated and testable, the project structure guide covers repository patterns that make query optimization easier to apply consistently. You can also see how these patterns are applied in the projects section.
N+1 queries are caused by lazy loading in a loop — a pattern that looks fine in isolation but destroys performance at real data volumes. The fix is always the same in concept: batch the related data in one query, not one query per item. Whether you use $in, $lookup, a SQL join, or DataLoader depends on your stack, but the principle is constant. Instrument your queries in development, catch the pattern early, and your APIs will scale without surprises.