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.
Learning outcomes
Use increment, arrayUnion/arrayRemove and server timestamps for operations that do not need an application read.
Explain why atomic transforms reduce lost-update windows without automatically enforcing business invariants.
Apply server-side lastUpdateTime/create preconditions for compare-and-set behavior and know when browser/mobile code should use a transaction instead.
Redesign unbounded arrays and high-contention fields instead of treating field transforms as universal concurrency control.
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: 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
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.
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.
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.
// 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
{ "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
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
- Why can increment avoid lost updates?
- Does increment(-1) prevent negative stock?
- What does a lastUpdateTime precondition prove?
- Why is arrayUnion unsuitable for unbounded order history?
- 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
- 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.