Muhammad Yasir Amin

Loading website…

More from Muhammad Yasir Amin

Build Along With Me

Practical walkthroughs on Laravel, React, APIs and deployment workflows.

Subscribe on YouTube

How to Diagnose and Fix N+1 Query Problems in PostgreSQL and Node.js

🚀 Performance Optimization6 min read

How to Diagnose and Fix N+1 Query Problems in PostgreSQL and Node.js

Muhammad Yasir Amin

Muhammad Yasir Amin

Senior Full Stack Engineer•September 30, 2026
How to Diagnose and Fix N+1 Query Problems in PostgreSQL and Node.js

What causes the N+1 query problem in modern ORMs?

The N+1 query problem occurs when an Object-Relational Mapper (ORM) executes one initial query to fetch a parent collection (1), followed by N subsequent queries to fetch related child records for each individual parent. This architectural antipattern drastically increases round-trip latency and saturates database connection pools in Node.js backend applications.

In modern backend engineering, developers rely heavily on high-level ORMs and query builders like Prisma, TypeORM, or Sequelize. These tools abstract away the underlying relational structure, making code readable and maintainable. However, this abstraction often masks the execution plan sent to the database. Consider a standard e-commerce scenario where you need to fetch a list of users alongside their respective orders:

JavaScript
// Example using Node.js and an ORM
async function getUsersWithOrders() {
    // 1 query to fetch all users
    const users = await prisma.user.findMany();
    
    // N queries: 1 query executed for every single user iteration
    const results = await Promise.all(
        users.map(async (user) => {
            const orders = await prisma.order.findMany({
                where: { userId: user.id }
            });
            return { ...user, orders };
        })
    );
    return results;
}

