
Atomic Transactions & Rollbacks in MongoDB with Mongoose
Content
Background
Recently I was developing a backend service and I came across a problem where updating user information across multiple database collections when a purchase is made. This is a common pattern in real-world applications where a single business operation requires updating multiple data entities.
The challenge? What happens when one operation succeeds but another fails?

Imagine we're running an online store selling Hand Held Retro Gaming Consoles. Our system has three main collections: Users, Products, and Orders. Below can be a typical sequence of purchase:
- Node & Express Js API server listening for client requests
- Customer places an order, payment is done by some gateway and browser sends request to backend with order and payment details.
- Backend validates inventory & payment and has following sub-steps in processing order:
- ✅ Updates
User collection
with new shipping address & probably we are saving monetary sum of all purchases made by user for analytics - LifeTimeValue of customer. - ✅ Updates Inventory in
Products collection
to reflect available stock after purchase - ❌ Creates a new Order in
Order collection
. But this step fails for some unknown reason, maybe server crashed due to high traffic, or Coding error or Network outage.. It can be any unknown factor.
With above flow we have Stale data or inconsistent data in Users collection & Products collection. Order was not created and customer is un-happy to see money deducted but no purchase order being made.
This scenario creates a nightmare: manual data cleanup, refund processing, and unhappy customers. If you have been in similar situation like above or have idea about this but do not know how to solve it then you are in right place.
Prerequisites
To proceed further, I assume you have minimal understand of Node & Express Js framework. You have built CRUD API endpoints before and persisted data in MongoDB using mongoose ODM.
Understanding Transactions & Rollbacks
Imagine you are sending a gift box to your friend. This box has a book, a toy and a letter. Shipping service takes your box and does all logistics to make sure gift box has reached to your friend with all three items. But it may happen that your friend is not at said address, in that case the shipping service returns the box to you with all three items as is.
The gift box with all contents either reaches destination or it comes back with all contents. There is not a case where items are partially delivered.
Another example you can think of is Bank Transactions. Say Alice wants to send money to Bob. Alice has done money transfer operation and money is debited from her account. If it reflects in Bob's account then transaction is committed. If transaction failed may be due to incorrect Bob's account address then transaction is aborted and rollback happens - crediting money back to Alice's account.
We call this a Transaction - your way of safely bundling things together, confident that you’ll either succeed completely—or not at all, with nothing lost or left half-done.
Rollback happens when a series of things needs to happen together but one or few of them fails. The reverting of all changes made within Transaction, restoring the state to what it was before the transaction began.

Code Examples & Implementation
The way we normally do mongoose Model CRUD operations is something like below. If the order creation fails, the user and product updates remain in the database, creating data inconsistency.
import express, { Request, Response } from 'express';
// ...
// ...
const router = express.Router();
router.post('/place-order', [validateUserToken], async (req: Request, res: Response) => {
try {
// Data from Request & Middleware
// ...
// Verify Payment & Product Stock
// ...
// ✅ Update User - DB Operation 1
user.shippingAddress = shippingAddress;
user.lifeTimeValue += billingAmount;
await user.save();
// ✅ Update Product Stock - DB Operation 2
product.quantity -= orderProductQuantity;
await product.save();
// ❌ Create Order - DB Operation 3
const newOrder = new OrderModel({ ...orderDetails });
await newOrder.save();
// Respond back to client
// ..
} catch (error: any) {
// Error Handling
// ...
}
});
export default router;
To solve this problem we can adopt MongoDB Transactions. Below is the big picture idea behind it. We will follow this while re-writing above code.

