Chapter 04 · CRUD with Client and Server SDKs: Reads, Writes, Updates, Deletes, Preconditions, and Converters

Field Paths, Nested Updates, Array Operations, Increment, Server Timestamps, and Atomic Field Transforms

Use field paths and atomic transforms deliberately so nested patches, array membership, numeric increments, deletion sentinels, and server timestamps change only the intended state without fragile read-modify-write loops.

Beginner → Advanced105–135 minutesAtlasMart emulator-first CRUD labFirebase CLI 15.30.0 · Web SDK 12.19.0 · Admin Node 14.4.0 · Node.js 22+Firestore Standard Native Core semantics unless explicitly labeled EnterpriseLast reviewed: September 2026

Learning outcomes

AtlasMart's product/profile documents contain nested maps, tags, counters, and audit timestamps. The dangerous implementation is a client that reads an entire document, mutates a JavaScript object, and writes it back. That expands the race window, risks clobbering unrelated fields, and hides which update is meant to be atomic. Firestore field paths and transform sentinels let the write request state the intended mutation directly.

01

Patch nested fields without accidentally replacing sibling keys in a map.

02

Use arrayUnion/arrayRemove, increment, serverTimestamp, and deleteField according to their exact state semantics.

03

Explain which transform eliminates a read-modify-write race and which business invariant still needs a transaction.

04

Observe server timestamps and field-level changes through before/after snapshots rather than local guesses.

05

Choose arrays versus subcollections based on boundedness, queryability, write contention, and index effects.

Chapter 04 baseline reviewed 15 September 2026

The lab continues Chapters 01–03 without changing the environment: project demo-atlasmart-firestore; Firestore emulator 127.0.0.1:8080; Auth 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.js SDK 14.4.0 (which currently carries @google-cloud/firestore 9.1.0); Node.js 22 or newer. The default teaching surface is Firestore Standard edition, Native mode, Core operations, default database (default), emulator-first. Enterprise/Pipeline/MongoDB-compatibility behavior is labeled rather than generalized.

Trust, execution, and evidence note

Browser/mobile SDK requests are untrusted requests evaluated by Firebase Authentication context plus Cloud Firestore Security Rules. Admin/server client libraries are privileged and bypass Firestore Security Rules; production access is governed by IAM/ADC and by authorization logic in your server application. The emulator demonstrates these trust paths and CRUD semantics, but it does not prove production IAM, regional latency, billing, quotas, contention, or service availability. Expected failures are described by invariant/error family rather than fabricated captured output.

1. Field paths make update granularity visible

A nested map is stored inside one Firestore document. Updating the whole map value can replace sibling keys; updating a nested field path changes the targeted path. For a static key such as preferences.theme, dot notation is convenient. If a field name itself contains a dot or other special path syntax, use an SDK FieldPath mechanism instead of pretending the string is unambiguous.

Web modular SDK · nested patch versus map replacement
import { doc, getDoc, setDoc, updateDoc } from "firebase/firestore";const ref = doc(db, "profiles", "u-alice");await setDoc(ref, {  displayName: "Alice",  preferences: { theme: "light", locale: "en", density: "comfortable" },  schemaVersion: 2});await updateDoc(ref, { "preferences.theme": "dark" });console.log((await getDoc(ref)).data().preferences);// Expected shape: theme changed; locale and density remain.// Contrast: updateDoc(ref, { preferences: { theme: "dark" } })// replaces the preferences map value and removes its omitted sibling keys.

2. Array transforms are atomic membership operations, not general list editing

arrayUnion() adds values that are not already present; arrayRemove() removes all instances of the supplied values. They are useful when the desired operation is set-like membership. They do not mean “append an event exactly once with a stable order” or “modify the third element in place.” If an array grows without a hard bound or each child needs independent query/update/lifecycle behavior, move the child data to documents/subcollections instead.

Web modular SDK · bounded tag membership
import { arrayRemove, arrayUnion, doc, updateDoc } from "firebase/firestore";const ref = doc(db, "products", "p-1001");await updateDoc(ref, { tags: arrayUnion("outdoor", "camera") });await updateDoc(ref, { tags: arrayUnion("camera") }); // duplicate membership is not added againawait updateDoc(ref, { tags: arrayRemove("outdoor") });

Equality semantics for complex array values are Firestore value semantics, not JavaScript object identity. Even when the transform is atomic, an ever-growing array still increases document size and can increase index fan-out. Chapter 02's hard limits and Chapter 03's modeling rules still apply.

3. increment is atomic arithmetic, not an invariant checker

increment(n) asks Firestore to atomically change a numeric field by n. If the field is missing or not numeric, current documented behavior sets the field to the increment value. This avoids the classic lost update caused by “read count → add one locally → write absolute count.” It does not enforce a floor, ceiling, inventory availability, account balance, or relationship with another document. Those read-dependent invariants require a transaction or a trusted workflow with equivalent concurrency control.

