If your Node.js app uses Mongoose and you’ve watched RSS climb steadily under load without a clear reason, cls-hooked may be the cause. It’s a common dependency in APM agents, context-tracking libraries, and async middleware. It also has a well-known problem with long-lived async resources that won’t get fixed, because the library is unmaintained.
How cls-hooked tracks async context
To understand the leak, you need to know what cls-hooked does.
Node.js doesn’t have a built-in concept of “the current request” the way a thread-per-request server does. When a request comes in, spawns a database query, and returns a response, those steps happen across different async callbacks. Code that needs to know which request a callback belongs to (an APM agent, a logging library, a multi-tenant middleware) has to track that association manually.
cls-hooked did this using Node’s async_hooks API. It maintained a global Map keyed by async ID, an integer Node assigns to every async resource. When a new async operation was created, cls-hooked copied the current context into the Map under the new ID. When the operation finished, it was supposed to clean up the entry via the destroy hook.
The destroy hook is where things break down.
Why Mongoose causes unbounded growth
Mongoose is heavily EventEmitter-based. Connections, models, and query streams are long-lived async resources. They are created at app startup and persist for the lifetime of the process. These resources never trigger their destroy hook, because they are never destroyed.
Every one creates a Map entry that stays there permanently. Under load, with queries flowing through a connection pool, the Map grows without bound. The growth is slow enough to miss in short test runs, around 40 MB per minute in our testing, but in production it adds up. Left long enough, it will climb into gigabytes.
The same pattern applies to anything that creates a long-lived async resource: Redis clients, WebSocket connections, database connection pools. Mongoose is the most common trigger, but not the only one.
How to tell if you’re affected
A few signs to look for:
- RSS grows steadily under load and doesn’t come back down after traffic drops
- The growth rate is roughly proportional to connection pool size, not request volume
- A heap snapshot shows a large
Mapheld by thecls-hookednamespace, with one entry per async ID
You can verify by taking a heap snapshot at startup, running load for 10 to 15 minutes, taking a second snapshot, and comparing them. If the biggest growth is a Map retained by cls-hooked, you’ve found it.
Check your package.json and your lockfile for cls-hooked or continuation-local-storage. If either is present, directly or as a transitive dependency of an APM agent or middleware library, and your app uses Mongoose or a similar driver, you’re probably affected.
The fix
Replace cls-hooked with AsyncLocalStorage.
AsyncLocalStorage has been stable in Node.js since version 16 and is built into the runtime. It propagates context through V8’s native async context mechanism. There is no global Map. Stores are tied to the async chain that created them and GC’d when that chain ends.
Long-lived resources created at app startup, like Mongoose connections and Redis clients, don’t inherit any per-request store, because they were created before any request context existed. They are invisible to AsyncLocalStorage and don’t contribute to memory growth.
If you are using cls-hooked directly, the migration is straightforward:
// Before
const cls = require('cls-hooked');
const namespace = cls.createNamespace('app');
namespace.run(() => {
namespace.set('requestId', '123');
// async work here
});
const id = namespace.get('requestId');
// After
const { AsyncLocalStorage } = require('async_hooks');
const store = new AsyncLocalStorage();
store.run({ requestId: '123' }, () => {
// async work here
});
const id = store.getStore()?.requestId;
If the dependency is inside a library you don’t control, the fix is to upgrade or replace the library. Any APM agent or context-tracking library still using cls-hooked in 2026 is carrying this leak.
Results
When we made this swap in the Scout Node.js agent, memory under the same MongoDB workload went from ~40 MB/min growth (plateauing around 1.8 GB) to stable at around 180 MB. The Map entries were gone because there was no Map.
The Scout Node.js agent has used AsyncLocalStorage since version 2.0.0. If you were seeing steady memory growth in a Scout-instrumented Node.js app running MongoDB or Mongoose, upgrading to 2.0.0 or later removes it.
Running into this or something similar? Reach out at support@scoutapm.com.
Scout Monitoring tracks memory, request timing, and database queries for Express, NestJS, and other Node.js apps. Try it free. No credit card needed.
For application monitoring with errors, logs, and traces, Scout Monitoring provides the fastest insights without the bloat.