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:
// Loads all columnsconst users = await userRepo.find().execute(orm.client);
// Only load needed fieldsconst 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 valuesconst 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:
// Built-in paginationconst page = await userRepo.paginate(1, 20);
// Or use query builderconst results = await userRepo.find() .limit(20) .offset(20) .execute(orm.client);
// Cursor-based pagination for large datasetsconst 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:
// Bad: one extra query per userconst 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 relationconst users = await userRepo.find().withRelations("posts").execute(orm.client);
// Good: the same option on the finders that take oneconst 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 sharedconst 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:
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:
const orm = new Stabilize( dbConfig, { enabled: true, ttl: 300, redisUrl: process.env.REDIS_URL, strategy: "cache-aside" });
// Check cache statsconst 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:
// Bad: Multiple individual insertsfor (const user of users) { await userRepo.create(user);}
// Good: Single bulk insertawait userRepo.bulkCreate(users, { batchSize: 1000 });
// Bulk updateawait userRepo.bulkUpdate([...]);
// Bulk upsert, keyed on one or more columnsawait 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:
// Bad: leading wildcard, so no index can be usedconst users = await userRepo.find() .where("email LIKE ?", "%@example.com") .execute(orm.client);
// Good: a trailing wildcard can use an index on emailconst users = await userRepo.find() .where("email LIKE ?", "ciniso%@example.com") .execute(orm.client);
// Bad: multiple OR branches on the same columnawait userRepo.find() .where("status = ?", "active") .orWhere("status = ?", "pending") .execute(orm.client);
// Good: a single IN predicateawait 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:
// Good: short, focused transactionawait 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 transactionawait 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:
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:
// ORM level: reachability, measured by running "SELECT 1"const ormHealth = await orm.healthCheck();// { status, database, latencyMs, cacheStatus }
// Repository level: counts the model's rowsconst repoHealth = await userRepo.healthCheck();// { status, table, rows, latencyMs } <- note "rows", not "cacheStatus"
// Pool numbersconst { 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:
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 QueryBuilderconst recentActive = await userRepo .scope("active") .scope("recent", 7) .execute(orm.client);
// The builder has scope() too, so a scope composes with a live queryconst 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
// Bad: Counts all matching rowsconst count = await userRepo.count({ role: "admin" });if (count > 0) { ... }
// Good: Stops at first matchconst exists = await userRepo.exists({ role: "admin" });if (exists) { ... }12. Use Pluck for Single Columns
// Bad: Loads full objectsconst users = await userRepo.find().execute(orm.client);const emails = users.map(u => u.email);
// Good: Only fetches the columnconst emails = await userRepo.pluck("email");Performance Checklist
- Use
select()orselectColumns()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
redisUrlis 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 multipleorWhere() - Keep transactions short and focused
- Log slow queries with a
LoggerConfig, and checkpoolStats()andhealthCheck()with their caveats in mind - Define scopes for common query patterns
- Use
exists()instead ofcount()for presence checks - Use
pluck()for single-column fetches - Use
increment()/decrement()for atomic counter updates