Chapter 09 · Transactions, Batched Writes, Atomic Field Operations, Retries, and Contention
Transactions Read-Then-Write Atomically, Optimistic / Pessimistic Differences by SDK / Edition Context, and Retry Semantics
Protect AtlasMart invariants with Firestore transactions while understanding serializable isolation, retries, client/server concurrency differences and contention evidence.
Learning outcomes
Choose a transaction only when an AtlasMart decision depends on values read inside the same atomic boundary.
Explain serializable isolation, read-before-write ordering, automatic retry, and why transaction callbacks must be free of irreversible side effects.
Distinguish mobile/web optimistic transaction emulation from server-library behavior under Standard and Enterprise database concurrency modes.
Instrument attempts, conflicts, final inventory state and tail latency without pretending emulator timing is production timing.
Use the Emulator Suite, a Firebase demo project, or an isolated test project for destructive, security-sensitive, billing-sensitive, migration, backup/restore, or write-heavy exercises unless the lesson explicitly marks managed verification as required. Treat shown output as expected evidence unless it is explicitly identified as captured output, and re-check current Firebase/Google Cloud edition, mode, quota, pricing, and security documentation before production execution.
AtlasMart continues the same environment used in Chapters
01–08: project ID demo-atlasmart-firestore,
Standard edition / Native mode /
(default) database for the mandatory lab,
Firestore emulator 127.0.0.1:8080, Authentication
emulator 127.0.0.1:9099, Emulator UI
127.0.0.1:4000, Firebase CLI
15.30.0, Firebase JavaScript SDK
12.19.0, Firebase Admin Node SDK
14.4.0 (bundling
@google-cloud/firestore 9.1.0), and Node.js 22+.
The mandatory exercises use only local/demo resources.
Production location, IAM credentials, billing, real lock
scheduling, multi-region latency and tail-throughput behavior
are not inferred from emulator results.
The Emulator Suite is appropriate for deterministic
correctness tests, Security Rules tests, retry-safe
application logic and controlled conflicting writes, but it is
not a production contention benchmark. Record your own attempt
counts and latencies. Do not publish emulator p95/p99 as a
Firestore service SLO. For Enterprise comparisons, the
mandatory path is a deterministic analysis: Standard server
libraries default to pessimistic concurrency, Enterprise
server libraries default to optimistic concurrency, while
mobile/web transactions emulate optimistic concurrency
regardless of the database setting. Pipeline DML is not a
replacement for Core transactions: current Pipeline
update/delete stages execute outside
transactions and can partially succeed across documents.
1. The AtlasMart problem: two buyers, one remaining camera
AtlasMart has catalogItems/p-1001 with
stock=1. Two checkout workers read that value at
nearly the same time. A naïve read followed by a separate update
lets both workers conclude that the unit is available. The
business invariant is not “write a new number”; it is “commit
the decrement only if the stock observed by this decision is
still the stock being committed against.” That is precisely the
shape of a transaction.
A transaction is an atomic read/write unit: all reads happen before writes, and the writes are committed only if the transaction can preserve a serializable outcome. Serializable isolation means the committed result is equivalent to some serial ordering of the competing transactions. A contention conflict occurs when concurrent operations compete over the same documents. A precondition is a condition such as “the document still has this update time” that must be true for a write to commit.
| Mechanism | Reads current state? | Atomic across documents? | Automatic retry? | Offline client behavior |
|---|---|---|---|---|
| Transaction | Yes; reads must precede writes | Yes, for its transaction set | Yes, on qualifying conflicts, for a finite number of attempts | Client transactions fail offline |
| Batched write | No read phase | Yes, for its write set | No transaction-style conflict retry | Mobile/web batches can queue offline |
| Atomic transform | No application read required | Atomic on the target write | SDK/network retry behavior differs; transform itself is server atomic | Can be queued by supported client SDKs |
| Pipeline DML | Pipeline can select then update/delete | No multi-document transaction guarantee | Not the Core transaction contract | Enterprise-only; not the offline/realtime Core path |
2. Transaction execution model: read, validate, stage, commit—or retry
Inside a Core transaction callback, Firestore reads documents first. Your code calculates the new state and stages writes. If a document read by the transaction changed in a way that conflicts with the commit, the SDK/backend can rerun the callback. The callback therefore describes a recomputable decision, not a one-shot procedure.
process.env.FIRESTORE_EMULATOR_HOST = "127.0.0.1:8080";process.env.GCLOUD_PROJECT = "demo-atlasmart-firestore";import { initializeApp } from "firebase-admin/app";import { getFirestore, FieldValue } from "firebase-admin/firestore";initializeApp({ projectId: "demo-atlasmart-firestore" });const db = getFirestore();const itemRef = db.doc("catalogItems/p-1001");let callbackAttempts = 0;async function reserveOne(workerId) { return db.runTransaction(async tx => { callbackAttempts += 1; const snap = await tx.get(itemRef); if (!snap.exists) throw new Error("missing-product"); const stock = snap.get("stock"); if (!Number.isInteger(stock) || stock < 1) { return { workerId, reserved: false, observedStock: stock }; } tx.update(itemRef, { stock: stock - 1, updatedAt: FieldValue.serverTimestamp() }); return { workerId, reserved: true, observedStock: stock }; });}await itemRef.set({ sku: "P-1001", name: "Trail Camera", stock: 1, schemaVersion: 2 });const results = await Promise.allSettled([reserveOne("A"), reserveOne("B")]);console.log({ callbackAttempts, results, final: (await itemRef.get()).data() });
The observable correctness condition is not a specific attempt
count. It is that at most one worker returns a committed
reservation and the final stock does not become negative. The
callback may run more times than the number of API calls your
application made. That is why logging should separate
requestId, workerId and
attempt.
3. Concurrency mode is not one global behavior
| Path | Concurrency behavior that matters | Default by edition |
|---|---|---|
| Web/mobile Core transaction | Optimistic emulation using document-version preconditions; independent of database concurrency mode | Same client behavior on Standard and Enterprise |
| Server client library transaction | Uses the database-level concurrency mode | Standard defaults to PESSIMISTIC; Enterprise defaults to OPTIMISTIC |
| Firestore with MongoDB compatibility | Separate compatibility transaction semantics; do not transplant Native SDK assumptions | Enterprise mode; validate against compatibility docs |
| Enterprise Pipeline DML | Not supported inside a Core transaction; each document is changed independently and partial success is possible | Enterprise-only, current DML is Preview |
Under pessimistic server transactions, reads can acquire locks and competing writes may wait. Under optimistic server transactions, commit succeeds only if the versions read are still valid. Both aim to preserve transaction correctness, but contention latency and failure shape can differ. Mobile/web SDKs intentionally avoid database locks because browsers and mobile devices can have high and unreliable network latency.
# Optional real-project inspection only; not required for the local lab.gcloud firestore databases describe --project=YOUR_PROJECT_ID --database='(default)'# Changing concurrency mode is a production configuration action; do not run casually.# gcloud firestore databases update --project=... --database=... --concurrency-mode=PESSIMISTIC
4. Wrong approach: charge a card or send mail inside the callback
Suppose the callback calls a payment gateway and then stages the Firestore write. If Firestore retries the callback, the card can be charged twice even though the database commits once. The same defect appears with email, SMS, webhook publication, file deletion, or mutation of application state outside the transaction.
Keep the transaction callback deterministic and side-effect
free. Commit a durable database fact such as
orders/{id}.paymentState="AUTHORIZED_PENDING_CAPTURE"
or an outbox/event document. Perform the external side effect
after the transaction, using an idempotency key and a
retry-aware workflow. Chapter 10 expands this into multi-step
business consistency.
5. Limits and non-guarantees that change design
Current Firestore limits include a 10 MiB maximum API request size, a 270-second transaction limit with 60-second idle expiration, and up to 500 field transformations on a single document in a Commit/transaction. The JavaScript API documents up to 500 writes in a transaction and five transaction attempts by default. Do not assume every SDK exposes the same retry option or default. Keep the transaction read/write set small and colocated in the model when possible.
Transactions are not an offline reservation mechanism: mobile/web transactions fail while offline. A cached value is not sufficient authority for an inventory or coupon invariant. Similarly, a transaction protects only the documents it actually reads/writes; it does not make an external payment provider or message bus atomic with Firestore.
6. Reproducible AtlasMart lab
{ "name": "atlasmart-firestore-ch09", "private": true, "type": "module", "engines": { "node": ">=22" }, "dependencies": { "firebase-admin": "14.4.0" }, "devDependencies": { "firebase-tools": "15.30.0" }}
{ "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json" }, "emulators": { "firestore": { "port": 8080 }, "auth": { "port": 9099 }, "ui": { "enabled": true, "port": 4000 } }}
rules_version = '2';service cloud.firestore { match /databases/{database}/documents { match /catalogItems/{productId} { allow read: if true; allow write: if false; } match /profiles/{uid} { allow read, write: if request.auth != null && request.auth.uid == uid; } match /orders/{orderId} { allow read: if request.auth != null && resource.data.customerId == request.auth.uid; allow write: if false; } match /{document=**} { allow read, write: if false; } }}
mkdir atlasmart-firestore-ch09 && cd atlasmart-firestore-ch09npm init -ynpm install firebase-admin@14.4.0npm install --save-dev firebase-tools@15.30.0# Save firebase.json, firestore.rules and firestore.indexes.json from this lesson.printf '{"indexes":[],"fieldOverrides":[]}' > firestore.indexes.jsonnpx firebase-tools@15.30.0 emulators:start --project demo-atlasmart-firestore --only firestore,auth
Seed catalogItems/p-1001 with stock 5, then run 12
concurrent workers, each reserving one unit through the
transaction function. Repeat with stock 3. Capture: number of
API-level requests, callback attempts, successful reservations,
ABORTED/other errors, final stock, and per-request
duration. The correctness assertion is
successfulReservations <= initialStock and
finalStock = initialStock - successfulReservations.
import { performance } from "node:perf_hooks";const initialStock = 3;await itemRef.set({ sku:"P-1001", stock: initialStock, schemaVersion:2 });const samples=[];const jobs=Array.from({length:12},(_,i)=>(async()=>{ const started=performance.now(); try { const value=await reserveOne(`worker-${i}`); samples.push({ok:true, ms:performance.now()-started, ...value}); } catch (error) { samples.push({ok:false, ms:performance.now()-started, code:error.code ?? "unknown"}); }})());await Promise.all(jobs);const final=(await itemRef.get()).get("stock");const successful=samples.filter(x=>x.ok && x.reserved).length;console.log({initialStock, successful, final, callbackAttempts, samples});if (successful > initialStock || final !== initialStock-successful) process.exitCode=1;
The code records your machine/emulator timing. Compute p50/p95/p99 only from the values you actually collected, with fixture size, concurrency, warm-up, emulator version and environment disclosed. Production server transactions can exhibit different lock scheduling, network and backend contention.
Production judgment
Use a transaction when correctness depends on one or more values
that must be read and validated atomically. Reduce the
transaction set before trying to “tune” retries. If many
requests fight over the same document, the data model—not the
retry count—is often the real bottleneck. Keep external side
effects outside retryable callbacks, make the surrounding
workflow idempotent, and instrument attempt counts and
ABORTED errors. Lesson 2 focuses on the simpler
atomic primitive when no read-dependent invariant exists:
batched writes.
Knowledge check
- Why can the transaction callback run more times than the number of user requests?
- Does setting a Standard database to pessimistic make browser transactions hold locks?
- What does serializable isolation guarantee?
- Why is a payment call unsafe inside the transaction callback?
- Can Enterprise Pipeline update/delete DML replace a multi-document transaction?
Review the answers
1. A conflicting change to a document read by the transaction can cause Firestore to rerun the callback before a successful commit or final failure.
2. No. Mobile/web SDKs emulate optimistic concurrency regardless of the database concurrency mode.
3. Committed transactions are equivalent to a serial ordering by commit time, protecting the atomic read/write decision from incompatible concurrent outcomes.
4. The callback can retry, so an irreversible external side effect could happen multiple times.
5. No. Current Pipeline DML runs outside transactions and can partially succeed across documents.
Summary and next step
Transactions protect read-dependent invariants, but retry semantics make callback purity and contention-aware modeling essential. Next, AtlasMart uses batched writes for atomic multi-document changes that do not need a read phase.
Authoritative references
- Transactions and batched writes — atomicity, retry rules, offline boundary, batch behavior and failure conditions.
- Transaction serializability and isolation — Standard/Enterprise concurrency defaults, mobile/web optimistic emulation, server locking and contention errors.
- Usage and limits — transaction time/request/field-transform and Security Rules access-call limits.
- Distributed counters — shard-based write distribution and read/cost tradeoffs.
- Firestore best practices — hotspot, transaction size and scale guidance.
- Enterprise Native Core/Pipeline overview — operation-family boundaries.
- Pipeline DML — Preview update/delete semantics and non-transactional partial-success boundary.
- Firebase JavaScript SDK release notes — 12.19.0 baseline.
-
Firebase Admin Node.js release notes
— 14.4.0,
@google-cloud/firestore9.1.0 and Node.js 22+ baseline. - Firebase CLI release notes — 15.30.0 baseline.