The Error Breakdown
Managing data integrity with MongoDB transactions is powerful, but it often leads to a specific, frustrating roadblock: MongoError: Cannot use a session that has ended. This error surfaces when your Node.js application tries to run a query using a ClientSession that was already closed by session.endSession(). Essentially, you're trying to use a tool that has already been put away.
You might see a stack trace similar to this:
MongoError: Cannot use a session that has ended
at ClientSession.applySession (/node_modules/mongodb/lib/core/sessions.js:145:12)
at Collection.insertOne (/node_modules/mongodb/lib/collection.js:456:15)
at /app/services/orderService.js:42:24
Common Root Causes
I've spent hours digging through production logs to find why these sessions die prematurely. Almost every time, the issue boils down to how the asynchronous event loop interacts with the session lifecycle. Here are the most frequent culprits:
1. The "Unawaited" Promise Race
JavaScript won't wait for your database just because you're in a transaction. If you miss a single await inside your transaction block, the engine skips straight to the finally block. Your code might reach session.endSession() in less than 1ms, while the database operation is still taking 20-50ms to travel across the network. The session dies, the query arrives late, and the driver throws an error.
2. Premature Cleanup in Shared Logic
Manual session management is brittle. If you pass a session into a utility function that also has its own try/finally cleanup, that child function might kill the session before the parent is done. This creates a "shared ownership" nightmare where the first function to finish breaks the entire chain.
3. Reusing Sessions After an Abort
Aborting a transaction changes the internal state of the session. If your logic catches an error and then tries to run a cleanup query using that same session object without restarting the transaction, MongoDB will reject it as invalid.
How to Fix the Error
Approach 1: Use the withTransaction Helper (Recommended)
Think of withTransaction as the "safety first" mode for MongoDB. Since Mongoose v5.2.0, this helper has been the standard for avoiding lifecycle bugs. It handles the start, commit, and abort logic automatically. Most importantly, it ensures the session only ends after all internal promises are fully resolved.
const mongoose = require('mongoose');
async function updateInventoryAndCreateOrder(orderData) {
const session = await mongoose.startSession();
try {
// withTransaction handles the lifecycle and retries transient errors
await session.withTransaction(async () => {
const product = await mongoose.model('Product')
.findOne({ _id: orderData.productId })
.session(session);
if (product.stock < orderData.quantity) {
throw new Error('Out of stock');
}
product.stock -= orderData.quantity;
await product.save({ session });
await mongoose.model('Order').create([orderData], { session });
});
} catch (err) {
console.error('Transaction aborted:', err.message);
} finally {
session.endSession();
}
}
Approach 2: Strict Manual Transaction Discipline
If your workflow is too complex for withTransaction, you must be surgical with your await keywords. Every single operation—even logging or minor updates—must be awaited before you reach the cleanup block.
const session = await mongoose.startSession();
session.startTransaction();
try {
await User.updateOne({ _id: userId }, { $inc: { balance: -10 } }, { session });
const logEntry = new Log({ action: 'purchase', userId });
// DANGER: If you forget 'await' here, the session ends before the log saves
await logEntry.save({ session });
await session.commitTransaction();
} catch (error) {
await session.abortTransaction();
throw error;
} finally {
session.endSession();
}
Approach 3: Coordinating Parallel Operations
Parallel queries are faster, but they are the most common source of ended-session errors. When using Promise.all, verify that the session is passed to every single call and that the parent function waits for the entire array to resolve before touching endSession().
// Wait for all operations to finish before moving to the 'finally' block
await Promise.all([
User.updateOne({ _id: u1 }, { $set: { status: 'active' } }, { session }),
User.updateOne({ _id: u2 }, { $set: { status: 'active' } }, { session })
]);
Verification Steps
Confirming a fix requires more than just seeing the error disappear. Use these three checks to ensure your database is healthy:
- Monitor Session Metrics: Run
db.serverStatus().logicalSessionRecordCache.activeSessionsCountin your Mongo shell. If this number climbs indefinitely, you've removedendSession()to fix the error, which causes a memory leak. - Simulate Network Lag: Use a middleware to inject a 100ms delay into your DB calls during local development. If your session management is flawed, this delay will trigger the race condition and surface the error immediately.
- Audit Database Logs: Search for
abortTransactionfollowed byendSessions. A healthy lifecycle shows these commands in a clear, sequential order without overlapping errors.
Prevention Best Practices
- Default to
withTransaction: Use manualstartTransactiononly when you have a specific architectural constraint that prevents the helper from working. - Enable ESLint Security: Use the
no-floating-promisesrule. It flags any database call missing anawait, stopping the most common cause of this error before it hits production. - Centralize Session Ownership: Only the function that calls
startSessionshould be allowed to callendSession. Treat sessions like a borrowed tool: return it to the owner when you're done, but don't throw it away yourself. - Upgrade Mongoose: If you are still on v5.x, moving to v6 or v7 provides much more descriptive error messages that pinpoint exactly which operation failed.

