A zero-runtime-dependency Node.js + TypeScript library that catches N+1 query patterns in Mongoose applications before they reach production.
Inspired by Ruby on Rails' Bullet gem β built for an ecosystem that never had one.
- The problem
- What Mongoosleuth does about it
- Installation
- Compatibility & Requirements
- Usage
- Configuration Reference
- How it works
- Technical details: the fingerprinting system
- FAQ
- Development scripts
- Non-negotiable principles
- Contributing & Issues
- Star History
- Roadmap
- License
// Looks completely fine. Isn't.
const posts = await Post.find();
for (const post of posts) {
// One extra query. Per post. Every single time this route is hit.
post.author = await User.findById(post.authorId);
}Fifty posts. Fifty-one queries. The response still comes back. Nobody notices β until the collection grows, the route gets popular, and the database starts spending more time on this one endpoint than on everything else combined.
This is the N+1 query problem, and in the Ruby on Rails world it has had a
well-known solution for over a decade: the Bullet gem watches your queries
in development and tells you exactly where you went wrong. Mongoose has never
had an equivalent. Mongoosleuth is that equivalent.
It watches the same code, says nothing while everything is fine, and the moment that loop fires the same query shape more times than your threshold allows in a single request, it tells you exactly where to look:
[mongoosleuth] N+1 detected
model: User
query: findById(<value>)
called 50 times in routes/posts.ts:14
fix: use .populate('author') or User.find({ _id: { $in: [...] } })
No raw values are ever logged β only field names and value types β so this is safe to leave running against a database full of real, sensitive, user data.
npm install mongoosleuthmongoose is a peer dependency, not a regular dependency β make sure it is
already installed in your project.
- Node.js:
>= 18.0.0 - Mongoose:
^7.0.0 || ^8.0.0 - Zero runtime dependencies: Will not clutter your
node_modulesor increase bundle sizes.
import mongoose from 'mongoose';
import { Mongoosleuth } from 'mongoosleuth';
const sleuth = new Mongoosleuth({
enabled: process.env.NODE_ENV !== 'production',
threshold: 3,
});
// Patch Mongoose's query execution once, at startup.
sleuth.attach(mongoose);
// Open a tracking scope for every incoming request.
app.use(sleuth.middleware());await sleuth.run(async () => {
const users = await User.find({ status: 'active' });
for (const user of users) {
// Tracked and analyzed exactly like a request β fires the same warning
// if this turns out to be a loop instead of a batched query.
const profile = await Profile.findOne({ userId: user.id });
}
});You can exclude specific collections or operations from N+1 analysis to avoid tracking logs on expected bulk operations:
const sleuth = new Mongoosleuth({
ignore: [
{ model: 'AuditLog' }, // Ignore all queries on AuditLog collection
{ operation: 'updateOne' }, // Ignore all updates on any collection
{ model: 'Session', operation: 'findOne' } // Ignore specific collection-operation pair
]
});Mongoosleuth comes built-in with ConsoleReporter (default) and JsonReporter (for NDJSON logging pipelines). You can also easily implement the Reporter interface to ship warnings to Slack, Sentry, or S3:
import { Mongoosleuth, JsonReporter, Reporter, Finding } from 'mongoosleuth';
class SlackReporter implements Reporter {
report(findings: Finding[]): void {
for (const finding of findings) {
// Post warning to Slack hook
fetch('https://hooks.slack.com/services/...', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `π¨ N+1 Query detected on *${finding.model}* (${finding.count} calls) at ${finding.callSite}`
})
});
}
}
}
const sleuth = new Mongoosleuth({
reporters: [
new SlackReporter(),
new JsonReporter(line => process.stderr.write(line + '\n'))
]
});View Configuration Options (MongoosleuthOptions)
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
process.env.NODE_ENV !== 'production' |
Toggle Mongoosleuth N+1 pattern detection. When set to false, it acts as a zero-overhead pass-through. |
threshold |
number |
3 |
Number of identical query shapes executed from the same call site within a single scope before triggering a warning. Must be >= 1. |
captureStackTrace |
boolean |
true |
When true, automatically identifies the exact file and line number (call site) of queries. Set to false for absolute zero-overhead performance (logs will group call sites as 'unknown'). |
ignore |
Array<{ model?: string; operation?: string }> |
[] |
Excludes queries matching specific model names or operations from being tracked. |
reporters |
Reporter[] |
[new ConsoleReporter()] |
Pluggable output handlers that receive identified N+1 query findings. |
flowchart TD
A[Incoming request] --> B[Tracking scope opens<br/>AsyncLocalStorage]
B --> C[Query interceptor<br/>logs every Mongoose query]
C --> D[Pattern analyzer<br/>flags repeated query patterns]
D --> E[Reporter<br/>warns in the console]
Each request gets its own isolated tracking scope. Every query fired inside that scope is fingerprinted and logged. When the request finishes, the analyzer checks whether any fingerprint repeated, from the same line of code, often enough to be a loop rather than a coincidence β and if so, the reporter prints it.
At the core of the detector is a deterministic fingerprinting function
(src/fingerprint.ts) that identifies the shape of a query without ever
touching the actual data inside it.
1. Normalization. Every primitive value inside a query filter is replaced with a tag representing its type, never its value:
| Value type | Normalized to |
|---|---|
string |
<string> |
number |
<number> |
boolean |
<boolean> |
Date |
<Date> |
ObjectId |
<ObjectId> |
RegExp |
<RegExp> |
null |
<null> |
undefined |
<undefined> |
MongoDB query operators such as $gt, $in, or $regex are preserved as
part of the shape β they change what the query means, so they are never
collapsed away. Arrays are normalized using the shape of their first element
only; an empty array is tagged <empty array>.
2. Deterministic key sorting. Object keys are sorted alphabetically at every nesting level before the fingerprint is built, so field order in your code never affects detection:
{ name: "John", age: 30 } β { age: <number>, name: <string> }
{ age: 30, name: "John" } β { age: <number>, name: <string> }
Same fingerprint either way β which is exactly the point.
Yes, absolutely. When enabled is set to false (which is the default outside development/staging environments), all of Mongoosleuth's internal hooks are bypassed and act as zero-overhead no-op pass-throughs. Additionally, Mongoosleuth never captures or logs raw query values (PII data is fully protected by substituting types), ensuring complete data privacy.
In development (enabled: true), capturing stack traces to locate the exact query line (callSite) has a minor CPU cost. If you wish to run high-throughput load tests or minimize trace collection overhead, you can set captureStackTrace: false to disable call-site tracing and group queries under a single 'unknown' location.
Database profilers and query loggers record individual raw queries independently without context of where they originated. Mongoosleuth isolates query shapes per request scope and groups them by their exact code line location. This allows it to distinguish between normal independent query executions and repeated, identical queries fired from a single loop context (N+1 query pattern).
| Command | What it does |
|---|---|
npm run build |
Builds dual ESM (.mjs) and CommonJS (.js) output, plus .d.ts/.d.mts |
npm test |
Runs the unit and integration suite (Vitest + mongodb-memory-server) |
npm run lint |
Checks code style and TypeScript linter compliance |
npm run format |
Formats the codebase with Prettier |
Zero runtime dependencies.
mongooseis a peer dependency, never a dependency.Privacy first. Raw query values are never logged β only field names and value type tags.
No shared state. Every request is isolated through
AsyncLocalStorage; two concurrent requests never see each other's query logs.Dual module output. Full support for ESM and CommonJS consumers, with accurate
.d.tsdefinitions for both.
We welcome contributions in the form of bug reports, feature suggestions, or pull requests. Please report any issues you encounter via the GitHub Issues page.
- Unused eager loading detection β flagging a
.populate()call whose result was never actually read, the mirror image of the N+1 problem.
MIT