# How to Perform Transactions in Firestore

- Tool: Firebase
- Difficulty: Intermediate
- Time required: 12-18 min
- Compatibility: Firebase JS SDK v9+, Firestore (all plans)
- Last updated: March 2026

## TL;DR

Firestore transactions use runTransaction to perform atomic read-then-write operations. All reads must happen before any writes inside the transaction callback. Transactions automatically retry when another client modifies the same document concurrently, up to a default of 25 attempts. They guarantee that either all operations succeed or none do. Transactions are limited to 500 document operations (reads plus writes combined), fail when the client is offline, and have a 270-second timeout. Use batch writes when you only need atomic writes without reads.

## Performing Atomic Transactions in Firestore

When multiple users can modify the same data simultaneously, you need transactions to prevent race conditions. A Firestore transaction reads one or more documents, applies logic based on their current values, and writes the results atomically. If another client changes a read document during the transaction, Firestore retries the entire callback. This tutorial covers the transaction API, common patterns like counters and transfers, and the differences between transactions and batch writes.

## Before you start

- A Firebase project with Firestore enabled
- Firebase JS SDK v9+ installed
- Basic understanding of Firestore reads and writes
- Familiarity with async/await and error handling

## Step-by-step guide

### 1. Run a basic transaction with runTransaction

Use runTransaction from the Firestore SDK to execute an atomic operation. The callback receives a transaction object with get (for reads) and set/update/delete (for writes). All reads must come before all writes. If any read document changes between when you read it and when the transaction commits, Firestore retries the entire callback automatically.

```
import { getFirestore, doc, runTransaction } from 'firebase/firestore'

const db = getFirestore()

// Atomically increment a counter
async function incrementCounter(counterId: string) {
  const counterRef = doc(db, 'counters', counterId)

  const newCount = await runTransaction(db, async (transaction) => {
    // Step 1: Read the current value
    const counterDoc = await transaction.get(counterRef)
    if (!counterDoc.exists()) {
      throw new Error('Counter document does not exist')
    }

    // Step 2: Calculate the new value
    const currentCount = counterDoc.data().count
    const updatedCount = currentCount + 1

    // Step 3: Write the new value
    transaction.update(counterRef, { count: updatedCount })

    return updatedCount
  })

  console.log('Counter updated to:', newCount)
  return newCount
}
```

**Expected result:** The counter increments atomically, even if multiple clients try to increment simultaneously.

### 2. Transfer a value between two documents

A common transaction pattern is transferring a value (like credits or currency) from one document to another. Read both documents, verify the sender has enough balance, deduct from the sender, and add to the receiver — all in one atomic operation. If either document changes during the transaction, Firestore retries.

```
async function transferCredits(
  fromUserId: string,
  toUserId: string,
  amount: number
) {
  const fromRef = doc(db, 'users', fromUserId)
  const toRef = doc(db, 'users', toUserId)

  await runTransaction(db, async (transaction) => {
    // Read both documents first
    const fromDoc = await transaction.get(fromRef)
    const toDoc = await transaction.get(toRef)

    if (!fromDoc.exists() || !toDoc.exists()) {
      throw new Error('One or both users do not exist')
    }

    const fromBalance = fromDoc.data().credits
    if (fromBalance < amount) {
      throw new Error(`Insufficient credits: has ${fromBalance}, needs ${amount}`)
    }

    // Write both updates
    transaction.update(fromRef, { credits: fromBalance - amount })
    transaction.update(toRef, { credits: toDoc.data().credits + amount })
  })

  console.log(`Transferred ${amount} credits from ${fromUserId} to ${toUserId}`)
}
```

**Expected result:** Credits are moved atomically between two user documents. If either read document was modified by another client, the transaction retries.

### 3. Handle transaction errors and retries

