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.

Intermediate125–150 minutesAtomic batches · limitsFirebase JS 12.19.0 · Admin 14.4.0 · CLI 15.30.0Last reviewed: September 2026

Learning outcomes

01

Distinguish a write batch from a transaction by asking whether the final write set depends on fresh database reads.

02

Build an all-or-nothing AtlasMart order/audit projection with deterministic failure injection.

03

Apply the 500-write JavaScript WriteBatch limit, 10 MiB request limit, index amplification and Security Rules access-call limits correctly.

04

Choose BulkWriter/parallel server writes for bulk ingestion rather than stretching atomic batches beyond their business boundary.

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: 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

Web modular batch
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

failure injection
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 pattern with getAfter()
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.

Decision rule

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

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
Admin/server atomic batch
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

  1. What key question separates a batch from a transaction?
  2. If the third write in a batch fails, are the first two committed?
  3. Can a batch by itself guarantee stock never becomes negative?
  4. What is the current JavaScript WriteBatch maximum?
  5. 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

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.