There are two ways mongoose allows us to implement this. One is called managed transactions and another is manual transactions. Lets look at both.
Managed Transactions (Recommended for Beginners)
Mongoose's withTransaction() method handles commit/rollback automatically:
const session = await mongoose.startSession();
await session.withTransaction(async () => {
// Your operations here with { session }
await Model1.create([data], { session });
await Model2.updateOne(filter, update, { session });
});
session.endSession();
// import statements
// ...
// ...
import { startSession } from 'mongoose';
const router = express.Router();
router.post('/place-order', [validateUserToken], async (req: Request, res: Response) => {
try {
// Data from Request & Middleware
// ...
// Verify Payment & Product Stock
// ...
const session = await startSession();
let isTransactionSuccessful = false;
let transactionErrorMessage = '';
try {
await session.withTransaction(async () => {
// DB Operation 1 - Update User
user.shippingAddress = shippingAddress;
user.lifeTimeValue += billingAmount;
await user.save({ session }); // All operations use the same session
// DB Operation 2 - Update Product Stock
product.quantity -= orderProductQuantity;
await product.save({ session }); // All operations use the same session
// DB Operation 3 - Create Order
const newOrder = new OrderModel({ ...orderDetails });
await newOrder.save({ session }); // All operations use the same session
isTransactionSuccessful = true;
});
} catch (error) {
// Error Handling - transactionErrorMessage
// ..
} finally {
await session.endSession();
}
// Respond back to client - isTransactionSuccessful, transactionErrorMessage
// ..
} catch (error: any) {
// Error Handling
// ...
}
})
export default router;
Pros:
- Automatic error handling
- Built-in retry logic
- Simpler implementation
Cons:
- Less control over error handling
- Harder to implement custom rollback logic
Manual Transactions (For Advanced Use Cases)
For more control, use manual transaction management. I personally prefer this in my projects.
const session = await mongoose.startSession();
try {
// Start Transaction Session
session.startTransaction();
// DB Operations
await Model1.create([data], { session });
await Model2.updateOne(filter, update, { session });
// Commit Transaction
await session.commitTransaction();
} catch (error) {
// Abort Transaction - Failure
await session.abortTransaction();
throw error;
} finally {
// End Transaction Session
session.endSession();
}
// import statements
// ...
// ...
import { startSession } from 'mongoose';
const router = express.Router();
router.post('/place-order', [validateUserToken], async (req: Request, res: Response) => {
const session = await startSession();
try {
// Data from Request & Middleware
// ...
// Verify Payment & Product Stock
// ...
// DB Operation 1 - Update User
user.shippingAddress = shippingAddress;
user.lifeTimeValue += billingAmount;
await user.save({ session }); // All operations use the same session
// DB Operation 2 - Update Product Stock
product.quantity -= orderProductQuantity;
await product.save({ session }); // All operations use the same session
// DB Operation 3 - Create Order
const newOrder = new OrderModel({ ...orderDetails });
await newOrder.save({ session }); // All operations use the same session
// All DB Operations Successful - Commit Transaction
await session.commitTransaction();
// Respond back to client - isTransactionSuccessful, transactionErrorMessage
// ..
} catch (error: any) {
// One or Few DB Operations Failed - Abort Transaction (Rollback)
await session.abortTransaction();
// Error Handling
// ...
}
})
export default router;
Pros:
- Full control over transaction lifecycle
- Custom error handling per operation
- Better for complex business logic
Cons:
- More boilerplate code
- Manual commit/rollback management
Best Practices
1. Error handling: Always wrap transactions in try-catch blocks and ensure sessions are properly closed:
const session = await mongoose.startSession();
try {
// Transaction logic
} catch (error) {
// Handle errors appropriately
console.error('Transaction failed:', error);
} finally {
await session.endSession(); // Always close session
}
3. Performance Considerations:
- Keep transactions short to minimize lock time
- Avoid long-running operations within transactions
- Consider breaking large operations into smaller transactions<
4. Session Management:
// ❌ Don't do this
const session = await mongoose.startSession();
// ... long running code without proper cleanup
// ✅ Do this
const session = await mongoose.startSession();
try {
// Your transaction code
} finally {
await session.endSession(); // Always cleanup
}
Conclusion
The key is understanding when to use transactions and implementing them with proper error handling and session management. Start with managed transactions using withTransaction() and move to manual transactions when you need more control.
Remember: transactions are powerful but should be used judiciously. Not every operation needs a transaction - reserve them for cases where data consistency across multiple operations is critical.
If you found this article helpful and want to support my work, please visit buymeacoffee page here.