Chapter 09 · Transactions, Batched Writes, Atomic Field Operations, Retries, and Contention
Batched Writes for Atomic Multi-Document Changes Without Reads and Operation Limits
Use Firestore batched writes for atomic multi-document changes that do not depend on fresh reads, with correct limits, Rules validation and failure evidence.
Learning outcomes
Distinguish a write batch from a transaction by asking whether the final write set depends on fresh database reads.
Build an all-or-nothing AtlasMart order/audit projection with deterministic failure injection.
Apply the 500-write JavaScript WriteBatch limit, 10 MiB request limit, index amplification and Security Rules access-call limits correctly.
Choose BulkWriter/parallel server writes for bulk ingestion rather than stretching atomic batches beyond their business boundary.
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: publish one accepted order into several documents
After a trusted checkout service has already decided that order
ord-9001 is valid, it must create the immutable
order snapshot, add an audit record, and update a
customer-facing status document together. None of those values
needs a fresh Firestore read at commit time. If one write fails,
exposing only part of the projection would confuse downstream
screens. A batched write is the smaller, more
appropriate atomic primitive.
| Question | Transaction | Write batch |
|---|---|---|
| Does the write depend on current database values? | Yes | No |
| Reads inside atomic unit? | Yes, before writes | No |
| Automatic retry due to read conflicts? | Yes, finite retries | No transaction callback/retry loop |
| Atomicity of staged writes? | All-or-nothing | All-or-nothing |
| Client offline support? | Transaction fails offline | Supported client batches can be queued offline |
2. Build the batch from an already-made decision
import { writeBatch, doc, serverTimestamp } from "firebase/firestore";const orderId = "ord-9001";const batch = writeBatch(db);batch.set(doc(db,"orders",orderId), { customerId: user.uid, status: "accepted", totalCents: 12900, schemaVersion: 2, createdAt: serverTimestamp()});batch.set(doc(db,"orderStatus",orderId), { customerId: user.uid, status: "accepted", updatedAt: serverTimestamp()});batch.set(doc(db,"auditEvents",`order-${orderId}`), { kind: "ORDER_ACCEPTED", orderId, actor: user.uid, createdAt: serverTimestamp()});await batch.commit();
The important property is that the three writes share one atomic commit. A reader does not observe a committed state where only one or two of the writes succeeded. The batch does not validate inventory by itself, because it has no read phase.
3. Controlled failure: one invalid update aborts the batch
const batch = writeBatch(db);batch.set(doc(db,"auditEvents","batch-proof"), { kind:"BATCH_PROOF" });// update() requires the target document to exist.batch.update(doc(db,"definitelyMissing","x"), { value: 1 });try { await batch.commit(); throw new Error("unexpected-success");} catch (error) { console.log("expected batch failure", error.code);}// Verify auditEvents/batch-proof was NOT committed either.
This is the evidence to collect: the expected error plus the
absence of auditEvents/batch-proof. Do not weaken
the lesson into “batch is atomic” without checking the database
state after the injected failure.
4. Operation and validation limits are part of the design
The current JavaScript WriteBatch API allows at
most 500 writes in one batch. Firestore also imposes a 10 MiB
API request limit. Large documents and their index-entry changes
contribute to request/work cost, so “500 small writes” and “500
index-heavy large writes” are not equivalent operationally. A
batch containing hundreds of documents may be legal yet still be
a poor latency choice.
For mobile/web writes evaluated by Security Rules, atomic
multi-document requests also have document-access-call limits:
up to 20 total get()/exists()/getAfter()
calls for the multi-document request, while the per-operation
limit still applies. This matters when Rules prove
cross-document invariants.
rules_version = '2';service cloud.firestore { match /databases/{database}/documents { function signedIn() { return request.auth != null; } match /orders/{orderId} { allow create: if signedIn() && request.resource.data.customerId == request.auth.uid && getAfter(/databases/$(database)/documents/orderStatus/$(orderId)).data.status == "accepted"; } match /orderStatus/{orderId} { allow create: if signedIn() && request.resource.data.customerId == request.auth.uid; } }}
Do not overuse getAfter(): every rule-time document
access consumes the relevant limits and couples authorization to
write shape. Trusted Admin/server SDKs bypass Security Rules and
instead rely on IAM/application authorization, so this rule is
specifically a direct-client control.
5. Wrong approach: use a batch to decrement “whatever stock is there”
A batch can send increment(-1), but that operation
does not prove stock was positive. If “never oversell” is the
invariant, the service must read and condition the
decision—usually with a transaction or a reservation model.
Atomicity is not the same as validity.
If the write set is known before touching Firestore, a batch is a good candidate. If the write values or permission to write depend on current Firestore state, use a transaction, precondition, or remodel the workflow.
6. Bulk work is a different problem
Do not turn every import, backfill or migration into one giant atomic batch. The Firestore guidance recommends server client libraries and parallelized writes for bulk entry. Node server environments also offer BulkWriter for high-volume independent writes. Bulk throughput intentionally gives up “all 50,000 documents commit together” because that is rarely the real business requirement.
| Workload | Preferred starting primitive | Reason |
|---|---|---|
| Create order + status + audit together | Batched write | Known write set; atomic visibility matters |
| Decrement stock if stock > 0 | Transaction | Decision depends on fresh read |
| Increment analytics counter | Atomic transform; shard if hot | No read needed; spread contention when required |
| Backfill 100k independent documents | Server parallel/BulkWriter | Atomic mega-batch is unnecessary and brittle |
7. 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
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 orderId="ord-9001";const batch=db.batch();batch.set(db.doc(`orders/${orderId}`), {customerId:"u-1",status:"accepted",schemaVersion:2,createdAt:FieldValue.serverTimestamp()});batch.set(db.doc(`orderStatus/${orderId}`), {customerId:"u-1",status:"accepted",updatedAt:FieldValue.serverTimestamp()});batch.set(db.doc(`auditEvents/order-${orderId}`), {kind:"ORDER_ACCEPTED",orderId,createdAt:FieldValue.serverTimestamp()});await batch.commit();const refs=[`orders/${orderId}`,`orderStatus/${orderId}`,`auditEvents/order-${orderId}`].map(p=>db.doc(p));console.log((await db.getAll(...refs)).map(s=>({path:s.ref.path,exists:s.exists,data:s.data()})));
Run a success case and the deliberate missing-document failure case. Verify exact document presence/absence after each run. For a browser client variant, disable network before committing a batch and observe that the client queues the batch; this behavior is different from transactions, which fail offline.
Production judgment
Batch only the documents that share a real atomic business boundary. Measure batch size, index fan-out and latency instead of maximizing the write count. Keep direct-client Rules proofs small enough to fit access-call limits. Use trusted server authorization rather than assuming Admin writes see Rules. Lesson 3 narrows the atomic boundary further: many conflicts disappear when the server can apply a field transform without a read-modify-write cycle.
Knowledge check
- What key question separates a batch from a transaction?
- If the third write in a batch fails, are the first two committed?
- Can a batch by itself guarantee stock never becomes negative?
- What is the current JavaScript WriteBatch maximum?
- Should a 100,000-document backfill be modeled as one atomic batch?
Review the answers
1. Whether the write decision depends on fresh values read inside the same atomic operation.
2. No. A committed write batch is all-or-nothing.
3. No. Atomicity does not validate a read-dependent invariant; use a transaction or conditional design.
4. 500 writes, subject also to request size and practical latency/index considerations.
5. No. Use a server bulk/parallel write strategy unless there is an actual atomic business requirement.
Summary and next step
Batches atomically publish a known write set without transaction retries. Next, we reduce conflict surfaces further with server-side transforms and explicit compare/precondition patterns.
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.