npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

mongoosleuth

v0.2.0

Published

N+1 query and unused eager loading pattern detector for Mongoose

Readme

Table of contents

The problem

// 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.

What Mongoosleuth does about it

It watches your application's queries, says nothing while everything is fine, and alerts you the moment inefficiencies are detected:

1. N+1 Query Loops (Missing .populate())

When queries are executed repeatedly inside a loop:

[mongoosleuth] N+1 detected
  model: User
  query: findById({ _id: <ObjectId> })
  called 50 times in routes/posts.ts:14
  fix: use .populate('author') or batch this with a single query using $in

2. Unused Eager Loading (Wasteful .populate())

When a query eagerly loads associations via .populate(), but your controller or view never actually accesses that field:

[mongoosleuth] Unused eager loading detected
  model: Post
  unused: author
  loaded in routes/posts.ts:18 (10 docs)
  fix: remove .populate('author') from your query

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.

Installation

npm install mongoosleuth

mongoose is a peer dependency, not a regular dependency — make sure it is already installed in your project.

Compatibility & Requirements

  • Node.js: >= 18.0.0
  • Mongoose: ^7.0.0 || ^8.0.0
  • Zero runtime dependencies: Will not clutter your node_modules or increase bundle sizes.

Usage

Express / Fastify / Koa middleware

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());

Manual scope (background jobs, scripts, anything without HTTP)

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 });
  }
});

Ignore options

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
  ]
});

Pluggable Custom Reporters

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'))
  ]
});

Configuration Reference

| 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 and unused eager loading query findings. | | detectUnusedPopulate | boolean | true | When true, automatically identifies .populate() calls whose populated fields were never read or accessed during the request. |

How it works

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.

Technical details: the fingerprinting system

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.

FAQ

Is it safe to run in production?

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.

Will this affect my application's performance?

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.

Why not just use built-in database query logging?

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).

Development scripts

| 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 |

Non-negotiable principles

Zero runtime dependencies. mongoose is 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.ts definitions for both.

Contributing & Issues

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.

Star History

Star History Chart

Roadmap

  • [x] Unused eager loading detection — flags .populate() calls whose result was never read (Shipped in v2).
  • [ ] Native Fastify & Koa plugins — dedicated framework wrappers for zero-config integration.
  • [ ] Slack & Discord Webhook Reporter — send automated notifications to developer channels on staging.

License

MIT