Fixing 'MongoError: Cannot use a session that has ended' in MongoDB Transactions

intermediate🍃 MongoDB2026-04-10| Node.js (v14+), MongoDB (v4.0+ Replica Set), Mongoose (v5.x/v6.x/v7.x)

Error Message

MongoError: Cannot use a session that has ended
#mongodb#session#transaction#mongoose#nodejs

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.activeSessionsCount in your Mongo shell. If this number climbs indefinitely, you've removed endSession() 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 abortTransaction followed by endSessions. A healthy lifecycle shows these commands in a clear, sequential order without overlapping errors.

Prevention Best Practices

  • Default to withTransaction: Use manual startTransaction only when you have a specific architectural constraint that prevents the helper from working.
  • Enable ESLint Security: Use the no-floating-promises rule. It flags any database call missing an await, stopping the most common cause of this error before it hits production.
  • Centralize Session Ownership: Only the function that calls startSession should be allowed to call endSession. 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.

Related Error Notes