A few days ago, I was working on a small authentication feature using both Firebase Auth and the Realtime Database (RTDB). I decided to write this post because understanding how Firebase RTDB transactions work—especially in Node—took me longer than I expected, even after reading the docs.
The scenario: a user is already logged in on their phone and enters a short code into another device to link their session.
Since the authentication was already handled on the phone, our backend issued a Firebase Auth custom token tied to that code. The device where the code is entered would then sign in using this token.
To securely bind the session in RTDB, we needed to:
- Check if the session code was valid and unclaimed.
- Atomically assign the user ID to it.
- Ensure only one session claim succeeds.
The rest of this post dives into how these transactions work specifically in the Admin SDK for Node.js, and how to use them.
The Confusion
Take this transaction example from the official docs:
const upvotesRef = db.ref(
'server/saving-data/fireblog/posts/-JRHTHaIs-jNPLXOQivY/upvotes'
);
upvotesRef.transaction((current_value) => {
return (current_value || 0) + 1;
});
This is straightforward: the callback returns a new value, and Firebase writes it.
But when I tried running a similar transaction, the first call to the callback received current_value
as null
—even though a record already existed. My assumption was that I should have seen the record immediately.
Reading further into the docs, I found this note:
Your transaction handler is called multiple times and must be able to handle null data. Even if there is existing data in your database it may not be locally cached when the transaction function is run.
So this behavior is expected: initially, current_value
is null
because it’s coming from the local cache. Firebase will then fetch the latest state from the server and re-run the callback with fresh data.
Here was my initial transaction logic:
const result = await codeRef.transaction((currentData) => {
if (currentData === null) {
return null; // Will delete node if data is truly null
}
const code = Code.fromData(currentData);
if (code.isExpired()) {
return; // Abort transaction, code is expired
}
return code.use(uid);
});
There are a few possible paths here:
- The code exists — we fetch it and apply the update if it’s valid.
- The code doesn’t exist —
currentData
comes asnull
from the server. - The code exists but is expired — we abort the transaction.
In the first case, the initial state is null
, triggering the first if-block
and returning null
. Firebase automatically retries with the fresh server state, and the logic proceeds.
In the second case, the first run returns null
, and when Firebase syncs with the server and still finds nothing, the transaction commits the null
(effectively a no-op).
In the third case, the same initial null
happens, but the second run receives the real data, sees that the code is expired, and aborts.
So what’s the confusion?
It was this: what’s the difference between returning null
vs. undefined
? Why does one retry and the other doesn’t? What exactly happens under the hood?
Going a Bit Deeper
Understanding how Firebase handles transactions internally clears things up.
In RTDB, the transaction callback may first receive stale or incomplete local data—often just null
. Firebase uses optimistic concurrency control: it starts the transaction with local cache data (which may be stale), then automatically retries with fresh server data if needed.
Here’s how the return values work:
Returning undefined means: “Abort the transaction.”
Firebase immediately exits with committed = false, no retries.Returning any other value (including
null
) means: “Commit this value.”
If you returnnull
, you’re telling Firebase to set the node tonull
(i.e., delete it).
The key insight: retries are driven by Firebase’s optimistic concurrency model, not by what you return.
Back to our code:
if (currentData === null) {
return null; // This will delete the node if data is truly null
}
This works because:
- If the local cache is stale and
currentData
isnull
, Firebase automatically retries with fresh server data - If the server data is also
null
(record doesn’t exist), Firebase commits thenull
value (no-op for non-existent data) - The retry mechanism is built into Firebase’s transaction system, not triggered by returning
null
No Transactions, Maybe?
After digging into the SDK and internal behavior, I wondered if a simpler design would work.
In my case, I just wanted to:
- Check if a record exists
- Read it
- Possibly update it
So I tried replacing the transaction with a regular read and conditional write.
const snapshot = await codeRef.once('value');
if (!snapshot.exists()) {
throw new Error('Code does not exist');
}
const code = Code.fromData(snapshot.val());
if (code.isExpired()) {
throw new Error('Code expired');
}
await codeRef.set(code.use(uid));
This approach:
- Uses 1 read (via
.once()
— which always goes to the server) - Writes only if needed
- Avoids the retry logic and potential edge cases with transactions
It’s simpler and faster if concurrent modifications aren’t a risk — for example, if I’ve enforced uniqueness at a higher level (like preventing the same code from being used twice). But I hadn’t done that. Multiple clients could try to use the same code at once. In that case, the transaction is still the safer and more efficient choice — because it gives atomicity out of the box.
And since RTDB charges by data transferred, not by operation count, the number of reads/writes doesn’t matter as much — what I want to minimize is bandwidth, not function calls.
Just thought I’d share this — since getting my head around how Firebase handles transactions took me a while. Hope this saves someone else some time :)