Skip to content

Understanding Transaction Boundaries

· 2 min read

Why one user action can require several database writes to succeed or fail together.

On this page

It looks like one button click. Someone splits a dinner bill, taps Save, and the app moves on. On the backend, though, that single action can touch several rows: the expense itself, one share per participant, and the running balance for each member. If some of those writes land and others do not, the group's balances quietly stop adding up.

A transaction is how you make that group of writes behave like a single one.

The failure you are guarding against

Imagine saving an expense as four separate statements:

INSERT INTO expenses (...) VALUES (...);
INSERT INTO shares (...) VALUES (...);   -- for member A
INSERT INTO shares (...) VALUES (...);   -- for member B
UPDATE balances SET amount = amount + 20 WHERE member = 'A';

If the process crashes after the second INSERT, you are left with an expense, one share, and no balance update. Nothing is technically "broken" — every row that exists is valid — but the group's financial state is now wrong, and no single row tells you so.

Wrapping the work

A transaction says: treat these statements as one unit. Either all of them commit, or none of them do.

await db.$transaction(async (tx) => {
  const expense = await tx.expense.create({ data: expenseInput });
  await tx.share.createMany({ data: sharesFor(expense) });
  await tx.balance.updateMany({ data: balanceDeltas(expense) });
});

If any line inside throws, the whole block rolls back. The database returns to the state it was in before the click. The user sees an error instead of a half-saved expense — which is the correct outcome, because a retry is safe.

Choosing the boundary

The interesting decision is not whether to use a transaction but where to draw its edges. Too small, and related writes drift apart. Too large, and you hold locks while doing slow work — network calls, image processing, model requests — and everything else waits behind you.

A useful rule: a transaction should wrap the writes that must agree with each other, and nothing else.

Belongs insideBelongs outside
The expense and its sharesUploading the receipt image
The balance updatesCalling an OCR/model service
Anything that reads a value it also writesSending a notification

Do the slow, external work first, get back plain data, and only then open the transaction to commit it.

Why this ends up being a design question

Once you take boundaries seriously, they stop being a database detail and start shaping the feature. "Edit an expense" is no longer one action — it is remove the old shares, write the new ones, and recompute the affected balances, all at once. Naming that unit is most of the work. The $transaction call is just where you write it down.

That is the shift worth internalizing: transaction boundaries are part of the feature's definition, not something hidden underneath it.