# How to Use Transactions in Firebase Realtime Database

- Tool: Firebase
- Difficulty: Intermediate
- Time required: 10-15 min
- Compatibility: Firebase JS SDK v9+, Realtime Database, all Firebase plans
- Last updated: March 2026

## 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.

## Before you start

- 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.

```
import { getDatabase, ref, runTransaction } from 'firebase/database'

const db = getDatabase()
const 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.

```
import { getDatabase, ref, runTransaction } from 'firebase/database'

const db = getDatabase()
const likesRef = ref(db, 'posts/post-123/likes')

async function incrementLikes() {
  try {
    const result = await runTransaction(likesRef, (currentLikes) => {
      // currentLikes is null if the path does not exist yet
      return (currentLikes || 0) + 1
    })

    if (result.committed) {
      console.log('New like count:', result.snapshot.val())
    } else {
      console.log('Transaction was not committed')
    }
  } catch (error) {
    console.error('Transaction failed:', error)
  }
}
```

**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.

```
const inventoryRef = ref(db, 'items/sword-01')

async function purchaseItem(buyerId: string) {
  const result = await runTransaction(inventoryRef, (item) => {
    if (item === null) return item  // Item does not exist, abort

    if (item.quantity > 0 && item.ownerId === null) {
      item.quantity -= 1
      item.ownerId = buyerId
      item.purchasedAt = Date.now()
      return item
    }

    // Return undefined to abort the transaction
    return undefined
  })

  if (result.committed) {
    console.log('Purchase successful')
  } else {
    console.log('Item unavailable or already owned')
  }
}
```

**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.

```
async function safeIncrement(path: string) {
  const dbRef = ref(db, path)

  try {
    const result = await runTransaction(dbRef, (current) => {
      return (current || 0) + 1
    })

    if (result.committed) {
      return result.snapshot.val()
    } else {
      throw new Error('Transaction aborted')
    }
  } catch (error) {
    // Transaction failed after all retries
    console.error('Transaction failed permanently:', error)
    throw error
  }
}
```

**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.

```
// database.rules.json
{
  "rules": {
    "posts": {
      "$postId": {
        "likes": {
          ".write": "auth != null",
          ".validate": "newData.isNumber() && ((!data.exists() && newData.val() === 1) || newData.val() === data.val() + 1)"
        }
      }
    }
  }
}
```

**Expected result:** Only authenticated users can increment likes, and only by exactly 1 per write.

## Complete code example

File: `src/lib/rtdb-transactions.ts`

```typescript
// src/lib/rtdb-transactions.ts
import { getDatabase, ref, runTransaction } from 'firebase/database'

const db = getDatabase()

// Atomic counter increment
export async function incrementCounter(path: string): Promise<number> {
  const counterRef = ref(db, path)
  const result = await runTransaction(counterRef, (current) => {
    return (current || 0) + 1
  })
  if (!result.committed) throw new Error('Transaction aborted')
  return result.snapshot.val()
}

// Atomic counter decrement (minimum 0)
export async function decrementCounter(path: string): Promise<number> {
  const counterRef = ref(db, path)
  const result = await runTransaction(counterRef, (current) => {
    const value = current || 0
    return value > 0 ? value - 1 : 0
  })
  if (!result.committed) throw new Error('Transaction aborted')
  return result.snapshot.val()
}

// Atomic item purchase
export async function purchaseItem(
  itemPath: string,
  buyerId: string
): Promise<boolean> {
  const itemRef = ref(db, itemPath)
  const result = await runTransaction(itemRef, (item) => {
    if (!item || item.quantity <= 0) return undefined
    item.quantity -= 1
    item.lastBuyerId = buyerId
    item.lastPurchasedAt = Date.now()
    return item
  })
  return result.committed
}

// Toggle membership in a set (add/remove user ID)
export async function toggleSetMember(
  path: string,
  userId: string
): Promise<boolean> {
  const setRef = ref(db, path)
  let isNowMember = false
  await runTransaction(setRef, (members) => {
    if (!members) members = {}
    if (members[userId]) {
      delete members[userId]
      isNowMember = false
    } else {
      members[userId] = true
      isNowMember = true
    }
    return members
  })
  return isNowMember
}
```

## Common mistakes

- **Performing side effects like API calls inside the transaction callback, which run multiple times on retries** — undefined Fix: 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.
- **Not handling the null case when the path does not exist yet, causing the transaction to write null** — undefined Fix: Always check if the current value is null in your callback and provide a default value. For counters, use (current || 0) + 1.
- **Using transactions on deeply nested paths that trigger frequent retries due to other writers** — undefined Fix: 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

## 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.

---

Source: https://www.rapidevelopers.com/firebase-tutorial/how-to-use-transactions-in-realtime-database
© RapidDev — https://www.rapidevelopers.com/firebase-tutorial/how-to-use-transactions-in-realtime-database