Transactions retry automatically when there is a write conflict (another client modified a read document). The default retry limit is 25 attempts. If all retries fail, the transaction throws an error. You should also handle application-level errors (like insufficient balance) separately. Wrap the runTransaction call in try/catch to distinguish between contention failures and business logic failures.

```
async function safeTransfer(
  fromId: string,
  toId: string,
  amount: number
) {
  try {
    await transferCredits(fromId, toId, amount)
    return { success: true }
  } catch (error: any) {
    if (error.message.includes('Insufficient credits')) {
      // Business logic error — do not retry
      return { success: false, reason: 'insufficient_balance' }
    }
    if (error.code === 'failed-precondition') {
      // Transaction failed after max retries due to contention
      return { success: false, reason: 'too_much_contention' }
    }
    if (error.code === 'unavailable') {
      // Client is offline — transactions require connectivity
      return { success: false, reason: 'offline' }
    }
    throw error // Unknown error
  }
}
```

**Expected result:** Transaction errors are caught and classified so the UI can show appropriate messages.

### 4. Understand when to use transactions vs batch writes

Transactions are for read-then-write operations where the write depends on the current data. Batch writes (writeBatch) are for atomic multi-document writes where you already know what to write. Batch writes are faster because they skip the read step, work offline (queued until connectivity), and have the same 500-operation limit. Use transactions when you need to check current values; use batches when you already have all the data.

```
import { writeBatch, doc, serverTimestamp } from 'firebase/firestore'

// Batch write: atomic writes WITHOUT reads
async function publishPost(postId: string, tags: string[]) {
  const batch = writeBatch(db)

  // Update the post status
  batch.update(doc(db, 'posts', postId), {
    status: 'published',
    publishedAt: serverTimestamp(),
  })

  // Create tag documents
  for (const tag of tags) {
    batch.set(doc(db, 'tags', tag), {
      name: tag,
      lastUsed: serverTimestamp(),
    }, { merge: true })
  }

  // All writes succeed or all fail
  await batch.commit()
}

// Transaction: atomic read-then-write
// Use when the write depends on current values
// Example: incrementing a counter, transferring balance

// Batch: atomic writes only
// Use when you already know what to write
// Example: publishing a post + updating tags
```

**Expected result:** You can choose the right tool: transactions for read-dependent writes, batch writes for known-value atomic writes.

### 5. Use transactions on the server with Admin SDK

In Cloud Functions, use the Admin SDK's runTransaction for server-side transactions. The Admin SDK version has the same API but bypasses security rules, supports up to 500 operations, and can set maxAttempts to control retry behavior. Server-side transactions are often more appropriate for critical operations because they run closer to the Firestore backend with lower latency.

```
// Cloud Function using Admin SDK transaction
import { getFirestore } from 'firebase-admin/firestore'

const db = getFirestore()

export async function processOrder(orderId: string) {
  const orderRef = db.doc(`orders/${orderId}`)
  const inventoryRef = db.doc(`products/${orderId}`)

  await db.runTransaction(async (transaction) => {
    const orderDoc = await transaction.get(orderRef)
    const inventoryDoc = await transaction.get(inventoryRef)

    if (!orderDoc.exists || !inventoryDoc.exists) {
      throw new Error('Order or product not found')
    }

    const quantity = orderDoc.data()!.quantity
    const stock = inventoryDoc.data()!.stock

    if (stock < quantity) {
      throw new Error('Insufficient stock')
    }

    transaction.update(inventoryRef, { stock: stock - quantity })
    transaction.update(orderRef, { status: 'confirmed' })
  }, { maxAttempts: 5 })
}
```

**Expected result:** Server-side transactions process orders atomically with controlled retry behavior.

## Complete code example

File: `firestore-transactions.ts`

