Why Postgres Transactions Saved Our Order Flow
2026-09-07
Early in Storich's development, our order creation flow did roughly this:
It worked fine in testing. Then we hit a production case where step 2 failed halfway through, one product updated, another did not, and we ended up with a paid order referencing stock we had not properly reserved. The fix was Postgres transactions.
A transaction groups multiple database operations into a single atomic unit. Either all of them succeed (commit) or none of them apply (rollback). There is no in-between.
The four properties that guarantee this are called ACID:
// Dangerous, no transaction
async createOrder(userId: string, cartItems: CartItem[]) {
const order = await this.orderRepo.save({ userId, status: 'pending' });
for (const item of cartItems) {
// If this throws on item 3, items 1 and 2 are already decremented
await this.productRepo.decrement(
{ id: item.productId },
'stock',
item.quantity,
);
}
await this.cartRepo.delete({ userId });
// Order exists but cart is not cleared and stock is partially wrong
}
If anything throws after orderRepo.save, you have corrupted state, an order with no corresponding stock deduction, or partial stock deduction with no order.
TypeORM exposes transactions via QueryRunner, which gives you a dedicated database connection you fully control:
async createOrder(userId: string, cartItems: CartItem[]): Promise<Order> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const order = await queryRunner.manager.save(Order, {
userId,
status: 'pending',
orderNumber: this.generateOrderNumber(),
});
for (const item of cartItems) {
const product = await queryRunner.manager.findOne(Product, {
where: { id: item.productId },
lock: { mode: 'pessimistic_write' }, // prevents race conditions
});
if (!product || product.stock < item.quantity) {
throw new BadRequestException(
`Insufficient stock for ${product?.name}`,
);
}
await queryRunner.manager.decrement(
Product,
{ id: item.productId },
'stock',
item.quantity,
);
await queryRunner.manager.save(OrderItem, {
orderId: order.id,
productId: item.productId,
quantity: item.quantity,
unitPrice: product.price,
});
}
await queryRunner.manager.delete(CartItem, { userId });
await queryRunner.commitTransaction();
return order;
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release(); // always release back to pool
}
}
Notice lock: { mode: 'pessimistic_write' } on the product fetch. Without it, two concurrent orders for the same product could both read a stock of 1, both pass the stock check, and both decrement, leaving stock at -1. Pessimistic write locking tells Postgres to hold a row-level lock until the transaction commits, so the second request blocks until the first is done.
-- What TypeORM generates under the hood
SELECT * FROM product WHERE id = $1 FOR UPDATE;
For less complex flows, TypeORM offers a cleaner wrapper:
await this.dataSource.transaction(async (manager) => {
await manager.save(Order, orderData);
await manager.decrement(Product, { id: productId }, 'stock', qty);
await manager.delete(CartItem, { userId });
});
It handles commit and rollback automatically. Use QueryRunner when you need fine-grained control (savepoints, custom isolation levels); use dataSource.transaction() for straightforward flows.
CREATE INDEX CONCURRENTLY and long-running backfills must run outside a transaction to avoid locking tables or hitting Postgres restrictions.release() the QueryRunner in a finally block, failing to do so leaks connection pool resources.