Chapter 09 · Transactions, Batched Writes, Atomic Field Operations, Retries, and Contention

Increment, arrayUnion / arrayRemove, Server Timestamp, Compare / Precondition Patterns, and Conflict Reduction

Reduce conflict surfaces with Firestore atomic field transforms and explicit server preconditions while preserving clear invariant boundaries.

Intermediate125–150 minutesTransforms · preconditionsFirebase JS 12.19.0 · Admin 14.4.0 · CLI 15.30.0Last reviewed: September 2026

Learning outcomes

01

Use increment, arrayUnion/arrayRemove and server timestamps for operations that do not need an application read.

02

Explain why atomic transforms reduce lost-update windows without automatically enforcing business invariants.

03

Apply server-side lastUpdateTime/create preconditions for compare-and-set behavior and know when browser/mobile code should use a transaction instead.

04

Redesign unbounded arrays and high-contention fields instead of treating field transforms as universal concurrency control.

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: stop doing read → modify → write for simple field math

An analytics worker wants to add one view to catalogItems/p-1001.viewCount. If ten workers each read 100, compute 101, and write 101, nine increments are lost. The application never needed the old value for a business decision; it only needed an atomic “add one.” A field transform lets the backend apply that operation at commit time.

Transform/pattern Useful for Does not guarantee
increment(n) Atomic numeric delta on one write Upper/lower bounds such as stock >= 0
arrayUnion(v) Add value if not already represented in array Scalable unbounded membership or high-cardinality set
arrayRemove(v) Remove matching values Ordering/history semantics
serverTimestamp() Backend commit-time timestamp placeholder Cross-system clock/order with external services
lastUpdateTime precondition Compare-and-set against a specific document version Multi-document invariant unless coordinated explicitly

2. Atomic transforms remove one race—but only the race they express

Web transforms
import { doc, updateDoc, increment, arrayUnion, arrayRemove, serverTimestamp } from "firebase/firestore";const ref=doc(db,"profiles",user.uid);await updateDoc(ref, {  loginCount: increment(1),  favoriteSkus: arrayUnion("P-1001"),  updatedAt: serverTimestamp()});await updateDoc(ref, { favoriteSkus: arrayRemove("P-0007") });

These transforms are part of a single document write and do not require your application to read the prior values. That shortens the race window and usually reduces reads. But increment(-1) on stock is not a substitute for “decrement only if stock is positive.” The transform knows arithmetic, not your invariant.

3. Compare-and-set with a server precondition

Trusted Node/server clients expose document-version preconditions. AtlasMart can read a product snapshot, present a review screen to an operator, then update only if the document has not changed since that read. This is useful for explicit compare-and-set workflows that do not need a whole transaction callback.

Node/Admin lastUpdateTime precondition
const ref = db.doc("catalogItems/p-1001");const snap = await ref.get();if (!snap.exists) throw new Error("missing-product");const expected = snap.updateTime;try {  await ref.update(    { moderationState: "approved", moderatedAt: FieldValue.serverTimestamp() },    { lastUpdateTime: expected }  );  console.log("compare-and-set committed");} catch (error) {  console.log("precondition failed or other write error", error.code);}

For create-if-absent on the server, DocumentReference.create() provides an existence precondition. Browser/mobile APIs do not expose the same generic lastUpdateTime write-precondition surface; their transaction implementation uses version preconditions internally. When an untrusted client needs a conditional decision based on current data, use a transaction plus Security Rules rather than inventing a client-only compare-and-set token.

4. Server timestamp is authoritative for the Firestore commit, not for the universe

serverTimestamp() avoids trusting a client clock. It represents a server-generated timestamp resolved when the write commits. That is useful for updatedAt, ordering within Firestore data and audit metadata. It does not prove an external payment happened at exactly the same instant, nor does it make two separate systems one transaction.

Client observation nuance

Before server acknowledgement, latency-compensated client snapshots can show a local estimate/null/previous-value representation depending on SDK read options. Treat the resolved server timestamp after acknowledgement as the authoritative Firestore value.

5. Arrays are convenient, not an unbounded relationship store

arrayUnion and arrayRemove are useful for bounded profile preferences or tags. They are a poor fit for “all order IDs ever placed by this customer” or a million followers. Large arrays increase document size and index-entry fan-out and create a shared hot document. Use subcollections/root collections for growing relationships.

wrong: unbounded shared array
// Anti-pattern for a high-volume order history:await updateDoc(doc(db,"profiles",uid), {  everyOrderIdEver: arrayUnion(newOrderId)});// Better model: orders/{orderId} with customerId, or profiles/{uid}/orders/{orderId}.

6. Conflict-reduction decision table

Need Smallest correct primitive Why
Add one metric without bounds increment No read-modify-write cycle
Add bounded unique-ish preference arrayUnion Server-side array transform
Record backend commit time serverTimestamp Avoid client clock trust
Update only if version unchanged Server precondition / client transaction Explicit compare-and-set
Decrement stock only if stock > 0 Transaction Requires read-dependent validation
Publish three known docs atomically Batch No read required
High-frequency global count Sharded/distributed counter Spread writes across documents

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
transform-vs-read-modify-write.js
const ref=db.doc("metrics/catalog");await ref.set({views:0,labels:[],schemaVersion:1});// Launch independent atomic increments.await Promise.all(Array.from({length:50},()=>ref.update({views:FieldValue.increment(1)})));console.log("atomic increment result", (await ref.get()).get("views"));// Demonstrate version precondition failure deterministically.const before=await ref.get();await ref.update({noise:FieldValue.increment(1)}); // changes updateTimetry {  await ref.update({reviewed:true},{lastUpdateTime:before.updateTime});  throw new Error("unexpected-precondition-success");} catch (e) {  console.log("expected stale precondition", e.code);}

The expected logical evidence is views=50 after fifty successful atomic increments and a rejected stale precondition after the intervening update. Exact error strings can vary by emulator/client version; assert the failure class/state rather than hard-coding prose.

Production judgment

Prefer transforms when the operation itself fully expresses the desired state transition. Prefer preconditions when one document must still be the exact version you inspected. Prefer transactions when correctness depends on fresh values across one or more documents. None of these primitives cures an inherently hot key; Lesson 4 spreads write pressure across shards while preserving idempotency at the application level.

Knowledge check

  1. Why can increment avoid lost updates?
  2. Does increment(-1) prevent negative stock?
  3. What does a lastUpdateTime precondition prove?
  4. Why is arrayUnion unsuitable for unbounded order history?
  5. What does serverTimestamp establish?
Review the answers

1. The arithmetic is applied atomically by Firestore at commit time instead of being computed from a stale application read.

2. No. It does not evaluate the business condition stock > 0; use a transaction or reservation design.

3. The write commits only if the document still has the version/update time that was observed.

4. The array grows one shared document, increasing size/index fan-out and contention; model growing relationships as documents.

5. A Firestore backend commit-time value, not atomic time coordination with external systems.

Summary and next step

Transforms and preconditions shrink conflict surfaces by expressing updates directly. When one logical value is still written far too frequently, the next step is not “retry harder”; it is to distribute the write load.

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.