Using SERIALIZABLE Isolation in Sequelize to Prevent Race Conditions on Inventory Updates
Learn how to use Sequelize’s SERIALIZABLE transaction isolation level to prevent race conditions when decrementing inventory or updating financial records.
27 Aug 2025, 22:28 UTC

The problem: concurrent updates can corrupt inventory counts
Imagine an e‑commerce API where two users try to buy the last item of a product at the same time. Each request reads the current stock, decrements it, and writes the new value back. Without proper coordination, both requests may read the same stock value (e.g., 5), both subtract 1, and write 4, effectively losing one sale. This classic race condition appears whenever multiple transactions modify the same rows concurrently.
Why transaction isolation matters
Isolation levels define how visible the changes of one transaction are to another running at the same time. The default level varies by dialect: MySQL uses REPEATABLE READ, PostgreSQL uses READ COMMITTED. Relying on the default can lead to inconsistent behavior when you switch databases or when the default changes in a new version.
The SERIALIZABLE level is the strictest: it guarantees that transactions appear to execute serially, one after another, even if they actually run in parallel. This prevents phantom reads and ensures that two concurrent updates to the same row cannot both succeed without one being rolled back.
Configuring a Sequelize transaction with SERIALIZABLE isolation
Sequelize lets you specify the isolation level when you start a transaction:
const t = await sequelize.transaction({
isolationLevel: Sequelize.Transaction.ISOLATION_LEVELS.SERIALIZABLE,
});
The isolationLevel option accepts one of the four standard values: READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, or SERIALIZABLE. When the promise returned by the transaction callback resolves, Sequelize automatically commits; if it rejects, it rolls back.
Worked example: safe inventory decrement
Below is a self‑contained Node.js snippet that demonstrates how to use the SERIALIZABLE level to protect an inventory update. Replace the placeholders with your actual connection details and model definitions.
const { Sequelize, Model, DataTypes } = require('sequelize');
// 1. Setup Sequelize (adjust dialect, credentials, pool size as needed)
const sequelize = new Sequelize('postgres://user:password@localhost:5432/mydb', {
logging: msg => console.log(msg), // enables visibility of raw SQL
pool: { max: 10, min: 0, acquire: 30000, idle: 10000 },
});
// 2. Define a simple Inventory model
class Inventory extends Model {}
Inventory.init({
productId: { type: DataTypes.INTEGER, primaryKey: true },
quantity: { type: DataTypes.INTEGER, allowNull: false },
}, { sequelize, modelName: 'inventory' });
// 3. Function that safely decrements stock
async function reserveItem(productId, amount = 1) {
const t = await sequelize.transaction({
isolationLevel: Sequelize.Transaction.ISOLATION_LEVELS.SERIALIZABLE,
});
try {
// Load the row inside the transaction
const item = await Inventory.findByPk(productId, { transaction: t, lock: t.LOCK.UPDATE });
if (!item) {
throw new Error(`Product ${productId} not found`);
}
if (item.quantity < amount) {
throw new Error('Insufficient stock');
}
// Perform the decrement
item.quantity -= amount;
await item.save({ transaction: t });
// Commit only after the update succeeds
await t.commit();
console.log(`Reserved ${amount} for product ${productId}. New stock: ${item.quantity}`);
} catch (err) {
// On any error, roll back to keep the database consistent
await t.rollback();
console.error('Transaction failed:', err.message);
// In a real service you might retry the operation here
throw err;
}
}
// 4. Example usage (call from your route handler)
reserveItem(42, 1).catch(console.error);
Explanation of key parts:
lock: t.LOCK.UPDATEacquires a row‑level lock on the selected row, preventing other transactions from reading or writing it until the current transaction ends.- The
try / catchblock ensures that any failure triggers a rollback, leaving the stock unchanged. - Logging (
logging: msg => console.log(msg)) lets you see the emitted SQL, includingSET TRANSACTION ISOLATION LEVEL SERIALIZABLE, confirming that Sequelize honored the requested level.
Trade‑offs and limitations
While SERIALIZABLE eliminates the race condition, it introduces considerations you should weigh:
- Higher abort rate: Under heavy concurrency, transactions may be rolled back due to serialization failures. Your application logic must be prepared to retry the operation (exponential back‑off is a common pattern).
- Performance impact: Holding locks for the duration of the transaction can reduce throughput. Keep the transaction scope as short as possible—avoid expensive computations or external API calls inside the callback.
- Dialect differences: Some databases (e.g., SQLite) do not support SERIALIZABLE; attempting to use it will throw an error. Verify that your target dialect supports the level before relying on it.
Practical way to verify the behavior:
- Run two Node.js processes that each call
reserveItem(42, 1)against the same database at nearly the same time. - Observe the logs: one process will commit successfully, the other will receive a serialization failure error (e.g.,
deadlock detectedorcould not serialize access due to concurrent update). - Check that the final stock reflects exactly one decrement, confirming that the race condition was prevented.
Closing thoughts
Choosing the right isolation level is a deliberate engineering decision, not a default setting. For workloads where correctness outweighs raw throughput—such as inventory management, financial ledgers, or any scenario where lost updates are unacceptable—SERIALIZABLE isolation in Sequelize provides a strong guarantee. Pair it with short transaction scopes, proper error handling, and a retry strategy, and you’ll have a robust foundation for safe concurrent updates in your Node.js application.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.