Gunjan Sharma

System Design · Database Optimization

The Sequelize N+1 Query That Was Making 3,000 Database Calls Per Request

· Updated

How Query Logging Exposed 3,047 SQL Queries Behind a Single Admin Dashboard Request

For weeks, our admin dashboard felt sluggish.

Nobody was particularly surprised.

The page displayed:

  • Investor information

  • Portfolio summaries

  • KYC status

  • Investment counts

  • Property details

  • NAV information

A lot of data. A lot of relationships. A lot of joins.

The easy explanation was:

"The dashboard is complex. Of course it's slow."

Then I enabled query logging.

The Moment Everything Changed

We turned on Sequelize query logging to understand what was actually happening underneath.

const sequelize = new Sequelize(DB_NAME, DB_USER, DB_PASS, {
  dialect: 'mysql',
  benchmark: true,
  logging: (sql, timing) => {
    if (timing > 100) {
      console.log(`SLOW QUERY (${timing}ms):`, sql);
    }
  }
});

I opened the dashboard.

One request.

One page.

Fifty users.

And Sequelize logged 3,047 SQL queries in under four seconds.

At that moment, the problem was no longer "complex data."

The problem was architecture.


The First N+1 Problem

The dashboard endpoint looked innocent enough.

const users = await User.findAll({
  where: { status: 'active' },
  limit: 50,
  offset: page * 50
});

const usersWithDetails = await Promise.all(
  users.map(async (user) => {
    const investments = await Investment.findAll({
      where: { user_id: user.id }
    });

    const kyc = await KYC.findOne({
      where: { user_id: user.id }
    });

    return {
      ...user.toJSON(),
      investments,
      kyc
    };
  })
);

At first glance, this looks reasonable.

In reality, it's a textbook N+1 query pattern.

Query Breakdown

For 50 users:

1 query  -> Load users
50 queries -> Load investments
50 queries -> Load KYC

Total:

101 SQL queries

Still bad.

But nowhere near 3,047.

The real problem was hidden one level deeper.


The Second Layer of N+1

Each investment loaded additional information.

investments.forEach(async (investment) => {
  investment.property = await Property.findByPk(
    investment.property_id
  );

  investment.nav = await PropertyNAV.findOne({
    where: {
      property_id: investment.property_id
    }
  });
});

Every investment triggered:

  • One Property query

  • One NAV query

On average:

50 users
× 6 investments per user
× 2 queries

Result:

600 additional queries

And this wasn't the only section on the dashboard.

Several widgets followed similar patterns.

When everything executed together, the final count reached:

≈ 3,047 SQL queries

To render a single page.


Why ORMs Make This Easy to Miss

One of the most dangerous things about ORMs is that they make database calls look cheap.

This code:

await Property.findByPk(id);

Feels harmless.

But place it inside a loop:

for (const item of items) {
  await Property.findByPk(item.property_id);
}

And you've silently created hundreds or thousands of database round trips.

The ORM doesn't complain.

The ORM doesn't warn you.

The ORM simply executes exactly what you asked it to execute.


The Immediate Fix: Eager Loading

Sequelize supports eager loading through include.

Instead of loading related data one record at a time, we fetch everything together.

const users = await User.findAll({
  where: { status: 'active' },
  limit: 50,
  offset: page * 50,
  include: [
    {
      model: Investment,
      include: [
        { model: Property },
        { model: PropertyNAV }
      ]
    },
    {
      model: KYC
    }
  ]
});

Behind the scenes, Sequelize generates SQL joins and retrieves all required data in a single operation.

Result

Queries: 3,047 → 1
Response Time: 4,200ms → 310ms

A dramatic improvement.

But not necessarily the final optimization.


When One Query Is Not Always Better

Many engineers stop at eager loading.

In practice, massive join trees can become their own performance problem.

Imagine:

Users
  -> Investments
      -> Properties
      -> NAV
      -> Documents
      -> Transactions
      -> Audit Logs

The generated SQL becomes enormous.

Large joins often produce:

  • Huge result sets

  • Duplicate row expansion

  • Excessive memory usage

  • Slow execution plans

Sometimes two targeted queries outperform one giant query.


The Fetch-Then-Index Pattern

For high-traffic endpoints, we often prefer a hybrid approach.

First load the primary dataset.

const users = await User.findAll({
  where: { status: 'active' },
  limit: 50
});

const userIds = users.map(u => u.id);

Then fetch all related records in bulk.

const investments = await Investment.findAll({
  where: {
    user_id: {
      [Op.in]: userIds
    }
  },
  include: [
    Property,
    PropertyNAV
  ]
});

Create an in-memory index.

const investmentsByUser = {};

for (const inv of investments) {
  if (!investmentsByUser[inv.user_id]) {
    investmentsByUser[inv.user_id] = [];
  }

  investmentsByUser[inv.user_id].push(inv);
}

Finally combine the datasets.

return users.map(user => ({
  ...user.toJSON(),
  investments:
    investmentsByUser[user.id] || []
}));

Result

Queries: 2
Response Time: 180ms

Fewer joins.

Simpler SQL.

Better database execution plans.

More predictable performance.


Building a Query Budget

Fixing the dashboard solved today's problem.

Preventing future problems required a process.

We introduced a simple rule:

No API endpoint should execute more than 10 SQL queries per request.

To enforce it, we built a lightweight middleware that counted executed queries during development and testing.

When an endpoint exceeded the budget:

  • A warning was logged

  • Test environments rejected the request

  • Engineers investigated before merging

Within days, we discovered three more N+1 issues hiding in lower-traffic endpoints.

None had triggered alerts yet.

All would have become production bottlenecks eventually.


The Rule Every Backend Engineer Should Remember

The easiest way to spot an N+1 problem is surprisingly simple:

If you are calling a model method inside a loop, assume you have a performance problem until proven otherwise.

Examples:

for (const user of users) {
  await Investment.findAll(...);
}
users.map(async user => {
  return KYC.findOne(...);
});
investments.forEach(async inv => {
  await Property.findByPk(...);
});

Every one of these deserves scrutiny.

Not every loop is a bug.

But every loop that touches the database deserves investigation.


Final Takeaway

The dashboard wasn't slow because the data was complex.

It was slow because we had unknowingly converted a single page request into thousands of database round trips.

The most important lesson wasn't learning about N+1 queries.

It was learning to make database behavior visible.

Once query logging was enabled, the problem became obvious.

And once it became obvious, it became fixable.

A useful rule of thumb:

If you cannot explain every SQL query your endpoint executes, you do not fully understand your endpoint.

At scale, that understanding is often the difference between a system that feels instant and one that slowly collapses under its own success.