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

How to Perform Transactions in Firestore

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.

What you'll learn

  • How to perform atomic read-then-write operations with runTransaction
  • How transactions handle concurrency with automatic retries
  • How to transfer values between documents atomically
  • When to use transactions versus batch writes
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate8 min read12-18 minFirebase JS SDK v9+, Firestore (all plans)March 2026RapidDev Engineering Team
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.

Prerequisites

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

typescript
1import { getFirestore, doc, runTransaction } from 'firebase/firestore'
2
3const db = getFirestore()
4
5// Atomically increment a counter
6async function incrementCounter(counterId: string) {
7 const counterRef = doc(db, 'counters', counterId)
8
9 const newCount = await runTransaction(db, async (transaction) => {
10 // Step 1: Read the current value
11 const counterDoc = await transaction.get(counterRef)
12 if (!counterDoc.exists()) {
13 throw new Error('Counter document does not exist')
14 }
15
16 // Step 2: Calculate the new value
17 const currentCount = counterDoc.data().count
18 const updatedCount = currentCount + 1
19
20 // Step 3: Write the new value
21 transaction.update(counterRef, { count: updatedCount })
22
23 return updatedCount
24 })
25
26 console.log('Counter updated to:', newCount)
27 return newCount
28}

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.

typescript
1async function transferCredits(
2 fromUserId: string,
3 toUserId: string,
4 amount: number
5) {
6 const fromRef = doc(db, 'users', fromUserId)
7 const toRef = doc(db, 'users', toUserId)
8
9 await runTransaction(db, async (transaction) => {
10 // Read both documents first
11 const fromDoc = await transaction.get(fromRef)
12 const toDoc = await transaction.get(toRef)
13
14 if (!fromDoc.exists() || !toDoc.exists()) {
15 throw new Error('One or both users do not exist')
16 }
17
18 const fromBalance = fromDoc.data().credits
19 if (fromBalance < amount) {
20 throw new Error(`Insufficient credits: has ${fromBalance}, needs ${amount}`)
21 }
22
23 // Write both updates
24 transaction.update(fromRef, { credits: fromBalance - amount })
25 transaction.update(toRef, { credits: toDoc.data().credits + amount })
26 })
27
28 console.log(`Transferred ${amount} credits from ${fromUserId} to ${toUserId}`)
29}

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.

typescript
1async function safeTransfer(
2 fromId: string,
3 toId: string,
4 amount: number
5) {
6 try {
7 await transferCredits(fromId, toId, amount)
8 return { success: true }
9 } catch (error: any) {
10 if (error.message.includes('Insufficient credits')) {
11 // Business logic error — do not retry
12 return { success: false, reason: 'insufficient_balance' }
13 }
14 if (error.code === 'failed-precondition') {
15 // Transaction failed after max retries due to contention
16 return { success: false, reason: 'too_much_contention' }
17 }
18 if (error.code === 'unavailable') {
19 // Client is offline — transactions require connectivity
20 return { success: false, reason: 'offline' }
21 }
22 throw error // Unknown error
23 }
24}

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.

typescript
1import { writeBatch, doc, serverTimestamp } from 'firebase/firestore'
2
3// Batch write: atomic writes WITHOUT reads
4async function publishPost(postId: string, tags: string[]) {
5 const batch = writeBatch(db)
6
7 // Update the post status
8 batch.update(doc(db, 'posts', postId), {
9 status: 'published',
10 publishedAt: serverTimestamp(),
11 })
12
13 // Create tag documents
14 for (const tag of tags) {
15 batch.set(doc(db, 'tags', tag), {
16 name: tag,
17 lastUsed: serverTimestamp(),
18 }, { merge: true })
19 }
20
21 // All writes succeed or all fail
22 await batch.commit()
23}
24
25// Transaction: atomic read-then-write
26// Use when the write depends on current values
27// Example: incrementing a counter, transferring balance
28
29// Batch: atomic writes only
30// Use when you already know what to write
31// 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.

