Performance Optimization

Tips and techniques for optimizing query performance in Stabilize ORM

1. Use Selective Field Loading

Only load the fields you need. Every column you fetch is a column the driver has to read, decode and hand back:

typescript
// Loads all columns
const users = await userRepo.find().execute(orm.client);
// Only load needed fields
const users = await userRepo
.find()
.select("id", "email", "name")
.execute(orm.client);
// Or use selectColumns on the repository (no client argument)
const partial = await userRepo.selectColumns("id", "email");
// A single column, as a plain array of values
const emails = await userRepo.pluck("email");

selectColumns() returns Partial<T>[] — the rows are real objects with only the named keys, so a field you forgot to select is undefined rather than an error. Save pluck() for the one-column case.

2. Pagination

Never load all records at once:

typescript
// Built-in pagination
const page = await userRepo.paginate(1, 20);
// Or use query builder
const results = await userRepo.find()
.limit(20)
.offset(20)
.execute(orm.client);
// Cursor-based pagination for large datasets
const results = await userRepo.findMany({
take: 20,
orderBy: { field: "id", direction: "ASC" },
});

3. Optimize Relationship Loading

Working with related rows is where N+1 queries appear. Ask for the relations you need in the same call instead of looping:

typescript
// Bad: one extra query per user
const users = await userRepo.find().execute(orm.client);
for (const user of users) {
const posts = await postRepo.find().where("userId = ?", user.id).execute(orm.client);
}
// Good: one batched query for the whole relation
const users = await userRepo.find().withRelations("posts").execute(orm.client);
// Good: the same option on the finders that take one
const user = await userRepo.findOne(id, { relations: ["posts"] });
const admins = await userRepo.findBy({ role: "admin" }, { relations: ["posts"] });
const paged = await userRepo.findAndCount({ relations: ["posts"] });
const many = await userRepo.findMany({ take: 20, relations: ["posts"] });
// Nested paths work too; "posts" is read once and shared
const thread = await userRepo.find()
.withRelations("posts.comments", "posts.tags")
.execute(orm.client);

Relations are loaded per call, not configured once

There is no model-level eager-loading setting and no join strategy to choose from. Each relation you ask for is loaded with one additional batched query — IN (…) against the parent keys — and paths that share a first segment are grouped so the parent relation is read once. That removes the N+1 within a result set, but it does not remove it across the codebase: a lazy accessor that fetches a relation per row later on is still N+1, and still yours to avoid. Request the relation up front, or load it in one query for the whole set.

4. Use Indexes Strategically

Index the columns you filter, join and sort on — and not much else, since every index is extra work on each write. A column takes an index option whose value is the index name, and unique: true creates an index of its own:

models/User.ts
const User = defineModel({
tableName: "users",
columns: {
id: { type: DataTypes.STRING, required: true },
// unique creates <table>_<column>_uniq, so no index name is needed
email: { type: DataTypes.STRING, unique: true, required: true },
// index takes the name of the index to create
status: { type: DataTypes.STRING, index: "idx_users_status" },
createdAt: { type: DataTypes.DATETIME, index: "idx_users_created_at" },
},
});
// autoMigrate creates any index that is missing. It is additive: it adds
// indexes and columns, and never drops either.
await orm.autoMigrate(User);

Indexes only exist once autoMigrate() has run against the table, so a model change needs that step deployed too. A composite index on the leading columns of your most common WHERE ... ORDER BY pair is usually worth more than several single-column indexes.

5. Enable Caching

Read-heavy workloads benefit most from a cache in front of the database. It is the second constructor argument:

typescript
const orm = new Stabilize(
dbConfig,
{ enabled: true, ttl: 300, redisUrl: process.env.REDIS_URL, strategy: "cache-aside" }
);
// Check cache stats
const stats = await orm.getCacheStats();
console.log("Hit ratio:", stats.hits / (stats.hits + stats.misses) * 100);

6. Use Bulk Operations

Batch individual statements into one round trip:

typescript
// Bad: Multiple individual inserts
for (const user of users) {
await userRepo.create(user);
}
// Good: Single bulk insert
await userRepo.bulkCreate(users, { batchSize: 1000 });
// Bulk update
await userRepo.bulkUpdate([...]);
// Bulk upsert, keyed on one or more columns
await userRepo.bulkUpsert(users, ["email"]);

Each bulk method runs its work inside a transaction it opens itself, so a failure part-way through does not leave half the batch committed.

7. Optimize Query Conditions

A predicate has to be written the way the index is stored for the index to be usable at all:

typescript
// Bad: leading wildcard, so no index can be used
const users = await userRepo.find()
.where("email LIKE ?", "%@example.com")
.execute(orm.client);
// Good: a trailing wildcard can use an index on email
const users = await userRepo.find()
.where("email LIKE ?", "ciniso%@example.com")
.execute(orm.client);
// Bad: multiple OR branches on the same column
await userRepo.find()
.where("status = ?", "active")
.orWhere("status = ?", "pending")
.execute(orm.client);
// Good: a single IN predicate
await userRepo.find()
.whereIn("status", ["active", "pending"])
.execute(orm.client);

