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.
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.
Patch nested fields without accidentally replacing sibling keys in a map.
Use arrayUnion/arrayRemove, increment, serverTimestamp, and deleteField according to their exact state semantics.
Explain which transform eliminates a read-modify-write race and which business invariant still needs a transaction.
Observe server timestamps and field-level changes through before/after snapshots rather than local guesses.
Choose arrays versus subcollections based on boundedness, queryability, write contention, and index effects.
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.
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.
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.
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.
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.
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.
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
- Why is updating "preferences.theme" safer than replacing the entire preferences map for one setting change?
- What guarantee does arrayUnion provide, and what does it not provide?
- Why can increment replace a read-modify-write counter loop?
- Why is serverTimestamp not the same as the user-event timestamp?
- 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
- Add data to Cloud Firestore — Official set, merge, update, nested field, server timestamp, array transform, and increment semantics.
- Get data with Cloud Firestore — Document snapshots, existence checks, and custom-object conversion.
- Delete data from Cloud Firestore — Document deletion, field deletion, collection deletion, and subcollection caveats.
- Cloud Firestore data model — Path hierarchy and the warning that deleting a document does not delete its subcollections.
- Secure data in Cloud Firestore — Mobile/web Security Rules versus server IAM trust paths.
- Security Rules conditions — Request/resource validation and explicit server-library bypass guidance.
- Test Security Rules with Emulator Suite — Deterministic local Rules testing.
- Transactions and batched writes — Retry behavior and when read-dependent writes require transactions.
- Firestore REST Precondition — Conditional existence/update-time semantics.
- Set up the Firebase Admin SDK — Privileged server setup, ADC, and current Node.js runtime requirement.
- Firebase JavaScript SDK release notes — Current Web SDK baseline.
- Firebase Admin Node.js SDK release notes — Current Admin/Firestore dependency baseline.
- Firebase CLI release notes — Current CLI baseline.