Web development
ACID transactions in practice
An ACID transaction is a boundary: every write lands or none does. A commit costs 0.330 ms with synchronous FULL and 0.018 ms with NORMAL.
Key points
- Across 200 single-row transactions, a commit costs 0.330 ms in SQLite with synchronous FULL and 0.018 ms with NORMAL.
- A CHECK constraint violated mid-transaction leaves both balances at 100: rollback undoes the first write.
- NORMAL and OFF in SQLite WAL mode lower commit cost and weaken durability, not atomicity.
- A transaction must not contain network calls: the lock stays open for its whole duration.
An ACID transaction is a boundary: every write lands or none does. Measured over 200 single-row transactions in SQLite 3.51.3, a commit costs 0.330 ms with synchronous FULL and 0.018 ms with NORMAL: the gap is one synchronisation call to disk.
What ACID promises, in the order it breaks
Atomicity means the group of writes is indivisible. Consistency means constraints hold at the end: a non-negative balance, an existing foreign key. Isolation means another connection never sees an intermediate state. Durability means a commit survives a crash. The first three break through code mistakes, the fourth breaks through a disk setting.
The rollback, measured
With two accounts of 100, a debit of 60 on the first and one of 150 on the second, the second UPDATE violates the CHECK and the transaction fails. After ROLLBACK both balances are still 100. The first write is gone: that behaviour is what makes a transaction useful, and it does not depend on disk speed.
The price of a commit
With synchronous FULL the commit writes the WAL and asks the disk to confirm: 0.330 ms per transaction on this machine. With NORMAL the confirmation waits for a checkpoint and the cost drops to 0.018 ms. The SQLite documentation, read on 1 November 2026, warns that in WAL mode NORMAL can lose recent commits after a power loss, while OFF is not advised even when only the application crashes.
node -e "const {DatabaseSync}=require('node:sqlite');const db=new DatabaseSync('app.db');db.exec('PRAGMA journal_mode=WAL');db.exec('BEGIN IMMEDIATE');try{db.exec('UPDATE accounts SET balance=balance-60 WHERE id=1');db.exec('UPDATE accounts SET balance=balance-150 WHERE id=2');db.exec('COMMIT')}catch(e){db.exec('ROLLBACK');console.log('rolled back:',e.message)}"
Short transactions, not long ones
A transaction should hold only the work that depends on the database. If an HTTP call, a file upload or a queue wait sits inside it, the lock stays open for the whole wait and other writes queue behind it. The practical rule is: read, decide, write, close. When a decision needs an external answer, get it first and retry if the state changed, instead of holding the transaction open.
Where the boundary ends
In PostgreSQL the default level is READ COMMITTED: each statement sees commits made before it started, so two reads inside one transaction can return different values. SQLite, with one writer at a time, offers simpler isolation. If the code assumes repeatable reads, the level must be declared: it is a programmer's choice, not a database detail.
The limit: durability is not atomicity
Lowering synchronous makes commits fast without breaking atomicity: rollback still works, but a crash can erase the last committed transactions. For a ledger that is unacceptable; for a preferences cache it does not matter. And no setting protects you from a disk that reports a write it never made: durability ends where the hardware ends.
Where to start
Frequently asked questions
What is the difference between a transaction and a batch?
A batch groups writes for speed, a transaction defines what is atomic. A batch inside a transaction can be large and still be one commit: if one row violates a constraint, everything rolls back.
Why is a commit slower with synchronous FULL?
Because it waits for the disk to confirm. On this machine it costs 0.330 ms against 0.018 ms with NORMAL, and the gap is the fsync. You pay it once per transaction, not once per row.
Can I run synchronous OFF in production?
Not for data you cannot rebuild. OFF lowers commit cost, but the SQLite documentation advises against it even when only the application crashes, not just on power loss: money and bookings stay on FULL.
What does a long transaction block?
It blocks other writes to the rows it touched and prevents space from being reclaimed. With a network call inside it, the lock lasts as long as the network, so close the transaction before any external wait.
Do I need a transaction for a single row?
If the operation is one statement, the database already runs it atomically. A transaction is needed when several statements must land together, such as a debit and a credit.