The same rule covers expressions: wrapping a column in a function (WHERE LOWER(email) = ?, WHERE DATE(createdAt) = ?) hides it from the index. Store the value in the form you query it, or index the expression. whereIn() with an empty array is short-circuited to a false predicate rather than generating invalid SQL.

8. Use Transactions Wisely

Group related writes in a transaction, but keep them short — an open transaction holds locks and a connection:

typescript
// Good: short, focused transaction
await orm.transaction(async (txClient) => {
const user = await userRepo.create({ email: "ciniso@example.com" }, {}, txClient);
await profileRepo.create({ userId: user.id, bio: "..." }, {}, txClient);
});
// Bad: doing slow work inside the transaction
await orm.transaction(async (txClient) => {
const user = await userRepo.create({ email: "ciniso@example.com" }, {}, txClient);
await sendWelcomeEmail(user.email); // third-party call - do it after commit
await profileRepo.create({ userId: user.id }, {}, txClient);
});

transaction() takes one argument — the callback — and no isolation level. A nested call reuses the outer transaction rather than opening a second one, so there are no savepoints and no partial rollback: a throw anywhere rolls back everything. Worth knowing while tuning: create(), update(), delete(), bulkCreate() and bulkUpsert() each open a transaction internally already, so wrapping a single write in your own is a no-op — the extra transaction is only worth it when several writes must succeed together.

9. Monitor Query Performance

Measure before you tune. The logger is the third constructor argument, and query text, bound parameters and execution time are written through it:

db.ts
import { Stabilize, LogLevel, type LoggerConfig } from "stabilize-orm";
const loggerConfig: LoggerConfig = {
level: LogLevel.Debug,
filePath: "logs/stabilize.log",
maxFileSize: 5 * 1024 * 1024, // 5MB
maxFiles: 3,
};
export const orm = new Stabilize(dbConfig, { enabled: false, ttl: 60 }, loggerConfig);

Two more probes answer the coarse questions — is the pool saturating, and is the database reachable at all? They return different shapes, so do not treat one as the other:

health.ts
// ORM level: reachability, measured by running "SELECT 1"
const ormHealth = await orm.healthCheck();
// { status, database, latencyMs, cacheStatus }
// Repository level: counts the model's rows
const repoHealth = await userRepo.healthCheck();
// { status, table, rows, latencyMs } <- note "rows", not "cacheStatus"
// Pool numbers
const { active, idle, total } = await orm.poolStats();

Read poolStats() with care

orm.poolStats() returns { active, idle, total }, but only the SQL Server branch reads a real pool. On PostgreSQL, MySQL and SQLite the call reports -1 sentinels — treat -1 as "not measured", not as an empty pool, and do not build alerting on total > 0. Both healthCheck() methods swallow the underlying error and report status: "unhealthy" — that is a useful liveness signal and a poor place to read the cause; check the log for that. And keep in mind that the logger's level filter admits Debug messages even at the default Info level, so statement logging is on by default: give it a filePath if you want the history, and measure what it costs before leaving it in a hot path.

10. Use Query Scopes

A scope is a named, reusable filter that returns the query builder it was handed, so scopes chain with each other and with the rest of the builder API:

models/User.ts
const User = defineModel({
tableName: "users",
columns: { /* ... */ },
scopes: {
active: (qb) => qb.where("isActive = ?", true),
recent: (qb, days: number) => qb.where(
"createdAt >= ?",
new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString()
),
},
});
// repository.scope() starts from find() and returns a QueryBuilder
const recentActive = await userRepo
.scope("active")
.scope("recent", 7)
.execute(orm.client);
// The builder has scope() too, so a scope composes with a live query
const admins = await userRepo
.find()
.scope("active")
.where("role = ?", "admin")
.execute(orm.client);

Arguments after the scope name are passed straight through to the scope function. An unknown scope name throws SCOPE_ERROR rather than being ignored, so a typo surfaces at the call site.

11. Use EXISTS Instead of COUNT

typescript
// Bad: Counts all matching rows
const count = await userRepo.count({ role: "admin" });
if (count > 0) { ... }
// Good: Stops at first match
const exists = await userRepo.exists({ role: "admin" });
if (exists) { ... }

12. Use Pluck for Single Columns

typescript
// Bad: Loads full objects
const users = await userRepo.find().execute(orm.client);
const emails = users.map(u => u.email);
// Good: Only fetches the column
const emails = await userRepo.pluck("email");

Performance Checklist

  • Use select() or selectColumns() to load only needed columns
  • Paginate large result sets
  • Request relations with withRelations() instead of querying per row
  • Index the columns you filter, join and sort on; run autoMigrate() to create them
  • Enable caching for read-heavy workloads — Redis when redisUrl is set, otherwise the in-process backend, which is per-process and lost on restart
  • Use bulkCreate() for multiple inserts
  • Avoid leading wildcards and functions around indexed columns
  • Use whereIn() instead of multiple orWhere()
  • Keep transactions short and focused
  • Log slow queries with a LoggerConfig, and check poolStats() and healthCheck() with their caveats in mind
  • Define scopes for common query patterns
  • Use exists() instead of count() for presence checks
  • Use pluck() for single-column fetches
  • Use increment()/decrement() for atomic counter updates