Chapter 04 · CRUD with Client and Server SDKs: Reads, Writes, Updates, Deletes, Preconditions, and Converters
Get Documents, Create / Set / Merge, Update, Delete, and Understand Missing vs Null Fields
Make Firestore CRUD semantics explicit: distinguish missing documents, missing fields, null values, overwrite versus merge versus update, conditional creation, field deletion, and lifecycle-safe document deletion.
Learning outcomes
AtlasMart now has a stable access-pattern model, but CRUD bugs
can still destroy that model. A profile patch can accidentally
erase preferences, an update can fail because the
document never existed, a nullable field can be confused with an
absent field, and a server delete can leave orphaned
subcollection data. This lesson treats every write method as a
state transition with explicit preconditions rather than as
interchangeable vocabulary.
Distinguish a missing document, an existing document with a missing field, and an existing field whose value is null.
Predict the state effect of get, create, set/overwrite, set/merge, update, field deletion, and document deletion.
Use server-side create/update-time preconditions when a write must fail rather than silently overwrite concurrent state.
Explain why Web-client authorization and trusted-server authorization are separate controls even when they target the same path.
Prove CRUD results with before/after snapshots, update times, rule failures, and lifecycle-aware cleanup.
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. Three different absences: document missing, field missing, field null
Firestore does not have one universal “empty” state. A missing document means a document reference names a location for which no document exists; a document snapshot reports that state separately from its data. An absent field means the document exists but the field path is not present. A null field is present and stores the Firestore null value. Those distinctions affect queries, converters, validation, patch semantics, and product meaning.
| State | Snapshot interpretation | AtlasMart meaning | Do not confuse with |
|---|---|---|---|
| Document missing |
exists() / exists is false
|
User/profile/product has no document at that path | Existing object with empty fields |
| Field missing | Document exists; key/path absent from data | Feature never set, older schema, or intentionally deleted | Explicit “none” |
Field = null |
Key is present with null value | Explicit no-value state if the contract permits it | Unknown/unmigrated field |
For AtlasMart, phoneNumber: null can mean “the user
explicitly has no phone number,” while a missing
phoneNumber can mean “this v1 profile predates the
field.” If a converter collapses both to JavaScript
undefined, the application loses migration
information. Conversely, writing JavaScript
undefined is not a portable Firestore data
contract; represent optionality deliberately.
2. Get, create, set, merge, and update have different contracts
A read returns a snapshot, not proof that the document exists. A
full set is an upsert-like replacement at a known
path: if the document is absent it is created; if present its
document fields are replaced by the supplied document unless
merge semantics are requested.
set(..., { merge: true }) preserves omitted fields,
but an explicitly supplied map can still replace nested map
content depending on the write shape.
update patches named fields and fails if the target
document does not exist. Server libraries additionally expose
create(), which fails if the target already exists.
| Operation | Missing target | Existing target | Good use | Typical failure |
|---|---|---|---|---|
Web getDoc / server get |
Snapshot says missing | Returns snapshot data | Read current state | Caller forgets existence check |
set, no merge |
Creates | Replaces supplied document state | Known complete canonical representation | Blind overwrite loses fields |
set + merge |
Creates with supplied fields | Merges supplied paths | Upsert a partial projection/config | Assuming every nested map update is path-granular |
update |
Fails | Patches named field paths | Existing-object patch | Missing document or stale assumptions |
Server create |
Creates | Fails | Create-if-absent/idempotency record | Retry interpreted as unexpected error |
import { doc, getDoc, setDoc, updateDoc } from "firebase/firestore";const ref = doc(db, "profiles", "u-alice");await setDoc(ref, { displayName: "Alice", locale: "en", schemaVersion: 1 });console.log("seed", (await getDoc(ref)).data());await setDoc(ref, { locale: "fa", schemaVersion: 1 }, { merge: true });console.log("merge", (await getDoc(ref)).data());await updateDoc(ref, { "preferences.theme": "dark" });console.log("patch", (await getDoc(ref)).data());// Deliberate failure: update requires the target document to exist.await updateDoc(doc(db, "profiles", "u-missing"), { locale: "en" });
3. Field deletion and document deletion are not the same lifecycle operation
Deleting one field uses a field-deletion sentinel in an update.
That removes the field path while preserving the document.
Deleting a document removes that document, but it does
not recursively delete documents in
subcollections. A path like
profiles/u-alice/private/audit can remain reachable
after profiles/u-alice is deleted. Therefore
“delete profile” is a product/lifecycle workflow, not just one
deleteDoc() call.
import { deleteDoc, deleteField, doc, setDoc, updateDoc } from "firebase/firestore";const ref = doc(db, "profiles", "u-alice");await setDoc(ref, { displayName: "Alice", phoneNumber: null, legacyAlias: "alice-1" });await updateDoc(ref, { legacyAlias: deleteField() }); // field becomes absentawait deleteDoc(ref); // document becomes absent// Any subcollection documents under profiles/u-alice are separate documents.// Recursive lifecycle cleanup must be designed and executed deliberately.
Before destructive production code, list every owned root document, subcollection, duplicated projection, search/vector copy, event record, backup/retention obligation, and legal-hold exception. This chapter uses only disposable emulator paths and does not present a client-side recursive delete as a production pattern.
4. Preconditions turn “last writer wins” into an explicit conflict
When a trusted service must change a document only if it has not
changed since a prior read, use a conditional write rather than
“read, compare in JavaScript, then blindly update.” Firestore's
precondition model can require existence/nonexistence or a
matching update time. In the Node server library, a document
snapshot exposes updateTime;
update and delete accept a
precondition. This is useful for optimistic concurrency on a
single document when a full transaction is unnecessary.
import { initializeApp } from "firebase-admin/app";import { getFirestore } from "firebase-admin/firestore";initializeApp({ projectId: "demo-atlasmart-firestore" });const db = getFirestore();const productRef = db.doc("products/p-1001");// create() succeeds only if the document does not already exist.await productRef.create({ name: "Trail Camera", priceCents: 12990, schemaVersion: 1 });const before = await productRef.get();const observedUpdateTime = before.updateTime;await productRef.update( { priceCents: 12490 }, { lastUpdateTime: observedUpdateTime });// If another writer modifies the document after the read, the same precondition fails.
A precondition does not authorize the business action. The Admin/server path is privileged, so the service must still decide whether the authenticated caller is allowed to change the price. A successful update-time match proves freshness relative to one observed version, not that the price itself is valid.
5. Hands-on lab: CRUD state matrix
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.
Case A: GET missing profiles/u-missing -> snapshot missing; no fabricated empty objectCase B: SET full profile -> document exists with exact canonical fieldsCase C: SET merge locale -> displayName remains; locale changesCase D: UPDATE nested field -> target must exist; only named path changesCase E: store phoneNumber=null -> field is present and explicitly nullCase F: deleteField(phoneNumber) -> field absent, document still existsCase G: Admin create same product twice -> first creates; duplicate create failsCase H: Admin update with stale lastUpdateTime -> conditional write failsCase I: delete profile -> parent absent; prove any seeded subcollection doc is separate
For each case, capture the document path, client/server identity, operation name, expected before state, expected after state, and error category. Do not assert exact emulator wording as a timeless API; assert the semantic outcome. Then reset only the Chapter 04 fixtures.
6. Deliberately wrong approach: blind replacement as “update”
Suppose the UI edits only locale but calls full
setDoc(ref, { locale: "fa" }). The write can erase
displayName, preferences,
schemaVersion, and any server-managed fields
because the code supplied a replacement document. The fix is not
“always use merge.” The fix is to choose the operation that
matches the contract: full set only when the caller owns the
complete canonical representation; merge/update for controlled
patches; server-side conditional writes or transactions for
read-dependent invariants.
The same discipline applies to delete. “The document disappeared” is not enough evidence for a privacy/lifecycle requirement if subcollections, projections, exports, or backups remain.
Knowledge check
Check your understanding
- How is a missing field different from a field whose value is null?
- Why can a plain full set be dangerous for a partial UI edit?
- What is the useful semantic difference between server create() and set()?
- What does a lastUpdateTime precondition protect against?
- Why does deleting a parent document not prove its subcollection data is gone?
Review the answers
1. A missing field has no key/path in the document; null is an explicit stored value. They can carry different migration and product meaning.
2. A full set can replace document fields that the partial UI never loaded or intended to change. Use a patch/merge contract or submit a complete canonical object intentionally.
3. create() is conditional create-if-absent and fails when the document already exists; set() can create or overwrite depending on options.
4. It rejects the write if the target changed since the caller observed that update time, exposing a concurrent edit instead of silently overwriting it.
5. Firestore documents in subcollections are independent documents; parent document deletion is shallow with respect to subcollection documents.
Summary and next step
CRUD method names now map to explicit state transitions: snapshots prove existence, null is not absence, full set is not a patch, update requires an existing target, field deletion is not document deletion, and server preconditions can surface concurrent changes. Next, apply the same precision to nested field paths and atomic transforms.
Next: Field Paths, Nested Updates, Array Operations, Increment, Server Timestamps, and Atomic Field Transforms.
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.