typescript
1// Cloud Function using Admin SDK transaction
2import { getFirestore } from 'firebase-admin/firestore'
3
4const db = getFirestore()
5
6export async function processOrder(orderId: string) {
7 const orderRef = db.doc(`orders/${orderId}`)
8 const inventoryRef = db.doc(`products/${orderId}`)
9
10 await db.runTransaction(async (transaction) => {
11 const orderDoc = await transaction.get(orderRef)
12 const inventoryDoc = await transaction.get(inventoryRef)
13
14 if (!orderDoc.exists || !inventoryDoc.exists) {
15 throw new Error('Order or product not found')
16 }
17
18 const quantity = orderDoc.data()!.quantity
19 const stock = inventoryDoc.data()!.stock
20
21 if (stock < quantity) {
22 throw new Error('Insufficient stock')
23 }
24
25 transaction.update(inventoryRef, { stock: stock - quantity })
26 transaction.update(orderRef, { status: 'confirmed' })
27 }, { maxAttempts: 5 })
28}

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

Complete working example

firestore-transactions.ts
1import { initializeApp } from 'firebase/app'
2import {
3 getFirestore,
4 doc,
5 runTransaction,
6 writeBatch,
7 serverTimestamp,
8} from 'firebase/firestore'
9
10const app = initializeApp({
11 apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY!,
12 authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN!,
13 projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!,
14})
15
16const db = getFirestore(app)
17
18export async function incrementCounter(counterId: string): Promise<number> {
19 const ref = doc(db, 'counters', counterId)
20 return runTransaction(db, async (tx) => {
21 const snap = await tx.get(ref)
22 const current = snap.exists() ? snap.data().count : 0
23 tx.set(ref, { count: current + 1 }, { merge: true })
24 return current + 1
25 })
26}
27
28export async function transferCredits(
29 fromId: string,
30 toId: string,
31 amount: number
32): Promise<void> {
33 const fromRef = doc(db, 'users', fromId)
34 const toRef = doc(db, 'users', toId)
35
36 await runTransaction(db, async (tx) => {
37 const fromSnap = await tx.get(fromRef)
38 const toSnap = await tx.get(toRef)
39 if (!fromSnap.exists() || !toSnap.exists()) throw new Error('User not found')
40
41 const balance = fromSnap.data().credits
42 if (balance < amount) throw new Error('Insufficient credits')
43
44 tx.update(fromRef, { credits: balance - amount })
45 tx.update(toRef, { credits: toSnap.data().credits + amount })
46 })
47}
48
49export async function batchPublish(
50 postId: string,
51 tags: string[]
52): Promise<void> {
53 const batch = writeBatch(db)
54 batch.update(doc(db, 'posts', postId), {
55 status: 'published',
56 publishedAt: serverTimestamp(),
57 })
58 for (const tag of tags) {
59 batch.set(doc(db, 'tags', tag), { name: tag }, { merge: true })
60 }
61 await batch.commit()
62}

Common mistakes when performing Transactions in Firestore

Why it's a problem: Writing to a document before reading all needed documents in the transaction

How to avoid: Move all transaction.get() calls before any transaction.set(), transaction.update(), or transaction.delete() calls. Firestore requires reads-before-writes inside transactions.

Why it's a problem: Using transactions for operations that do not depend on current document values

How to avoid: 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.

Why it's a problem: Running transactions while offline, which always fails

How to avoid: 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

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

Show me how to use Firestore transactions with the Firebase modular SDK v9. I need to atomically transfer credits between two user documents, handling insufficient balance and contention errors. Also show when to use writeBatch instead of runTransaction.

Firebase Prompt

Implement a stock management system where placing an order decreases product stock using a Firestore transaction. If stock is insufficient, show an error. Use runTransaction with proper reads-before-writes order and error handling.

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.

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.