
Implementing a Shopping Cart and Inventory System in MongoDB
Carts and inventory are where e-commerce stops being a content problem and becomes a concurrency problem. A product page can be cached for an hour, but stock can't. When two customers click "Buy" on the last item within the same second, exactly one of them should get it, and the other should see a clear message instead of a confirmation email for something you can't ship. Add abandoned carts, flash sales, and payment failures, and the edge cases pile up quickly.
MongoDB gives you the pieces to handle this cleanly: single-document atomic updates with conditions in the filter, which cover most inventory operations on their own; multi-document transactions for the checkout step that has to touch several collections at once; and TTL indexes to clean up carts nobody's coming back for.
This guide builds a cart and inventory system step by step: the data model, adding items, reserving stock without overselling, releasing expired reservations, an atomic checkout with a transaction, idempotency, and the pitfalls that cause oversells in production.
The Data Model
Three collections do the work:
carts: one document per active cart.inventory: one document per SKU, holding stock counts and active reservations.orders: created at checkout.
Product content (names, images, descriptions) stays in the catalog, as described in building an e-commerce product catalog. Inventory is deliberately separate: it's small, written constantly, and has completely different consistency needs.
The Cart Document
{
_id: ObjectId("66f3a0c1d2e3f40512345678"),
userId: ObjectId("66e0000000000000000000aa"), // null for guest carts
sessionId: "sess_9f2c71", // for guests
status: "active",
items: [
{ sku: "TR2-BLK-42", qty: 1, priceCents: 11900, name: "Trail Runner 2 (Black, 42)", addedAt: ISODate("2026-09-25T06:40:00Z") },
{ sku: "SOCK-MRN-M", qty: 2, priceCents: 1400, name: "Merino Socks (M)", addedAt: ISODate("2026-09-25T06:44:00Z") }
],
updatedAt: ISODate("2026-09-25T06:44:00Z"),
expiresAt: ISODate("2026-10-09T06:44:00Z")
}
Items are embedded because a cart is always read and written as a whole, and it has a natural upper bound (enforce one, say 50 line items). The price and name are snapshots taken when the item was added, so the cart page doesn't need to join the catalog. You'll re-verify prices at checkout.
The Inventory Document
{
_id: "TR2-BLK-42", // SKU as _id
onHand: 12, // physically in the warehouse
reserved: 3, // held by carts or pending orders
reservations: [
{ cartId: ObjectId("66f3a0c1d2e3f40512345678"), qty: 1, expiresAt: ISODate("2026-09-25T07:10:00Z") }
],
updatedAt: ISODate("2026-09-25T06:40:00Z")
}
Available stock is onHand - reserved. Keeping reserved as its own counter (rather than summing the reservations array on every read) makes the availability check a simple comparison that can go in an update filter.
Setting Up Indexes
db.carts.createIndex({ userId: 1, status: 1 });
db.carts.createIndex({ sessionId: 1, status: 1 });
db.carts.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
db.inventory.createIndex({ "reservations.cartId": 1 });
db.inventory.createIndex({ "reservations.expiresAt": 1 });
db.orders.createIndex({ idempotencyKey: 1 }, { unique: true });
db.orders.createIndex({ userId: 1, createdAt: -1 });
The TTL index on carts.expiresAt deletes abandoned carts automatically. Every cart update pushes expiresAt forward, so only carts that sit untouched for two weeks are removed. See TTL indexes for how the background deletion works.
Adding Items to the Cart
Adding an item has two cases: the SKU is already in the cart (increase quantity) or it isn't (push a new line). You can handle both without reading the cart first:
const CART_TTL_MS = 14 * 24 * 60 * 60 * 1000;
const MAX_LINES = 50;
export async function addToCart(db, cartId, { sku, qty, priceCents, name }) {
const carts = db.collection("carts");
const now = new Date();
const expiresAt = new Date(now.getTime() + CART_TTL_MS);
// Case 1: SKU already in cart, bump the quantity
const bumped = await carts.updateOne(
{ _id: cartId, status: "active", "items.sku": sku },
{ $inc: { "items.$.qty": qty }, $set: { updatedAt: now, expiresAt } },
);
if (bumped.matchedCount === 1) return;
// Case 2: new line, only if the SKU isn't there and the cart isn't full
const pushed = await carts.updateOne(
{
_id: cartId,
status: "active",
"items.sku": { $ne: sku },
[`items.${MAX_LINES - 1}`]: { $exists: false },
},
{
$push: { items: { sku, qty, priceCents, name, addedAt: now } },
$set: { updatedAt: now, expiresAt },
},
);
if (pushed.matchedCount === 1) return;
// Either another request just added this SKU (retry case 1) or the cart is full
const retry = await carts.updateOne(
{ _id: cartId, status: "active", "items.sku": sku },
{ $inc: { "items.$.qty": qty }, $set: { updatedAt: now, expiresAt } },
);
if (retry.matchedCount === 0) throw new Error("Cart is full or not active");
}
The "items.sku": { $ne: sku } condition prevents duplicate lines when two "add" requests race. The items.49 existence check caps the cart at 50 lines without a read. The positional $ operator updates the matched line.
Removing an item or setting an exact quantity is simpler:
await carts.updateOne(
{ _id: cartId },
{ $pull: { items: { sku } }, $set: { updatedAt: new Date() } },
);
await carts.updateOne(
{ _id: cartId, "items.sku": sku },
{ $set: { "items.$.qty": newQty, updatedAt: new Date() } },
);
Reserving Stock Without Overselling
The core operation: reserve qty units of a SKU for a cart, but only if enough are available. The whole check-and-reserve happens in one atomic update, using $expr to compare fields within the document:
const HOLD_MS = 15 * 60 * 1000; // 15-minute hold
export async function reserve(db, sku, cartId, qty) {
const expiresAt = new Date(Date.now() + HOLD_MS);
const result = await db.collection("inventory").updateOne(
{
_id: sku,
$expr: { $gte: [{ $subtract: ["$onHand", "$reserved"] }, qty] },
"reservations.cartId": { $ne: cartId },
},
{
$inc: { reserved: qty },
$push: { reservations: { cartId, qty, expiresAt } },
$set: { updatedAt: new Date() },
},
);
return result.modifiedCount === 1;
}
Because MongoDB applies the filter and the update atomically on a single document, two concurrent reservations for the last unit can't both succeed. One matches and increments reserved; the other's filter no longer matches, so modifiedCount is 0. No transaction, no lock, no race.
The "reservations.cartId": { $ne: cartId } condition keeps a cart from reserving the same SKU twice. To change a cart's reserved quantity, release and reserve again, or write a variant that adjusts the existing entry.
When to Reserve
There's a business decision hidden here. Reserving on "add to cart" guarantees stock for everyone with a full cart, but lets people hoard inventory they never buy. Reserving at "begin checkout" is the more common compromise: carts show live availability, and stock is held only for the few minutes a customer spends entering payment details. The code is the same either way; only the trigger changes.
Releasing a Reservation
export async function release(db, sku, cartId) {
const inventory = db.collection("inventory");
const doc = await inventory.findOne(
{ _id: sku, "reservations.cartId": cartId },
{ projection: { "reservations.$": 1 } },
);
if (!doc) return;
const { qty } = doc.reservations[0];
await inventory.updateOne(
{ _id: sku, "reservations.cartId": cartId },
{
$inc: { reserved: -qty },
$pull: { reservations: { cartId } },
$set: { updatedAt: new Date() },
},
);
}
The read gets the reserved quantity, and the filter on the update makes sure the reservation still exists, so if two releases race, only one of them decrements. If you want to avoid the read, a pipeline update can compute the quantity from the array and remove it in one step:
await inventory.updateOne({ _id: sku, "reservations.cartId": cartId }, [
{
$set: {
reserved: {
$subtract: [
"$reserved",
{
$sum: {
$map: {
input: {
$filter: {
input: "$reservations",
cond: { $eq: ["$$this.cartId", cartId] },
},
},
in: "$$this.qty",
},
},
},
],
},
reservations: {
$filter: {
input: "$reservations",
cond: { $ne: ["$$this.cartId", cartId] },
},
},
updatedAt: "$$NOW",
},
},
]);
Expiring Stale Reservations
A customer who starts checkout and walks away is still holding stock. A TTL index can't help here, because the reservation is an array element, not a document, and the counter must be decremented too. Run a small sweeper every minute instead (a cron job, a worker loop, or an Atlas scheduled trigger):
export async function sweepExpiredReservations(db) {
const now = new Date();
const inventory = db.collection("inventory");
const cursor = inventory.find(
{ "reservations.expiresAt": { $lt: now } },
{ projection: { _id: 1 } },
);
for await (const { _id } of cursor) {
await inventory.updateOne({ _id }, [
{
$set: {
reserved: {
$subtract: [
"$reserved",
{
$sum: {
$map: {
input: {
$filter: {
input: "$reservations",
cond: { $lt: ["$$this.expiresAt", now] },
},
},
in: "$$this.qty",
},
},
},
],
},
reservations: {
$filter: {
input: "$reservations",
cond: { $gte: ["$$this.expiresAt", now] },
},
},
updatedAt: "$$NOW",
},
},
]);
}
}
The update computes and removes expired entries in a single atomic pipeline update, so a checkout that confirms a reservation at the same moment can't double-count. The reservations.expiresAt index keeps the sweep query fast.
Checkout: One Transaction Across Collections
Checkout is where a single-document update isn't enough. You need to:
- Convert each reservation into a permanent stock decrement.
- Create the order.
- Mark the cart as converted.
If any step fails, none of them should happen. That's what multi-document transactions are for. They require a replica set, which every production deployment (and every Atlas cluster) already is.
export async function checkout(
client,
db,
{ cartId, userId, idempotencyKey, paymentIntentId },
) {
const carts = db.collection("carts");
const inventory = db.collection("inventory");
const orders = db.collection("orders");
const products = db.collection("products");
const existing = await orders.findOne({ idempotencyKey });
if (existing) return existing;
const session = client.startSession();
try {
let order;
await session.withTransaction(
async () => {
const cart = await carts.findOne(
{ _id: cartId, status: "active" },
{ session },
);
if (!cart || cart.items.length === 0)
throw new CheckoutError("Cart is empty or already checked out");
// Re-verify current prices from the catalog
const skus = cart.items.map((i) => i.sku);
const priced = await products
.aggregate(
[
{ $match: { "variants.sku": { $in: skus } } },
{ $unwind: "$variants" },
{ $match: { "variants.sku": { $in: skus } } },
{
$project: {
_id: 0,
sku: "$variants.sku",
priceCents: "$variants.priceCents",
},
},
],
{ session },
)
.toArray();
const priceBySku = new Map(priced.map((p) => [p.sku, p.priceCents]));
for (const item of cart.items) {
const res = await inventory.updateOne(
{
_id: item.sku,
onHand: { $gte: item.qty },
reservations: { $elemMatch: { cartId, qty: item.qty } },
},
{
$inc: { onHand: -item.qty, reserved: -item.qty },
$pull: { reservations: { cartId } },
$set: { updatedAt: new Date() },
},
{ session },
);
if (res.modifiedCount !== 1)
throw new CheckoutError(`Reservation missing for ${item.sku}`);
}
const lines = cart.items.map((i) => ({
sku: i.sku,
name: i.name,
qty: i.qty,
priceCents: priceBySku.get(i.sku) ?? i.priceCents,
}));
const totalCents = lines.reduce(
(sum, l) => sum + l.priceCents * l.qty,
0,
);
order = {
userId,
idempotencyKey,
paymentIntentId,
lines,
totalCents,
status: "pending_payment",
createdAt: new Date(),
};
const { insertedId } = await orders.insertOne(order, { session });
order._id = insertedId;
await carts.updateOne(
{ _id: cartId },
{
$set: {
status: "converted",
orderId: insertedId,
updatedAt: new Date(),
},
$unset: { expiresAt: "" },
},
{ session },
);
},
{ readConcern: { level: "snapshot" }, writeConcern: { w: "majority" } },
);
return order;
} finally {
await session.endSession();
}
}
class CheckoutError extends Error {}
What this guarantees:
- All or nothing. If a reservation expired (the sweeper removed it), the inventory update matches nothing, the transaction throws, and no order is created. The customer gets a clear "item no longer reserved" error and can retry.
- No oversell. Stock only moves from
reservedto sold if the specific reservation exists andonHandcovers it. - Automatic retry on conflicts.
withTransactionretries transient errors like write conflicts, which happen when two checkouts touch the same inventory document. - Unset
expiresAt. Converted carts should stick around as a record, so the TTL field is removed.
The price-verification step assumes the catalog stores priceCents on variants. The order uses the current catalog price, which protects you if a price changed while the item sat in the cart. You might prefer to show the customer the change and ask them to confirm instead.
Idempotency
Networks fail. A customer double-clicks. A mobile app retries after a timeout. Without protection, each retry creates another order. The idempotencyKey (generated by the client once per checkout attempt and sent with every retry) plus a unique index on it means a second attempt either finds the existing order up front, or fails with a duplicate key error inside the transaction, which aborts it cleanly. Handle that case by fetching and returning the existing order:
try {
return await checkout(client, db, input);
} catch (err) {
if (err.code === 11000)
return db
.collection("orders")
.findOne({ idempotencyKey: input.idempotencyKey });
throw err;
}
Where Payment Fits
Don't call a payment provider inside the transaction. External calls are slow and can't be rolled back, and transactions should be short (they abort after 60 seconds by default). The common flow is:
- Run the checkout transaction, creating an order in
pending_payment. - Charge the customer outside the transaction.
- On success (usually via the provider's webhook), update the order to
paid. - On failure or timeout, cancel the order and return the stock with
$inc: { onHand: qty }.
Every one of these later steps is a single-document update with a status condition in the filter ({ _id, status: "pending_payment" }), so each transition happens exactly once.
Merging Guest Carts on Login
Guests build carts under a sessionId. When they log in, merge the guest cart into their user cart:
export async function mergeCarts(db, sessionId, userId) {
const carts = db.collection("carts");
const guest = await carts.findOne({
sessionId,
status: "active",
userId: null,
});
if (!guest) return;
const userCart = await carts.findOneAndUpdate(
{ userId, status: "active" },
{
$setOnInsert: {
userId,
status: "active",
items: [],
updatedAt: new Date(),
},
},
{ upsert: true, returnDocument: "after" },
);
for (const item of guest.items) {
await addToCart(db, userCart._id, item);
}
await carts.deleteOne({ _id: guest._id });
}
Reusing addToCart means merged items follow the same deduplication and size limits as normal additions.
Common Pitfalls
Checking stock, then decrementing. A findOne followed by an updateOne leaves a gap where another request can take the last unit. Put the availability condition in the update's filter.
Letting counters go negative. Every decrement should include a guard in the filter (onHand: { $gte: qty }, or an $expr on available stock). A negative stock count is a bug that's already happened, not one you can catch later.
Calling external services inside transactions. Payment APIs, emails, and webhooks belong outside. Keep transactions to database work that finishes in milliseconds.
No reservation expiry. Without a sweeper, abandoned checkouts hold stock forever and your "sold out" pages lie.
No idempotency key. Retries create duplicate orders. Generate a key per checkout attempt and enforce it with a unique index.
Stock on the product document. Every sale rewrites the whole product and conflicts with catalog edits. Keep inventory per SKU in its own small documents.
Conclusion
A reliable cart and inventory system in MongoDB rests on a few ideas: carts are single documents with embedded, bounded line items; inventory is one small document per SKU with onHand and reserved counters; every stock change is an atomic update with its safety condition in the filter; a sweeper releases stale holds; and checkout wraps the multi-collection changes in one short transaction guarded by an idempotency key.
Write a quick load test that fires 50 concurrent reserve() calls at a SKU with 10 units on hand and count how many succeed. If the answer is exactly 10, your inventory logic holds, and you can build the rest of checkout on top of it with confidence.


