Transactions
Run atomic multi-document reads and writes through a transaction-scoped, hook-aware repository.
Transactions ensure atomic operations across multiple documents. Use them when consistency is critical (e.g., transferring balances, inventory management): either every write in the callback commits together, or none of them do.
Running a transaction
Section titled “Running a transaction”Call runInTransaction on a repository. The callback receives two arguments:
-
tx— the underlying Firestore transaction handle, passed to each transaction write helper. -
repo— a transaction-scoped repository. Prefer its*InTransactionhelpers for reads and writes inside the callback so thatbefore*hooks and validation still run, and so reads stay inside the transaction (and inside anyreadTimesnapshot).Non-transactional reads on a full repo (
getById,getAll,query(), …) perform I/O outside the transaction — and outsidereadTime. Non-transactional writes (create(),update(), …) do the same when no read-capable write interceptor is registered. WhenreadOnly: true, the callbackrepois narrowed toReadOnlyTransactionalRepositoryso those methods are absent from the type; a read-write callback still receives the full repository and relies on you not calling them.Prefer
*InTransactionhelpers sobefore*hooks and validation still run. Rawtx.set/tx.update/tx.deletebypass repository validation and hooks entirely.
Transaction hook context and retries
Section titled “Transaction hook context and retries”before* hooks fired from *InTransaction receive HookContext with
execution: 'transaction', retryable: true, and an attempt field:
- Inside
runInTransaction,attemptis a 1-based count of how many times the ORM wrapper has entered the Admin SDK callback for this logical call (contention may re-enter). - When you call
*InTransactionwith a caller-managed rawdb.runTransactionhandle,attemptisnull— the ORM cannot observe the outer callback. attemptis diagnostic only. Do not use it as an idempotency or deduplication key.
after* hooks do not run inside a transaction (the write is not committed until the callback
returns). For non-durable side effects, return data from the callback and run them after success —
or use a durable outbox when available (#80).
Calling a normal create() / update() on the transaction-scoped repo is still a direct
write (outside the transaction) when that repository has no read-capable write interceptor; its
hooks report execution: 'direct'. If a read-capable interceptor is registered, that same plain
write throws instead of opening a second transaction on this Firestore instance — use the
*InTransaction helpers to join the callback you are already in. See
the nested-write caution.
await accountRepo.runInTransaction(async (tx, repo) => { const from = await repo.getInTransaction(tx, 'account-1'); const to = await repo.getInTransaction(tx, 'account-2');
if (!from || from.balance < 100) { throw new Error('Insufficient funds'); }
await repo.updateInTransaction(tx, from.id, { balance: from.balance - 100, });
await repo.updateInTransaction(tx, to.id, { balance: to.balance + 100, });});The value returned from the callback becomes the resolved value of runInTransaction, which lets
you hand data back to the surrounding code (see
post-transaction side effects).
Transaction options
Section titled “Transaction options”runInTransaction accepts an optional second argument that is forwarded verbatim to the Admin SDK’s
db.runTransaction(fn, options):
| Options shape | Callback repo type |
Notes |
|---|---|---|
omitted / { maxAttempts?: number; readOnly?: false } |
full FirestoreRepository |
Default retries (SDK default is 5). maxAttempts must be an integer ≥ 1 — the SDK validates this client-side. |
{ readOnly: true; readTime?: Timestamp } |
ReadOnlyTransactionalRepository |
No document locks; not retried. Optional readTime for a consistent / PITR snapshot. |
// Cap contention retriesawait counterRepo.runInTransaction( async (tx, repo) => { const counter = await repo.getInTransaction(tx, 'global-counter'); await repo.updateInTransaction(tx, 'global-counter', { value: (counter?.value || 0) + 1, }); }, { maxAttempts: 3 },);Options-object typing. Prefer an inline literal (or as const /
satisfies FirebaseFirestore.ReadOnlyTransactionOptions). Two shapes that do not match the
overloads (TS2769):
const opts = { readOnly: true }—readOnlywidens toboolean.- a variable typed as the SDK’s
ReadOnlyTransactionOptions | ReadWriteTransactionOptionsunion (includingdeclared parameters, helper return values, and ternary-built options). Narrow before the call, or pass a single-constituent literal /satisfiesvalue.
A const annotated as the union with an initializer can be control-flow-narrowed to that
initializer (so const opts: RO | RW = { readOnly: true } may appear to work) — that is not the
union being accepted.
Read-only transactions
Section titled “Read-only transactions”Pass { readOnly: true } when you need a consistent snapshot without taking locks (and without
retries). The callback repo is ReadOnlyTransactionalRepository — only the read-safe member set:
getInTransaction(tx, id)— transaction-scoped read (lock-free in this mode)getManyInTransaction(tx, ids, options?)— batched transaction-scoped read (lock-free in this mode)fromSnapshot(snapshot)— map atx.get(query)/ trigger snapshot into the read modelvalidate/id/newId/getCollectionPath— pure helpersreadSchema/schemas— schema accessors
Write helpers and non-transactional reads are absent from the type. At runtime the SDK still
rejects a write attempted through the raw tx handle with a plain Error whose message matches
Firestore read-only transactions cannot execute writes. (no code).
const snapshot = await accountRepo.runInTransaction( async (tx, repo) => repo.getInTransaction(tx, 'account-1'), { readOnly: true },);PITR reads
Section titled “PITR reads”Point-in-time reads go through a read-only transaction with readTime. Prefer the convenience:
const historical = await accountRepo.runReadOnlyAt(readTime, async (tx, repo) => { return repo.getInTransaction(tx, 'account-1');});Equivalent options form: runInTransaction(fn, { readOnly: true, readTime }).
readTime window. Without PITR retention enabled on the database, Firestore accepts a
readTime within about the last 60 seconds. With
PITR enabled, you can read within the
configured retention window (minute granularity). Enabling PITR is a control-plane concern and stays
out of the ORM.
Emulator note. The Firestore emulator accepts a readTime well past the 60s window without
error, where production rejects it absent PITR retention. Local success is not proof the
production call will succeed. Time-travel itself is honored on the emulator (including through
tx.get(query)).
Query-shaped PITR (escape hatch)
Section titled “Query-shaped PITR (escape hatch)”There is no ORM query-in-transaction API yet. For a filtered PITR read, use the Admin SDK query on
tx and map with fromSnapshot (available on the read-only callback repo):
await userRepo.runReadOnlyAt(readTime, async (tx, repo) => { const snap = await tx.get( db.collection(repo.getCollectionPath()).where('status', '==', 'active'), ); return snap.docs.map(d => repo.fromSnapshot(d));});Transaction write helpers
Section titled “Transaction write helpers”All reads and writes inside the callback go through the transaction-scoped repo and take the tx
handle as their first argument:
| Method | Behavior |
|---|---|
getInTransaction(tx, id) |
Reads a document inside the transaction; returns the document (with id) or null if it is absent. Takes a lock in a read-write transaction; lock-free when readOnly: true. |
createInTransaction(tx, data) |
Creates a document with an auto-generated Firestore id |
createWithIdInTransaction(tx, id, data) |
Create-only under a caller-supplied id; a collision raises ConflictError |
updateInTransaction(tx, id, data, options?) |
Updates the document identified by id; options are { merge?, lastUpdateTime? } |
patchInTransaction(tx, id, data, options?) |
Merge-patches the document identified by id (always merges); options are { lastUpdateTime? } |
deleteInTransaction(tx, id, options?) |
Deletes the document identified by id; options are { lastUpdateTime? } |
Notes:
- Firestore requires that all reads happen before any writes within a transaction. Do your
getInTransactionreads first, then perform writes. idis always stripped from write payloads. The document id comes from the auto-generated Firestore id forcreateInTransaction, and from theidargument forcreateWithIdInTransaction,updateInTransaction,patchInTransaction, anddeleteInTransaction.patchInTransactionalways merges. Unlike the non-transactionpatch, it has noreturnDocoption (a transaction cannot read a document back after writing it); it does accept{ lastUpdateTime? }for optimistic concurrency.- A failed
lastUpdateTimeprecondition (or a create-only collision) does not trigger a transaction retry — Firestore retries on contention, not on a rejected precondition. The callback runs once and the whole transaction fails withPreconditionFailedError/ConflictError. Inside a read-write transaction the transaction’s own lock is usually the better tool; a precondition is for a token read outside the transaction. See Conditional writes. getByIdWithUpdateTimeis deliberately absent from the transaction helpers (and fromReadOnlyTransactionalRepository): it performs non-transactional I/O and would bypass both the transaction and anyreadTime. PlaingetManyis absent for the same reason — usegetManyInTransactioninside the callback instead.- Write helpers are unavailable on the typed surface of a read-only /
runReadOnlyAtcallback.
Hooks inside transactions
Section titled “Hooks inside transactions”Hooks fire inside a transaction only when writes go through the transaction-scoped repo passed
into the callback. See Lifecycle hooks for the
full event list.
No after* hooks on transaction write helpers
Section titled “No after* hooks on transaction write helpers”createInTransaction, updateInTransaction, patchInTransaction, and deleteInTransaction run
their before* hooks (before validation and the write) but skip the corresponding after* hooks by
design, so side effects stay outside the atomic transaction commit.
// WORKS - beforeUpdate runs before the transaction commitsorderRepo.on('beforeUpdate', data => { if (data.quantity < 0) { throw new Error('Negative quantity not allowed'); }});
// DOES NOT WORK - afterUpdate won't run in a transactionorderRepo.on('afterUpdate', async ({ id }) => { await sendEmailByUserId(id); // This will NOT execute});Hooks registered on the repository apply when you use the transaction-scoped repo from
runInTransaction; a before* hook that throws aborts the transaction before it commits, which is
what makes it a good place for validation and invariant checks.
Solution for post-transaction side effects
Section titled “Solution for post-transaction side effects”Because after* hooks do not run inside a transaction, perform side effects after
runInTransaction resolves. Return whatever you need from the callback and act on it once the
commit has succeeded:
const result = await accountRepo.runInTransaction(async (tx, repo) => { // ... transaction logic return { from, to };});
// Run side effects AFTER the transaction succeedsawait auditLog.record('transfer_completed', result);await sendEmail(result.from.email);This guarantees the side effects only run when the transaction actually committed — if the
transaction throws or is aborted, runInTransaction rejects and the side-effect code never runs.