If your users table contains 500 rows, this code triggers exactly 501 separate database round-trips (1 for the users, plus 500 individual queries for each user's orders). While local development environments hide this latency due to negligible network overhead, production environments running against a remote PostgreSQL instance experience severe performance degradation.

How to identify N+1 queries using database logs and APM tools

Identifying N+1 query problems requires analyzing query execution patterns through database query logging, APM (Application Performance Monitoring) solutions, or ORM debug flags. By observing repetitive queries with identical structures differing only by bound parameters, engineers can pinpoint performance bottlenecks before they impact production users.

The fastest way to detect N+1 issues during local development is by configuring your ORM to log generated SQL statements. For instance, in Prisma, you can instantiate the client with query event logging enabled:

JavaScript
const prisma = new PrismaClient({
    log: [
        { emit: 'event', level: 'query' },
        { emit: 'stdout', level: 'error' },
    ],
});

prisma.$on('query', (e) => {
    console.log('Query: ' + e.query);
    console.log('Duration: ' + e.duration + 'ms');
});

When running your test suite or local server, look out for sequences of repetitive statements in your terminal output:

SQL
SELECT "id", "email", "name" FROM "User" WHERE 1=1;
SELECT "id", "total", "userId" FROM "Order" WHERE "userId" = 1;
SELECT "id", "total", "userId" FROM "Order" WHERE "userId" = 2;
SELECT "id", "total", "userId" FROM "Order" WHERE "userId" = 3;

In production, APM tools like Datadog, New Relic, or open-source solutions like OpenTelemetry provide visual trace graphs. An N+1 query pattern manifests as a characteristic "comb" or "staircase" pattern in transaction traces, where a single HTTP request spawns dozens or hundreds of micro-queries executed serially.

How to fix N+1 issues with eager loading and query batching

To eliminate N+1 query issues, you must replace iterative fetching with database-level eager loading (such as SQL JOIN or LEFT JOIN clauses) or batch loading techniques (such as the IN operator or DataLoader pattern). Batching removes the per-parent query loop. The resulting SQL statement count depends on the ORM version and relation-loading strategy; it is not always one.

Using our previous Prisma example, we can refactor the code to use eager loading via the include parameter. The example loads related orders through the Prisma Client include API. Depending on the installed version and selected strategy, Prisma can use a database join or multiple batched queries. Confirm the actual statements in query logs:

JavaScript
async function getOptimizedUsersWithOrders() {
    // Eager loading: inspect logs for the actual SQL statement count
    const usersWithOrders = await prisma.user.findMany({
        include: {
            orders: true,
        },
    });
    return usersWithOrders;
}

When eager loading is not feasible due to architectural constraints—such as fetching across microservice boundaries or caching layers—the DataLoader pattern provides efficient query batching. DataLoader batches loads scheduled within its batching window and dispatches a single batched query using PostgreSQL's IN (...) syntax:

JavaScript
import DataLoader from 'dataloader';

const orderBatchLoader = new DataLoader(async (userIds) => {
    const orders = await prisma.order.findMany({
        where: {
            userId: { in: userIds },
        },
    });
    
    // Map orders back to their respective user IDs
    const ordersMap = new Map();
    orders.forEach(order => {
        if (!ordersMap.has(order.userId)) {
            ordersMap.set(order.userId, []);
        }
        ordersMap.get(order.userId).push(order);
    });

    return userIds.map(id => ordersMap.get(id) || []);
});

How to write database integration tests to prevent regression

Preventing future N+1 regressions requires automated integration tests that assert the total number of executed database queries for critical API endpoints. By intercepting query execution counts during test runs, CI/CD pipelines can automatically block code changes that introduce inefficient query patterns.

Configure a dedicated test client with query events and compare two fixture sizes in a serial integration test. Initialize the client and seed outside the measured interval so setup queries do not distort the count:

JavaScript
// Configure this dedicated Prisma Client with query event logging.
// Register the listener once, then run this integration test serially.
let queryCount = 0;
prisma.$on('query', () => { queryCount += 1; });

test('relation queries do not grow per user', async () => {
    await prisma.$connect();
    // Seed fixtures before measuring; seedUsersWithOrders is a test helper.
    await seedUsersWithOrders(5);
    queryCount = 0;
    await getOptimizedUsersWithOrders();
    const smallFixtureCount = queryCount;

    await seedUsersWithOrders(50);
    queryCount = 0;
    await getOptimizedUsersWithOrders();
    const largeFixtureCount = queryCount;

    expect(smallFixtureCount).toBeGreaterThan(0);
    expect(largeFixtureCount).toBeLessThanOrEqual(smallFixtureCount + 1);
});

By integrating this verification step into your continuous integration workflow, your team ensures that code refactors or new feature additions do not inadvertently reintroduce database bottlenecks into your PostgreSQL and Node.js stack.

Version scope and batching boundaries

The Prisma examples use the Prisma Client 6/7-style API. Match client initialization and any database adapter to your installed version. Query-event logging replaces the older middleware-based counter here; do not assume that legacy $use middleware exists in your client.

Create each DataLoader within the authorized request scope and include tenant or access constraints in its database query. Return one result for every input key in the same order, including an empty array for users without orders. Request-local caching avoids accidentally sharing one user's loaded records with another request.

The integration-test fixture helper above must be implemented in an isolated test database. Its small-versus-large comparison catches per-parent query growth; it is not a claim that these sample queries were benchmarked against this portfolio's backend.

References: Prisma Client query logging and Prisma 7 relation queries.

Article Index & Topic Tags
N+1 Query ProblemPostgresql Performance TuningNode.js Database OptimizationPrisma Orm Query BatchingSql Eager Loading

YouTube Channel

Build Along With Me

Practical walkthroughs on Laravel, React, APIs and deployment workflows.

Muhammad Yasir Amin
Available for Contracts
Author & Solutions Architect

Senior Full Stack Engineer

Independent Senior Full Stack Engineer working on Laravel, React and Next.js applications, APIs and delivery workflows.

Laravel & PHP ApplicationsReact & Next.js InterfacesREST APIs & IntegrationsDatabase Queries & DebuggingDocker Development WorkflowsClear Scope & Handover
Discuss Your Next Step

What Does Your Application Need Next?

Share your current system, the problem you want to solve and what a useful result looks like. We can identify the right starting point and agree on a practical scope.

Technical Audit

Find the Next Useful Fix

Application Development

Scoped, Reviewable Milestones

Ongoing Support

Agreed Capacity and Priorities

Share your goals, current stack and requirements before the call.