```typescript
import { initializeApp } from 'firebase/app'
import {
  getFirestore,
  doc,
  runTransaction,
  writeBatch,
  serverTimestamp,
} from 'firebase/firestore'

const app = initializeApp({
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY!,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN!,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!,
})

const db = getFirestore(app)

export async function incrementCounter(counterId: string): Promise<number> {
  const ref = doc(db, 'counters', counterId)
  return runTransaction(db, async (tx) => {
    const snap = await tx.get(ref)
    const current = snap.exists() ? snap.data().count : 0
    tx.set(ref, { count: current + 1 }, { merge: true })
    return current + 1
  })
}

export async function transferCredits(
  fromId: string,
  toId: string,
  amount: number
): Promise<void> {
  const fromRef = doc(db, 'users', fromId)
  const toRef = doc(db, 'users', toId)

  await runTransaction(db, async (tx) => {
    const fromSnap = await tx.get(fromRef)
    const toSnap = await tx.get(toRef)
    if (!fromSnap.exists() || !toSnap.exists()) throw new Error('User not found')

    const balance = fromSnap.data().credits
    if (balance < amount) throw new Error('Insufficient credits')

    tx.update(fromRef, { credits: balance - amount })
    tx.update(toRef, { credits: toSnap.data().credits + amount })
  })
}

export async function batchPublish(
  postId: string,
  tags: string[]
): Promise<void> {
  const batch = writeBatch(db)
  batch.update(doc(db, 'posts', postId), {
    status: 'published',
    publishedAt: serverTimestamp(),
  })
  for (const tag of tags) {
    batch.set(doc(db, 'tags', tag), { name: tag }, { merge: true })
  }
  await batch.commit()
}
```

## Common mistakes

- **Writing to a document before reading all needed documents in the transaction** — undefined Fix: Move all transaction.get() calls before any transaction.set(), transaction.update(), or transaction.delete() calls. Firestore requires reads-before-writes inside transactions.
- **Using transactions for operations that do not depend on current document values** — undefined Fix: If you already know what to write and do not need to read first, use writeBatch instead. It is faster, works offline, and is simpler.
- **Running transactions while offline, which always fails** — undefined Fix: Check connectivity before running transactions or catch the 'unavailable' error and show a message. Use batch writes for offline-capable atomic operations.

## Best practices

- Always read all documents before writing any documents inside a transaction
- Keep transactions small — fewer documents means less contention and faster commits
- Use writeBatch for atomic writes that do not depend on reading current values
- Throw explicit errors for business logic failures to distinguish them from contention retries
- Use server-side transactions in Cloud Functions for critical operations like payments
- Implement distributed counters for high-contention fields that many users update simultaneously
- Set a reasonable maxAttempts on the Admin SDK to avoid indefinite retries

## Frequently asked questions

### How many documents can a transaction read and write?

A transaction can involve up to 500 document operations total, counting both reads and writes combined. For example, 200 reads and 300 writes, or 100 reads and 400 writes.

### Do transactions work offline?

No. Transactions require a round trip to the Firestore server to verify that read documents have not changed. If the client is offline, the transaction fails immediately. Use batch writes for offline-capable atomic operations.

### How many times does a transaction retry?

The client SDK retries up to 25 times by default. The Admin SDK retries up to 5 times by default but allows you to set maxAttempts. Each retry re-executes the entire callback with fresh reads.

### What is the timeout for a transaction?

Transactions have a 270-second (4.5 minute) timeout. If the transaction does not commit within this time, it fails. Keep transactions fast by minimizing the number of documents and computation inside the callback.

### Can I use transactions across different collections?

Yes. A single transaction can read and write documents from any collection in the same Firestore database. For example, you can read from a users collection and write to an orders collection in one transaction.

### Can RapidDev help implement transactional logic in my Firestore app?

Yes. RapidDev can design and implement transactional workflows for your application, including inventory management, credit systems, and concurrent data operations with proper error handling.

---

Source: https://www.rapidevelopers.com/firebase-tutorial/how-to-perform-transactions-in-firestore
© RapidDev — https://www.rapidevelopers.com/firebase-tutorial/how-to-perform-transactions-in-firestore
