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.

Intermediate140–170 minutesTransactions · retries · isolationFirebase JS 12.19.0 · Admin 14.4.0 · CLI 15.30.0Last reviewed: September 2026

Learning outcomes

01

Choose a transaction only when an AtlasMart decision depends on values read inside the same atomic boundary.

02

Explain serializable isolation, read-before-write ordering, automatic retry, and why transaction callbacks must be free of irreversible side effects.

03

Distinguish mobile/web optimistic transaction emulation from server-library behavior under Standard and Enterprise database concurrency modes.

04

Instrument attempts, conflicts, final inventory state and tail latency without pretending emulator timing is production timing.

Execution and safety note

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.

Chapter 09 reproducibility baseline · reviewed 16 September 2026

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.

Evidence boundary

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.

server inventory transaction
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.

inspect a production database concurrency mode
# 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.

Repair pattern

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

package.json
{  "name": "atlasmart-firestore-ch09",  "private": true,  "type": "module",  "engines": { "node": ">=22" },  "dependencies": {    "firebase-admin": "14.4.0"  },  "devDependencies": {    "firebase-tools": "15.30.0"  }}
firebase.json
{  "firestore": {    "rules": "firestore.rules",    "indexes": "firestore.indexes.json"  },  "emulators": {    "firestore": { "port": 8080 },    "auth": { "port": 9099 },    "ui": { "enabled": true, "port": 4000 }  }}
firestore.rules
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; }  }}
local setup
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.

bounded runner
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;
Do not publish fake latency numbers

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

  1. Why can the transaction callback run more times than the number of user requests?
  2. Does setting a Standard database to pessimistic make browser transactions hold locks?
  3. What does serializable isolation guarantee?
  4. Why is a payment call unsafe inside the transaction callback?
  5. 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

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.