Choosing the Right Sequelize Transaction Pattern for Atomic Multi‑Statement Writes
Atomic multi‑row writes in Sequelize require a transaction. This guide compares managed callbacks, unmanaged transactions, and CLS namespaces, explains trade‑offs, and shows a concrete transfer‑funds implementation with validation tests.
25 Sept 2025, 11:15 UTC

Problem & Decision
When moving funds between two accounts you must guarantee that either both balances update or none do. In Sequelize this means wrapping the read‑modify‑write sequence in a single database transaction. The question is: which transaction API should you use in a Node.js service that talks to PostgreSQL, MySQL, MSSQL or SQLite?
Three supported options exist in Sequelize v6:
- Managed callback transactions –
sequelize.transaction(async (t) => { … }) - Unmanaged transactions –
const t = await sequelize.transaction(); … await t.commit(); - Continuation‑Local Storage (CLS) namespaces –
cls-hookedorasync_hooksto auto‑injecttinto every query.
Choosing the right pattern reduces the risk of partial writes, leaks, or accidental cross‑request contamination.
Compact Comparison
| Option | Supported in v6? | When to Use | Main Risk |
|---|---|---|---|
| Managed callback | ✔ | One async unit of work that finishes before the next event loop tick. | Must pass { transaction: t } to every query. |
| Unmanaged | ✔ | Transaction spans across multiple async functions or queued jobs. | Leaked or forgotten commit/rollback can exhaust the connection pool. |
| CLS namespace | ✔ (requires cls-hooked or async_hooks) | Deep call stacks where passing t becomes cumbersome. | Implicit boundaries can cause unrelated code to run inside the transaction; hard to detect leaks. |
Trade‑Offs
- Managed callbacks give the smallest surface for failure: if the async function throws, Sequelize rolls back automatically; if it returns, Sequelize commits. The only extra code is the
{ transaction: t }option on each query. - Unmanaged transactions provide flexibility when the transaction must survive beyond a single async function (e.g., a background worker). However, the developer must remember to commit or rollback in a
finallyblock, otherwise the pool can run out. - CLS namespaces remove boilerplate but hide the transaction boundary. A retry loop or a scheduled job that accidentally shares the same CLS context will run inside the transaction, potentially causing deadlocks or inconsistent reads.
Concrete Implementation Pattern
Below is a typical pattern for a money‑transfer endpoint that uses a managed callback. Replace Account with your model and adjust the isolation level if needed.
async function transferFunds(fromId, toId, amount) {
// Use a READ COMMITTED isolation level by default; change if your
// dialect requires a stricter level for concurrent transfers.
await sequelize.transaction({ isolationLevel: Sequelize.Transaction.ISOLATION_LEVELS.READ_COMMITTED }, async (t) => {
// 1. Load both rows with a FOR UPDATE lock to prevent race conditions.
const [from, to] = await Promise.all([
Account.findByPk(fromId, { transaction: t, lock: t.LOCK.UPDATE }),
Account.findByPk(toId, { transaction: t, lock: t.LOCK.UPDATE })
]);
if (!from || !to) throw new Error('Account not found');
// 2. Business rule checks.
if (Number(from.balance) < amount) throw new Error('Insufficient funds');
// 3. Apply updates.
await from.update({ balance: Sequelize.literal(`balance - ${amount}`) }, { transaction: t });
await to.update({ balance: Sequelize.literal(`balance + ${amount}`) }, { transaction: t });
});
}
Key points in the snippet:
- All queries receive
{ transaction: t }. - Both rows are locked with
FOR UPDATEvialock: t.LOCK.UPDATE– this is supported by PostgreSQL, MySQL, and MSSQL. SQLite ignores the lock but still uses the transaction. - The
asynccallback ensures that a thrown error triggersROLLBACK, while a successful return triggersCOMMIT.
Validation & Testing
To confirm that your transaction behaves correctly, run a unit test that intentionally throws after the first update. Verify that neither balance changes.
it('rolls back on error', async () => {
// Arrange
await Account.create({ id: 1, balance: 1000 });
await Account.create({ id: 2, balance: 500 });
try {
await sequelize.transaction(async (t) => {
const from = await Account.findByPk(1, { transaction: t });
const to = await Account.findByPk(2, { transaction: t });
await from.update({ balance: 900 }, { transaction: t });
// Force an error before the second update
throw new Error('Simulated failure');
await to.update({ balance: 600 }, { transaction: t });
});
} catch (e) {
// Expected
}
// Assert balances unchanged
const from = await Account.findByPk(1);
const to = await Account.findByPk(2);
expect(from.balance).to.equal(1000);
expect(to.balance).to.equal(500);
});
Run the same test against the production dialect and isolation level. Inspect Sequelize logs or the connection pool metrics to ensure no transaction remains open after the callback resolves.
Version Sensitivity & Limitations
- All examples target Sequelize v6. Verify your
package.jsonmajor version and consult the corresponding documentation before adopting. - Savepoints for nested transactions are only supported where the dialect implements them (PostgreSQL, MySQL, MSSQL). If you need nested logic, test that the inner throw rolls back to the savepoint.
- Row locking syntax (SELECT ... FOR UPDATE) is dialect‑specific. SQLite does not support explicit row locks; it still participates in the transaction but may lock the entire database.
- For monetary values, store cents as integers or use a DECIMAL type. JavaScript
Numberarithmetic can introduce rounding errors. - When using CLS, remember that any code executed within the same async context inherits the transaction. Avoid sharing a CLS namespace across unrelated services or background workers unless you explicitly want that behavior.
Practical Checklist Before Production
- Confirm Sequelize version and transaction API shape.
- Run a rollback test against the target dialect and isolation level.
- Verify that the connection pool returns the connection to the pool after the transaction completes.
- If using CLS, limit the namespace to the request scope and clear it after response.
By default, start with a managed callback transaction. It keeps the code explicit, limits the risk surface, and aligns with Sequelize’s built‑in rollback/commit logic. Use unmanaged or CLS only when the business flow truly demands a transaction that lives beyond a single async function or when you have a complex call stack that would otherwise require repeated transaction: t arguments.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.