Transactions in Firebase Realtime Database let you perform atomic read-modify-write operations using the runTransaction function. You pass a callback that receives the current value at a database reference, modifies it, and returns the new value. Firebase automatically retries the transaction if another client writes to the same location concurrently. This is essential for counters, inventory systems, and any data that multiple users can update simultaneously.
Atomic Read-Modify-Write Operations with Realtime Database Transactions
When multiple clients can update the same data simultaneously, simple read-then-write patterns create race conditions. Firebase Realtime Database transactions solve this by reading the current value, letting your callback compute the new value, and retrying automatically if the data changed between the read and write. This tutorial shows you how to use runTransaction for counters, inventory, and other concurrent-update scenarios.
Prerequisites
- A Firebase project with Realtime Database enabled
- Firebase JS SDK v9+ installed in your project
- Firebase initialized with your project config
- Basic understanding of asynchronous JavaScript
Step-by-step guide
Set up the database reference
Set up the database reference
Import the necessary functions from firebase/database and create a reference to the data you want to transact on. Transactions operate on a single database reference path. All reads and writes within the transaction are scoped to this path.
1import { getDatabase, ref, runTransaction } from 'firebase/database'23const db = getDatabase()4const likesRef = ref(db, 'posts/post-123/likes')Expected result: A database reference pointing to the specific path you want to transact on.
Run a basic transaction to increment a counter
Run a basic transaction to increment a counter
Call runTransaction with the database reference and a callback function. The callback receives the current value and must return the new value. If the current value is null (the path does not exist yet), initialize it. Firebase retries the callback if another write occurs at the same path between the read and commit.
1import { getDatabase, ref, runTransaction } from 'firebase/database'23const db = getDatabase()4const likesRef = ref(db, 'posts/post-123/likes')56async function incrementLikes() {7 try {8 const result = await runTransaction(likesRef, (currentLikes) => {9 // currentLikes is null if the path does not exist yet10 return (currentLikes || 0) + 111 })1213 if (result.committed) {14 console.log('New like count:', result.snapshot.val())15 } else {16 console.log('Transaction was not committed')17 }18 } catch (error) {19 console.error('Transaction failed:', error)20 }21}Expected result: The likes value increments atomically by 1, even when multiple users tap the like button simultaneously.
Implement a transaction on an object with multiple fields
Implement a transaction on an object with multiple fields
Transactions can operate on objects, not just primitive values. This is useful when you need to update multiple related fields atomically. The callback receives the entire object at the reference path and returns the modified object.
1const inventoryRef = ref(db, 'items/sword-01')23async function purchaseItem(buyerId: string) {4 const result = await runTransaction(inventoryRef, (item) => {5 if (item === null) return item // Item does not exist, abort67 if (item.quantity > 0 && item.ownerId === null) {8 item.quantity -= 19 item.ownerId = buyerId10 item.purchasedAt = Date.now()11 return item12 }1314 // Return undefined to abort the transaction15 return undefined16 })1718 if (result.committed) {19 console.log('Purchase successful')20 } else {21 console.log('Item unavailable or already owned')22 }23}Expected result: The item's quantity decreases by 1 and the ownerId is set atomically. If two users try to buy the last item, only one succeeds.
Handle transaction retries and failures
Handle transaction retries and failures
Firebase automatically retries a transaction up to 25 times if the data changes during the operation. If all retries are exhausted, the transaction fails. Wrap your transaction in a try/catch to handle errors gracefully. The result object's committed property tells you whether the write succeeded.
1async function safeIncrement(path: string) {2 const dbRef = ref(db, path)34 try {5 const result = await runTransaction(dbRef, (current) => {6 return (current || 0) + 17 })89 if (result.committed) {10 return result.snapshot.val()11 } else {12 throw new Error('Transaction aborted')13 }14 } catch (error) {15 // Transaction failed after all retries16 console.error('Transaction failed permanently:', error)17 throw error18 }19}Expected result: The function either returns the new value on success or throws an error after all retries are exhausted.
Add security rules to protect transacted data
Add security rules to protect transacted data
Security rules work alongside transactions. Write rules that validate the data your transaction produces. For counters, validate that the new value is exactly one more than the existing value. This prevents clients from setting arbitrary values even if they bypass your client code.
1// database.rules.json2{3 "rules": {4 "posts": {5 "$postId": {6 "likes": {7 ".write": "auth != null",8 ".validate": "newData.isNumber() && ((!data.exists() && newData.val() === 1) || newData.val() === data.val() + 1)"9 }10 }11 }12 }13}Expected result: Only authenticated users can increment likes, and only by exactly 1 per write.
Complete working example
1// src/lib/rtdb-transactions.ts2import { getDatabase, ref, runTransaction } from 'firebase/database'34const db = getDatabase()56// Atomic counter increment7export async function incrementCounter(path: string): Promise<number> {8 const counterRef = ref(db, path)9 const result = await runTransaction(counterRef, (current) => {10 return (current || 0) + 111 })12 if (!result.committed) throw new Error('Transaction aborted')13 return result.snapshot.val()14}1516// Atomic counter decrement (minimum 0)17export async function decrementCounter(path: string): Promise<number> {18 const counterRef = ref(db, path)19 const result = await runTransaction(counterRef, (current) => {20 const value = current || 021 return value > 0 ? value - 1 : 022 })23 if (!result.committed) throw new Error('Transaction aborted')24 return result.snapshot.val()25}2627// Atomic item purchase28export async function purchaseItem(29 itemPath: string,30 buyerId: string31): Promise<boolean> {32 const itemRef = ref(db, itemPath)33 const result = await runTransaction(itemRef, (item) => {34 if (!item || item.quantity <= 0) return undefined35 item.quantity -= 136 item.lastBuyerId = buyerId37 item.lastPurchasedAt = Date.now()38 return item39 })40 return result.committed41}4243// Toggle membership in a set (add/remove user ID)44export async function toggleSetMember(45 path: string,46 userId: string47): Promise<boolean> {48 const setRef = ref(db, path)49 let isNowMember = false50 await runTransaction(setRef, (members) => {51 if (!members) members = {}52 if (members[userId]) {53 delete members[userId]54 isNowMember = false55 } else {56 members[userId] = true57 isNowMember = true58 }59 return members60 })61 return isNowMember62}Common mistakes when using Transactions in Firebase Realtime Database
Why it's a problem: Performing side effects like API calls inside the transaction callback, which run multiple times on retries
How to avoid: Keep the transaction callback pure — only compute the new value from the current value. Perform side effects after the transaction completes by checking result.committed.
Why it's a problem: Not handling the null case when the path does not exist yet, causing the transaction to write null
How to avoid: Always check if the current value is null in your callback and provide a default value. For counters, use (current || 0) + 1.
Why it's a problem: Using transactions on deeply nested paths that trigger frequent retries due to other writers
How to avoid: Scope transactions to the narrowest possible path. Instead of transacting on an entire user object, transact on user/score specifically.
Best practices
- Always handle null values in the transaction callback since the path may not exist on first run
- Return undefined from the callback to explicitly abort a transaction when preconditions are not met
- Keep transaction callbacks pure with no side effects — retries will re-execute the callback
- Scope transactions to the narrowest database path to minimize contention and retries
- Add security rules that validate transacted data to prevent clients from bypassing transaction logic
- For apps with high-frequency concurrent writes that push RTDB transactions to their limits, RapidDev can help design the data architecture and conflict resolution strategy
- Use the committed property on the result to determine whether the transaction succeeded before updating UI
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
Show me how to use Firebase Realtime Database transactions in JavaScript to build an atomic like counter for a social media post. Include the runTransaction call, null handling for new posts, and a security rule that only allows incrementing by 1.
Create a Firebase Realtime Database transaction system for my app. I need an atomic counter for post likes, an inventory purchase function that decrements quantity only if stock is available, and security rules that validate each operation. Use Firebase modular SDK v9+ syntax.
Frequently asked questions
How many times does Firebase retry a transaction?
Firebase retries a Realtime Database transaction up to 25 times. If the data keeps changing between the read and the write attempt, the transaction fails after all retries are exhausted.
Do transactions work when the app is offline?
No. Unlike Firestore transactions, Realtime Database transactions require a network connection to guarantee atomicity. If the client is offline, the transaction will fail. Use regular set or update operations with conflict resolution for offline scenarios.
What is the difference between a transaction and an update in RTDB?
An update() writes values to specified paths without reading current data — it is a blind write. A transaction reads the current value first, lets you compute a new value, and retries if the data changed. Use transactions when the new value depends on the current value.
Can I run a transaction on multiple paths at once?
No. Realtime Database transactions operate on a single ref path. If you need to atomically update multiple paths, use the update() method with multi-location updates, or restructure your data so related values are under one path.
How do I abort a transaction?
Return undefined from the transaction callback. This tells Firebase to cancel the write without modifying the data. The result.committed property will be false.
Are transactions billed differently than regular reads and writes?
No. Transactions count as standard reads and writes. Each retry counts as an additional read. On the Blaze plan, Realtime Database is billed per bandwidth downloaded, not per operation, so retries add minimal cost.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation