Skip to main content
RapidDev - Software Development Agency
firebase-tutorial

How to Use Transactions in Firebase Realtime Database

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.

What you'll learn

  • How to use runTransaction for atomic read-modify-write operations
  • How to handle transaction retries and aborted transactions
  • How to implement a safe counter that avoids race conditions
  • How to combine transactions with security rules for data integrity
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate7 min read10-15 minFirebase JS SDK v9+, Realtime Database, all Firebase plansMarch 2026RapidDev Engineering Team
TL;DR

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

1

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.

typescript
1import { getDatabase, ref, runTransaction } from 'firebase/database'
2
3const 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.

2

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.

typescript
1import { getDatabase, ref, runTransaction } from 'firebase/database'
2
3const db = getDatabase()
4const likesRef = ref(db, 'posts/post-123/likes')
5
6async function incrementLikes() {
7 try {
8 const result = await runTransaction(likesRef, (currentLikes) => {
9 // currentLikes is null if the path does not exist yet
10 return (currentLikes || 0) + 1
11 })
12
13 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.

3

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.

typescript
1const inventoryRef = ref(db, 'items/sword-01')
2
3async function purchaseItem(buyerId: string) {
4 const result = await runTransaction(inventoryRef, (item) => {
5 if (item === null) return item // Item does not exist, abort
6
7 if (item.quantity > 0 && item.ownerId === null) {
8 item.quantity -= 1
9 item.ownerId = buyerId
10 item.purchasedAt = Date.now()
11 return item
12 }
13
14 // Return undefined to abort the transaction
15 return undefined
16 })
17
18 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.

4

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.

typescript
1async function safeIncrement(path: string) {
2 const dbRef = ref(db, path)
3
4 try {
5 const result = await runTransaction(dbRef, (current) => {
6 return (current || 0) + 1
7 })
8
9 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 retries
16 console.error('Transaction failed permanently:', error)
17 throw error
18 }
19}

Expected result: The function either returns the new value on success or throws an error after all retries are exhausted.

5

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.

typescript
1// database.rules.json
2{
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

src/lib/rtdb-transactions.ts
1// src/lib/rtdb-transactions.ts
2import { getDatabase, ref, runTransaction } from 'firebase/database'
3
4const db = getDatabase()
5
6// Atomic counter increment
7export async function incrementCounter(path: string): Promise<number> {
8 const counterRef = ref(db, path)
9 const result = await runTransaction(counterRef, (current) => {
10 return (current || 0) + 1
11 })
12 if (!result.committed) throw new Error('Transaction aborted')
13 return result.snapshot.val()
14}
15
16// 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 || 0
21 return value > 0 ? value - 1 : 0
22 })
23 if (!result.committed) throw new Error('Transaction aborted')
24 return result.snapshot.val()
25}
26
27// Atomic item purchase
28export async function purchaseItem(
29 itemPath: string,
30 buyerId: string
31): Promise<boolean> {
32 const itemRef = ref(db, itemPath)
33 const result = await runTransaction(itemRef, (item) => {
34 if (!item || item.quantity <= 0) return undefined
35 item.quantity -= 1
36 item.lastBuyerId = buyerId
37 item.lastPurchasedAt = Date.now()
38 return item
39 })
40 return result.committed
41}
42
43// Toggle membership in a set (add/remove user ID)
44export async function toggleSetMember(
45 path: string,
46 userId: string
47): Promise<boolean> {
48 const setRef = ref(db, path)
49 let isNowMember = false
50 await runTransaction(setRef, (members) => {
51 if (!members) members = {}
52 if (members[userId]) {
53 delete members[userId]
54 isNowMember = false
55 } else {
56 members[userId] = true
57 isNowMember = true
58 }
59 return members
60 })
61 return isNowMember
62}

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.

ChatGPT Prompt

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.

Firebase Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.