Each of these took hours to diagnose. The Firestore documentation doesn’t cover them prominently, and the errors they produce are misleading. If you’re using Firestore from Java (Spring Boot), these will bite you. If you’re using it from Node, some of them won’t — which makes debugging even harder when your backend uses both.
Gotcha #1: set() treats dotted keys as literal field names
In Node, update() treats dotted paths as nested field access:
// Node: sets gameBreakdown.cabo.count = 1
await db.collection("users").doc(uid)
.update({ "gameBreakdown.cabo.count": new FieldValue.increment(1) });This creates a nested structure:
{
"gameBreakdown": {
"cabo": {
"count": 1
}
}
}In Java, set() with SetOptions.merge() does not interpret dotted paths as nested. Instead:
// Java: creates a LITERAL field named "gameBreakdown.cabo.count"
db.collection("users").document(uid)
.set(Map.of(
"gameBreakdown.cabo.count", FieldValue.increment(1)
), SetOptions.merge());This creates a flat top-level key literally named gameBreakdown.cabo.count, not a nested structure. The document ends up as:
{
"gameBreakdown.cabo.count": 1 // flat key, dots are part of the name!
}The fix: use update() inside a transaction, which does interpret dotted paths as nested access in Java:
db.runTransaction(txn -> {
DocumentReference docRef = db.collection("users").document(uid);
// update() respects dotted paths as nested access
txn.update(docRef,
"gameBreakdown.cabo.count", FieldValue.increment(1),
"gameBreakdown.cabo.totalXP", FieldValue.increment(xpEarned)
);
return null;
});Gotcha #2: serverTimestamp() can’t live inside arrayUnion()
We wanted to append a session entry with a server-set timestamp:
db.collection("users").document(uid)
.update("sessions", FieldValue.arrayUnion(
new SessionEntry(sessionId, FieldValue.serverTimestamp(), ...)
));This throws: IllegalArgumentException: Cannot use FieldValue.serverTimestamp() as an argument at field 'sessions'. Firestore doesn’t allow transform sentinels inside array operations. The value is resolved at write time, but serverTimestamp() is itself a transform that resolves later.
The fix: use a concrete timestamp instead:
// Use Timestamp.now() instead of FieldValue.serverTimestamp()
SessionEntry entry = new SessionEntry(
sessionId,
Timestamp.now(), // concrete value, not a transform sentinel
...
);
db.collection("users").document(uid)
.update("sessions", FieldValue.arrayUnion(entry));Timestamp.now()is a concrete value — it’s resolved immediately. FieldValue.serverTimestamp()is a sentinel that resolves on the server. You can’t nest a sentinel inside another transform operation.
Gotcha #3: FieldValue sentinels crash Mongo serialization
Our Spring backend writes to both Firestore and MongoDB. When a Firestore write fails, we queue the write payload to MongoDB for retry. But FieldValue.increment(1) and FieldValue.arrayUnion() are not values — they’re transform sentinels. They can’t be serialized.
The retry queue’s toMap() method tried to serialize aFieldValue inside a Map, and got:
IllegalStateException: "Cannot convert FieldValue.increment(1) to a Mongo-compatible value"The fix: the FirestorePqWriter’s toMap() helper explicitly checks for FieldValueinstances and throws a descriptive error. No FieldValue should ever reach Mongo — if it does, it’s a bug in the write-building logic, not a serialization issue.
private void validateNoFieldValues(Map<String, Object> map) {
for (Map.Entry<String, Object> entry : map.entrySet()) {
if (entry.getValue() instanceof FieldValue) {
throw new IllegalStateException(
"FieldValue sentinel found in field '" + entry.getKey()
+ "' — these cannot be queued to Mongo.");
}
}
}This guard only exists in the Java backend because only the Java backend needs to serialize Firestore operations to Mongo for retry. The Node backend writes to Firestore directly and never touches Mongo.
The common thread
All three gotchas share a pattern: Firestore in Java treats data differently than Firestore in Node. The Node SDK interprets dotted paths as nested and serializes everything as JSON. The Java SDK uses FieldValuesentinels that can’t be nested and can’t be serialized. When you have both backends writing to the same Firestore, you need to be aware of these differences.
The Node backend handles session writes (client-computed XP from solo games). The Spring backend handles Cabo session writes (server-authoritative). Both write to the same users/<uid>/sessions/<date> documents. Cross-backend consistency requires knowing these gotchas cold.
What I’d Tell My Past Self
- Use
update()in transactions for nested paths. Never useset()with dotted keys in Java — it creates flat literal keys. - D Never put
serverTimestamp()insidearrayUnion(). UseTimestamp.now()for values inside arrays. FieldValuesentinels are not values. They can’t be serialized, can’t be queued, and can’t be logged. Validate early and fail fast.- Test with both backends. Write a test that updates the same document from both Node and Java. If they produce different structures, you’ve found a gotcha.
- Add inline comments. Every time you hit a gotcha, add a comment explaining why the code looks unusual. The next developer (probably future you) will thank you.
