Performance
Nine performance anti-patterns detected across seven ORMs with volume-aware severity scaling.
Performance Patterns
The performance detector identifies 9 data-access anti-patterns that cause slow queries, excessive memory usage, or database overload. Detection works across seven ORM/query libraries, and severity scales automatically based on declared data volumes.
Supported ORMs
| ORM | Entity Detection | Query Pattern Matching |
|---|---|---|
| Prisma | prisma.user.findMany() chain | findMany, findFirst, count, $queryRaw, $transaction |
| TypeORM | this.userRepo.find(), getRepository(User) | find, findOne, createQueryBuilder, query |
| Sequelize | User.findAll(), this.userModel.findAll() | findAll, findOne, count, findAndCountAll |
| Mongoose | UserModel.find(), this.userModel.find() | find, findOne, countDocuments, aggregate |
| Drizzle | db.select().from(users) | select, insert, update, delete |
| Knex | knex('users').select() | Table-name based detection |
| MikroORM | em.find(User), this.userRepo.find() | find, findOne, count, nativeDelete |
Volume-Aware Severity
Severity is not fixed -- it scales based on the data volume you declare for each entity in radar.yml:
data_volumes:
users: M # 10K - 100K rows
orders: L # 100K - 1M rows
audit_events: XL # 1M - 50M rows
logs: XXL # 50M+ rows
settings: S # 0 - 10K rows
The same pattern gets different severities based on volume:
| Volume | Max Rows | Severity | Gate Action |
|---|---|---|---|
| S | 10,000 | info (suppressed) | none |
| M | 100,000 | warning | warn |
| L | 1,000,000 | critical | warn |
| XL | 50,000,000 | critical | block |
| XXL | Infinity | critical | block |
Important: Entities without a declared volume default to
S(suppressed). You must declare volumes for tables you care about -- this prevents noisy false positives on small lookup tables.
Entity Name Resolution
The detector uses fuzzy matching to resolve entity names to volume declarations. It handles:
- Case normalization:
Usermatchesusers - Pluralization:
Ordermatchesorders,Countrymatchescountries - Snake case:
AuditEventmatchesaudit_events - ORM suffixes:
OrderEntityandOrderModelare stripped toOrderbefore matching
Pattern Reference
1. unbounded-find-many
A findMany/find/findAll call without a take/limit option. On large tables, this loads the entire dataset into memory.
Prisma -- Violation:
async getAllOrders(): Promise<Order[]> {
return this.prisma.order.findMany({
where: { status: 'active' },
// Missing: take/limit
});
}
Prisma -- Fix:
async getAllOrders(page = 1, pageSize = 50): Promise<Order[]> {
return this.prisma.order.findMany({
where: { status: 'active' },
take: pageSize,
skip: (page - 1) * pageSize,
orderBy: { createdAt: 'desc' },
});
}
TypeORM -- Violation:
async getAllOrders(): Promise<Order[]> {
return this.orderRepo.find({
where: { status: 'active' },
// Missing: take
});
}
TypeORM -- Fix:
async getAllOrders(page = 1, pageSize = 50): Promise<Order[]> {
return this.orderRepo.find({
where: { status: 'active' },
take: pageSize,
skip: (page - 1) * pageSize,
order: { createdAt: 'DESC' },
});
}
Sequelize -- Violation:
const orders = await Order.findAll({
where: { status: 'active' },
});
Sequelize -- Fix:
const orders = await Order.findAll({
where: { status: 'active' },
limit: 50,
offset: (page - 1) * 50,
});
Mongoose -- Violation:
const orders = await this.orderModel.find({ status: 'active' });
Mongoose -- Fix:
const orders = await this.orderModel
.find({ status: 'active' })
.limit(50)
.skip((page - 1) * 50)
.sort({ createdAt: -1 });
2. find-many-no-where
A findMany/find/findAll with no where clause at all -- returning the entire table.
Violation:
// Prisma: loads every row in the table
const users = await this.prisma.user.findMany();
// TypeORM: loads every row
const users = await this.userRepo.find();
// Mongoose: loads every document
const users = await this.userModel.find();
Fix:
// Always filter or paginate
const users = await this.prisma.user.findMany({
where: { isActive: true },
take: 100,
});
3. nested-include-large-relation
A nested include/relations/populate on a relation that references a large table. This generates joins or subqueries that multiply result set size.
Prisma -- Violation:
const user = await this.prisma.user.findUnique({
where: { id },
include: {
orders: { // orders table is XL
include: {
items: true, // Deep nesting multiplies rows
payments: true,
},
},
},
});
Prisma -- Fix:
// Separate queries with explicit limits
const user = await this.prisma.user.findUnique({ where: { id } });
const recentOrders = await this.prisma.order.findMany({
where: { userId: id },
take: 20,
orderBy: { createdAt: 'desc' },
include: { items: true },
});
Mongoose -- Violation:
const user = await this.userModel.findById(id).populate({
path: 'orders',
populate: { path: 'items' },
});
Mongoose -- Fix:
const user = await this.userModel.findById(id);
const orders = await this.orderModel
.find({ userId: id })
.limit(20)
.sort({ createdAt: -1 })
.populate('items');
4. n-plus-one-query
A database query executed inside a loop. For N parent records, this generates N+1 queries (1 parent query + N child queries).
Violation:
async getOrdersWithCustomers() {
const orders = await this.prisma.order.findMany({ take: 100 });
for (const order of orders) {
// N+1: one query per order inside the loop
order.customer = await this.prisma.user.findUnique({
where: { id: order.customerId },
});
}
return orders;
}
Fix:
async getOrdersWithCustomers() {
// Single query with join
return this.prisma.order.findMany({
take: 100,
include: { customer: true },
});
}
// Or batch manually:
async getOrdersWithCustomers() {
const orders = await this.prisma.order.findMany({ take: 100 });
const customerIds = [...new Set(orders.map(o => o.customerId))];
const customers = await this.prisma.user.findMany({
where: { id: { in: customerIds } },
});
const customerMap = new Map(customers.map(c => [c.id, c]));
return orders.map(o => ({
...o,
customer: customerMap.get(o.customerId),
}));
}
TypeORM -- Violation:
const orders = await this.orderRepo.find();
for (const order of orders) {
order.customer = await this.userRepo.findOne({
where: { id: order.customerId },
});
}
TypeORM -- Fix:
const orders = await this.orderRepo.find({
relations: ['customer'],
take: 100,
});
The detector also catches Promise.all(items.map(...)) patterns that batch N queries in parallel -- while faster than sequential N+1, it still creates N database connections:
// Also detected: parallel N+1
const enriched = await Promise.all(
orders.map(o => this.prisma.user.findUnique({ where: { id: o.userId } }))
);
5. fetch-all-filter-in-memory
Fetching all records from the database and then filtering with .filter() in JavaScript. The filtering should happen in the database query.
Violation:
async getActiveOrders() {
const allOrders = await this.prisma.order.findMany();
return allOrders.filter(o => o.status === 'active'); // Filter in JS, not DB
}
Fix:
async getActiveOrders() {
return this.prisma.order.findMany({
where: { status: 'active' },
take: 100,
});
}
6. missing-pagination-endpoint
A controller endpoint method that returns a findMany/find result without pagination parameters (take/skip, limit/offset, cursor).
Violation:
@Controller('users')
export class UserController {
@Get()
async findAll() {
return this.userService.findAll(); // No pagination params accepted
}
}
Fix:
@Controller('users')
export class UserController {
@Get()
async findAll(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
) {
return this.userService.findPaginated(page, Math.min(limit, 100));
}
}
7. unfiltered-count-large-table
A count() or countDocuments() call without a where clause on a large table. Unfiltered counts on tables with millions of rows are slow because the database must scan the entire table.
Violation:
// audit_events declared as XL (1M-50M rows)
const total = await this.prisma.auditEvent.count();
Fix:
// Filter the count to a relevant subset
const total = await this.prisma.auditEvent.count({
where: {
createdAt: { gte: thirtyDaysAgo },
userId: currentUserId,
},
});
// Or use an approximate count for display purposes
const approx = await this.prisma.$queryRaw`
SELECT reltuples AS estimate
FROM pg_class WHERE relname = 'audit_events'
`;
8. raw-sql-no-limit
A raw SQL query (via $queryRaw, query(), raw(), or template literals) that contains a SELECT but no LIMIT clause.
Violation:
const results = await this.prisma.$queryRaw`
SELECT * FROM orders WHERE status = 'active'
`;
Fix:
const results = await this.prisma.$queryRaw`
SELECT * FROM orders WHERE status = 'active' LIMIT 100
`;
9. Recursive N+1
A recursive or self-referencing query pattern where a function calls itself or a sibling function in a loop to traverse a tree structure, generating one query per node.
Violation:
async getFullCategory(id: string): Promise<Category> {
const category = await this.prisma.category.findUnique({ where: { id } });
if (category.parentId) {
category.parent = await this.getFullCategory(category.parentId); // Recursive N+1
}
return category;
}
Fix:
async getFullCategory(id: string): Promise<Category> {
// Use a recursive CTE for tree traversal in a single query
const categories = await this.prisma.$queryRaw`
WITH RECURSIVE tree AS (
SELECT * FROM categories WHERE id = ${id}
UNION ALL
SELECT c.* FROM categories c
JOIN tree t ON c.id = t.parent_id
)
SELECT * FROM tree
`;
return buildTree(categories);
}
Volume-Aware Severity in Practice
Here is how the same unbounded query gets different severities:
// settings table: S volume (< 10K rows)
await this.prisma.setting.findMany();
// Result: info severity, suppressed (no violation reported)
// users table: M volume (10K - 100K rows)
await this.prisma.user.findMany();
// Result: warning severity, shows in PR comment
// orders table: L volume (100K - 1M rows)
await this.prisma.order.findMany();
// Result: critical severity, shows in PR comment with warning badge
// audit_events table: XL volume (1M - 50M rows)
await this.prisma.auditEvent.findMany();
// Result: critical severity, BLOCKS MERGE
// logs table: XXL volume (50M+ rows)
await this.prisma.log.findMany();
// Result: critical severity, BLOCKS MERGE
Configuration
Performance rules are configured in radar.yml:
data_volumes:
users: M
orders: L
order_items: L
audit_events: XL
logs: XXL
settings: S
countries: S
Performance scoring weights:
scoring:
performance_risk_critical: 8
performance_risk_warning: 3
Exceptions
exceptions:
- rule: unbounded-find-many
file: "src/admin/**"
expires: "2026-06-01"
reason: "Admin panel has internal-only access with small result sets"
- rule: n-plus-one-query
file: "src/seed/**"
expires: "2026-12-31"
reason: "Database seeder runs once, performance is not critical"