Web modular SDK · safe atomic counter change and unsafe business inference
import { doc, increment, updateDoc } from "firebase/firestore";const statsRef = doc(db, "productStats", "p-1001");await updateDoc(statsRef, { viewCount: increment(1) });// Good: independent additive metric.// Not sufficient for: "decrement stock only if stock >= requested quantity".// That condition depends on the current value and needs a transaction/trusted invariant workflow.

4. serverTimestamp records server commit time, not client intent time

A client clock can be wrong, manipulated, or simply in another timezone. serverTimestamp() writes a transform resolved by Firestore when the server processes the write. It is appropriate for fields such as updatedAt when the contract is “database commit time.” It is not the same as the user's event time, payment-provider event time, or device-captured time; store those separately when they matter.

Web modular SDK · combine direct fields and transforms
import { deleteField, doc, increment, serverTimestamp, updateDoc } from "firebase/firestore";await updateDoc(doc(db, "products", "p-1001"), {  "merchandising.badge": "featured",  editCount: increment(1),  legacyPromotionCode: deleteField(),  updatedAt: serverTimestamp()});

Immediately after a local write, client snapshot behavior can expose pending local state depending on listener/source/cache semantics; do not infer the committed server timestamp from the device clock. For this chapter's deterministic lab, wait for the write promise, then read back from the emulator and assert that updatedAt is a Firestore timestamp value.

5. One write can contain several transforms, but atomic does not mean globally transactional

Transforms within one document write are applied atomically as part of that write. A batch can make multiple writes atomic without read-dependent logic. A transaction is required when the next write is conditional on values read from Firestore. The design question is not “which API is more advanced?” but “what state must be read, what invariant spans which documents, and what should happen under concurrent edits?”

Need Mechanism Concurrency property
Patch known nested fields update with field paths No read required; targeted patch
Add/remove bounded membership arrayUnion/arrayRemove Atomic transform on field
Add numeric delta increment Atomic arithmetic transform
Database commit timestamp serverTimestamp Server-resolved transform
Delete one field deleteField Removes path, preserves document
Condition on current stock and write result Transaction Read-dependent atomic invariant

6. Hands-on lab: prove no lost sibling fields and no read-modify-write counter race

Continue the same local AtlasMart workspace. Keep firebase emulators:start --only auth,firestore running. Admin/server examples set FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 and GCLOUD_PROJECT=demo-atlasmart-firestore; browser examples connect the modular Web SDK explicitly to the Firestore/Auth emulators. Use only synthetic Chapter 04 documents such as profiles/u-alice, products/p-1001, crudOperations/<operationId>, and named test users. Cleanup must target only those paths.

lab assertions · before/after evidence
1. Seed preferences = {theme: light, locale: en, density: comfortable}.2. Patch preferences.theme via field path; assert locale and density remain.3. Seed tags=[camera]; arrayUnion(camera,outdoor); assert camera appears once.4. arrayRemove(outdoor); assert it is absent.5. Run several increment(1) writes; assert final delta equals successful writes.6. Write updatedAt=serverTimestamp(); read back and assert Firestore Timestamp type.7. deleteField(legacyPromotionCode); assert field absent, not null.8. Demonstrate that an inventory floor cannot be enforced by increment alone.

7. Deliberately wrong approach: read the whole document, mutate it, write it back

Two clients can read the same product, one changes preferences.theme while the other increments a count, and each then writes an old full object. The later replacement can erase the first change. The repair is to express independent field mutations directly with field paths/transforms. When a change depends on a value you read, use a transaction or a version/update-time precondition instead of hoping the race is rare.

Knowledge check

Check your understanding

  1. Why is updating "preferences.theme" safer than replacing the entire preferences map for one setting change?
  2. What guarantee does arrayUnion provide, and what does it not provide?
  3. Why can increment replace a read-modify-write counter loop?
  4. Why is serverTimestamp not the same as the user-event timestamp?
  5. When do you need a transaction instead of field transforms?
Review the answers

1. It targets one nested path and preserves sibling fields that were not part of the change.

2. It atomically adds values not already present under Firestore value equality; it does not provide arbitrary ordered-list edits, unbounded-list scalability, or business invariants.

3. It applies a numeric delta atomically on the server so concurrent writers do not all overwrite the same stale absolute value.

4. It represents server-side write/commit timing; user/device/provider event time is a separate domain fact and may need its own field.

5. When the write result depends on current Firestore values, especially across fields/documents, and the invariant must be checked against a consistent read.

Summary and next step

Nested updates and transforms let the write request describe intent with smaller race surfaces: target one path, add/remove bounded membership, apply a numeric delta, resolve server time, or delete one field. Those tools still operate inside a security and trust model, which is the subject of the next lesson.

Next: Server vs Client SDK Authentication/Authorization Models and Trusted vs Untrusted Execution.

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.