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.