When building backend applications with Node.js and MongoDB, many operations involve more than one database write.
For example, imagine an e-commerce application where placing an order requires you to:
- Create an order.
- Reduce product inventory.
- Create a payment record.
- Update the user’s order history.
What happens if the order is created successfully but updating the inventory fails?
You could end up with inconsistent data.
This is where MongoDB transactions become useful.
MongoDB transactions allow multiple database operations to be executed as a single atomic unit. If all operations succeed, the transaction is committed. If something fails, the transaction can be aborted and the changes made during the transaction are rolled back.
In this article, we’ll learn how MongoDB transactions work in Node.js, when to use them, how to implement them with Mongoose, and what mistakes to avoid.
What Is a MongoDB Transaction?
A transaction is a group of database operations that should either all succeed or all fail.
For example:
Create Order
↓
Update Inventory
↓
Create Payment
↓
Commit Transaction
If the payment operation fails:
Create Order ✓
Update Inventory ✓
Create Payment ✗
↓
Rollback
↓
No changes saved
The goal is to maintain consistent application state.
Why Do We Need Transactions?
Without transactions, multiple database operations are independent.
Consider:
await Order.create(orderData);
await Product.updateOne(
{ _id: productId },
{ $inc: { stock: -1 } }
);
await Payment.create(paymentData);
Suppose the first two operations succeed but the payment operation fails.
Your database could now contain:
Order → Created
Inventory → Reduced
Payment → Not Created
The application is now in an inconsistent state.
A transaction allows these operations to behave as one unit.
MongoDB Transaction Example
A transaction typically follows this pattern:
Start Session
↓
Start Transaction
↓
Database Operations
↓
Commit
↓
End Session
If an error occurs:
Start Transaction
↓
Database Operations
↓
Error
↓
Abort Transaction
↓
End Session
MongoDB Transactions with Mongoose
If you’re using Node.js with Mongoose, transactions can be implemented using a session.
For example:
const session = await mongoose.startSession();
try {
session.startTransaction();
// Database operations
await session.commitTransaction();
} catch (error) {
await session.abortTransaction();
throw error;
} finally {
await session.endSession();
}
Let’s break this down.
Step 1: Start a Session
First, create a MongoDB session:
const session = await mongoose.startSession();
The session allows MongoDB to associate your database operations with the transaction.
Step 2: Start the Transaction
Start the transaction:
session.startTransaction();
From this point onward, operations that use this session participate in the transaction.
Step 3: Pass the Session to Database Operations
This is extremely important.
For example:
await User.create(
[
{
name: "John",
email: "john@example.com"
}
],
{ session }
);
And:
await Account.updateOne(
{ userId },
{
$inc: {
balance: -100
}
},
{ session }
);
The { session } option tells MongoDB that these operations belong to the current transaction.
If you forget to pass the session, that operation may execute outside the transaction.
Step 4: Commit the Transaction
If every operation succeeds:
await session.commitTransaction();
MongoDB permanently applies the changes.
Step 5: Abort the Transaction
If something goes wrong:
await session.abortTransaction();
MongoDB rolls back the operations performed within the transaction.
Step 6: End the Session
Finally:
await session.endSession();
You should always clean up the session.
Complete Example
Suppose we have an e-commerce application.
We want to:
- Create an order
- Reduce product stock
- Create a payment record
We can implement it like this:
import mongoose from "mongoose";
async function createOrder(
userId: string,
productId: string,
quantity: number
) {
const session = await mongoose.startSession();
try {
session.startTransaction();
const product = await Product.findById(productId)
.session(session);
if (!product) {
throw new Error("Product not found");
}
if (product.stock < quantity) {
throw new Error("Insufficient stock");
}
const order = await Order.create(
[
{
userId,
productId,
quantity,
status: "created"
}
],
{ session }
);
await Product.updateOne(
{ _id: productId },
{
$inc: {
stock: -quantity
}
},
{ session }
);
await Payment.create(
[
{
userId,
orderId: order[0]._id,
amount: product.price * quantity,
status: "pending"
}
],
{ session }
);
await session.commitTransaction();
return order[0];
} catch (error) {
await session.abortTransaction();
throw error;
} finally {
await session.endSession();
}
}
Here, all three operations are part of the same transaction.
Using withTransaction()
MongoDB also provides a convenient transaction helper.
With Mongoose, you can use:
const session = await mongoose.startSession();
try {
await session.withTransaction(async () => {
await Order.create(
[
{
userId,
productId,
quantity
}
],
{ session }
);
await Product.updateOne(
{ _id: productId },
{
$inc: {
stock: -quantity
}
},
{ session }
);
});
} finally {
await session.endSession();
}
This approach simplifies transaction handling because the transaction lifecycle is managed by the helper.
For many applications, withTransaction() makes transaction code easier to read and maintain.
When Should You Use MongoDB Transactions?
Transactions are useful when multiple operations must remain consistent.
Common examples include:
1. Money Transfers
For example:
Account A
↓
- $100
Account B
↓
+ $100
Both operations should succeed together.
If the first operation succeeds but the second fails, the system becomes inconsistent.
A transaction can ensure both changes happen together.
2. Order Processing
An order might involve:
Create Order
↓
Reduce Stock
↓
Create Payment
↓
Update Customer
If these operations represent one logical action, a transaction can help maintain consistency.
3. Inventory Management
For example:
Product stock = 10
Customer purchases 2
Stock = 8
Order = Created
If the order creation succeeds but the inventory update fails, your application could have incorrect inventory information.
4. User Registration
Suppose registration requires:
Create User
↓
Create Profile
↓
Create Settings
↓
Create Subscription
If the profile creation fails after the user is created, you may end up with incomplete data.
A transaction can keep these operations atomic when they need to be treated as one unit.
5. Healthcare or Workflow Applications
Applications that manage multiple related records may also benefit from transactions.
For example:
Create Patient
↓
Create Episode
↓
Create Treatment
↓
Create Follow-up
If all these records must be created together, a transaction can prevent partially completed workflows.
When Should You NOT Use Transactions?
Transactions are powerful, but you shouldn’t use them for every database operation.
For example:
await User.findById(userId);
doesn’t need a transaction.
Likewise:
await Product.find();
doesn’t need a transaction.
A transaction is generally unnecessary when there is only one independent database operation.
Instead, use transactions when multiple operations need to behave as one atomic unit.
Transactions vs Single Document Atomicity
MongoDB already provides atomicity for writes to a single document.
For example:
await Product.updateOne(
{ _id: productId },
{
$inc: {
stock: -1
}
}
);
This operation is atomic at the document level.
You don’t necessarily need a transaction just because you’re updating data.
The need for a transaction usually appears when you need consistency across multiple documents or collections.
For example:
Order document
+
Product document
+
Payment document
If these operations must succeed or fail together, a transaction may be appropriate.
Transactions Across Multiple Collections
One of the major advantages of MongoDB transactions is that operations across multiple collections can participate in the same transaction.
For example:
orders
payments
products
users
You can perform changes to all of them using the same session.
await Order.create(orderData, { session });
await Payment.create(paymentData, { session });
await Product.updateOne(
{ _id: productId },
{
$inc: {
stock: -quantity
}
},
{ session }
);
If the transaction is aborted, changes made through the transaction are rolled back.
Important: Every Operation Must Use the Session
Consider:
session.startTransaction();
await Order.create(
[orderData],
{ session }
);
await Product.updateOne(
{ _id: productId },
{
$inc: {
stock: -quantity
}
}
);
The second operation does not use:
{ session }
That means it isn’t participating in the transaction.
A safer pattern is:
await Order.create(
[orderData],
{ session }
);
await Product.updateOne(
{ _id: productId },
{
$inc: {
stock: -quantity
}
},
{ session }
);
Always make sure operations that must be atomic are executed using the same session.
Transaction Error Handling
Transaction errors should be handled carefully.
Basic pattern:
try {
await session.withTransaction(async () => {
// database operations
});
} catch (error) {
console.error("Transaction failed:", error);
throw error;
} finally {
await session.endSession();
}
Don’t silently swallow transaction errors.
Bad:
catch (error) {
console.log(error);
}
Better:
catch (error) {
console.error("Transaction failed:", error);
throw error;
}
The controller or service layer can then return an appropriate API response.
Transactions and Express Controllers
A clean Node.js architecture should generally keep transaction logic in the service layer rather than putting all database logic inside the controller.
For example:
Controller
↓
Service
↓
Transaction
↓
Models
Controller:
export const createOrder = async (
req: Request,
res: Response
) => {
try {
const order = await orderService.createOrder(
req.user.id,
req.body
);
return res.status(201).json({
message: "Order created successfully",
data: order
});
} catch (error) {
return res.status(500).json({
message: "Unable to create order"
});
}
};
Service:
async function createOrder(
userId: string,
data: CreateOrderInput
) {
const session = await mongoose.startSession();
try {
await session.withTransaction(async () => {
// order operations
});
} finally {
await session.endSession();
}
}
This separation makes the code easier to test and maintain.
Avoid Long-Running Transactions
Transactions should generally be kept as short as practical.
Avoid doing unrelated work inside a transaction.
For example, don’t do this:
await session.withTransaction(async () => {
await Order.create(
[orderData],
{ session }
);
await sendEmail();
await callExternalPaymentAPI();
await generateLargePDF();
await Product.updateOne(
{ _id: productId },
{ $inc: { stock: -1 } },
{ session }
);
});
External API calls and expensive operations can make transactions unnecessarily long.
A better approach is to keep the transaction focused on the required database changes.
Don’t Perform External API Calls Inside a Transaction
Suppose your application calls a payment provider:
await session.withTransaction(async () => {
await Order.create(
[orderData],
{ session }
);
await paymentProvider.charge();
});
This can create complicated failure scenarios.
For example:
Payment provider succeeds
↓
MongoDB transaction fails
↓
Order is rolled back
↓
Payment may already have been charged
The database transaction cannot automatically roll back an external service.
For workflows involving external systems, consider patterns such as:
- Outbox pattern
- Idempotency keys
- Retry mechanisms
- Background jobs
- Explicit payment states
Transaction Performance Considerations
Transactions add overhead compared with simple single-document operations.
Therefore, use them where they provide real consistency benefits.
Instead of:
Every database operation
↓
Transaction
prefer:
Independent operation
↓
Normal MongoDB write
Multiple dependent operations
↓
Transaction
Also make sure the queries used inside your transaction are properly indexed.
Transactions and MongoDB Deployment
MongoDB transactions require a deployment configuration that supports them.
For production applications, MongoDB replica sets are commonly used.
If you’re developing locally and want to test transactions, make sure your local MongoDB setup supports transactions as well.
If you receive an error indicating that transactions are not supported, check your MongoDB deployment configuration before debugging your application code.
Common MongoDB Transaction Mistakes
Mistake 1: Forgetting the Session
await User.create(userData);
instead of:
await User.create(
[userData],
{ session }
);
Mistake 2: Not Aborting on Failure
If you’re manually managing the transaction lifecycle:
catch (error) {
await session.abortTransaction();
throw error;
}
Mistake 3: Forgetting to End the Session
Always clean up:
finally {
await session.endSession();
}
Mistake 4: Putting External Services Inside Transactions
Avoid:
Transaction
↓
Payment API
↓
Email API
↓
Large file processing
Keep the database transaction focused.
Mistake 5: Using Transactions Everywhere
Transactions aren’t automatically better.
If one atomic database operation is enough, a transaction may add unnecessary complexity.
A Reusable Transaction Helper
If your application uses transactions in many services, you can create a reusable helper.
import mongoose, {
ClientSession
} from "mongoose";
export async function runTransaction<T>(
callback: (session: ClientSession) => Promise<T>
): Promise<T> {
const session = await mongoose.startSession();
try {
let result!: T;
await session.withTransaction(async () => {
result = await callback(session);
});
return result;
} finally {
await session.endSession();
}
}
Then your service can become:
const order = await runTransaction(
async (session) => {
const order = await Order.create(
[orderData],
{ session }
);
await Product.updateOne(
{ _id: productId },
{
$inc: {
stock: -quantity
}
},
{ session }
);
return order[0];
}
);
This can reduce repeated transaction boilerplate across your application.
Best Practices for MongoDB Transactions
Here is a practical checklist:
- Use transactions only when multiple operations need atomicity.
- Use a single session for related operations.
- Pass the session to every operation that belongs to the transaction.
- Prefer
withTransaction()for simpler transaction management. - Keep transactions short.
- Avoid external API calls inside transactions.
- Avoid expensive processing inside transactions.
- Make sure queries are properly indexed.
- Handle errors properly.
- Always end sessions.
- Design external workflows to be idempotent.
- Test rollback scenarios.
- Test what happens when each individual operation fails.
Final Thoughts
MongoDB transactions are useful when multiple database operations need to behave as a single atomic operation.
The basic lifecycle is:
Start Session
↓
Start Transaction
↓
Perform Database Operations
↓
Everything succeeds?
/ \
Yes No
↓ ↓
Commit Abort
↓ ↓
End Session
The most important concept is not simply knowing how to start a transaction. It’s understanding when you actually need one.
For a single-document operation, MongoDB’s built-in atomicity may already be sufficient. For workflows involving multiple related documents or collections where partial updates would create inconsistent state, transactions can provide the atomic behavior your application needs.
When using Node.js and Mongoose, keep transaction logic in the service layer, pass the same session to every participating database operation, keep transactions short, and handle external services separately.
Used appropriately, MongoDB transactions can make complex workflows much safer and easier to reason about.




