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
Run a basic transaction with runTransaction
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.
1import { getFirestore, doc, runTransaction } from 'firebase/firestore'23const db = getFirestore()45// Atomically increment a counter6async function incrementCounter(counterId: string) {7 const counterRef = doc(db, 'counters', counterId)89 const newCount = await runTransaction(db, async (transaction) => {10 // Step 1: Read the current value11 const counterDoc = await transaction.get(counterRef)12 if (!counterDoc.exists()) {13 throw new Error('Counter document does not exist')14 }1516 // Step 2: Calculate the new value17 const currentCount = counterDoc.data().count18 const updatedCount = currentCount + 11920 // Step 3: Write the new value21 transaction.update(counterRef, { count: updatedCount })2223 return updatedCount24 })2526 console.log('Counter updated to:', newCount)27 return newCount28}Expected result: The counter increments atomically, even if multiple clients try to increment simultaneously.
Transfer a value between two documents
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.
1async function transferCredits(2 fromUserId: string,3 toUserId: string,4 amount: number5) {6 const fromRef = doc(db, 'users', fromUserId)7 const toRef = doc(db, 'users', toUserId)89 await runTransaction(db, async (transaction) => {10 // Read both documents first11 const fromDoc = await transaction.get(fromRef)12 const toDoc = await transaction.get(toRef)1314 if (!fromDoc.exists() || !toDoc.exists()) {15 throw new Error('One or both users do not exist')16 }1718 const fromBalance = fromDoc.data().credits19 if (fromBalance < amount) {20 throw new Error(`Insufficient credits: has ${fromBalance}, needs ${amount}`)21 }2223 // Write both updates24 transaction.update(fromRef, { credits: fromBalance - amount })25 transaction.update(toRef, { credits: toDoc.data().credits + amount })26 })2728 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.
Handle transaction errors and retries
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.
1async function safeTransfer(2 fromId: string,3 toId: string,4 amount: number5) {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 retry12 return { success: false, reason: 'insufficient_balance' }13 }14 if (error.code === 'failed-precondition') {15 // Transaction failed after max retries due to contention16 return { success: false, reason: 'too_much_contention' }17 }18 if (error.code === 'unavailable') {19 // Client is offline — transactions require connectivity20 return { success: false, reason: 'offline' }21 }22 throw error // Unknown error23 }24}Expected result: Transaction errors are caught and classified so the UI can show appropriate messages.
Understand when to use transactions vs batch writes
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.
1import { writeBatch, doc, serverTimestamp } from 'firebase/firestore'23// Batch write: atomic writes WITHOUT reads4async function publishPost(postId: string, tags: string[]) {5 const batch = writeBatch(db)67 // Update the post status8 batch.update(doc(db, 'posts', postId), {9 status: 'published',10 publishedAt: serverTimestamp(),11 })1213 // Create tag documents14 for (const tag of tags) {15 batch.set(doc(db, 'tags', tag), {16 name: tag,17 lastUsed: serverTimestamp(),18 }, { merge: true })19 }2021 // All writes succeed or all fail22 await batch.commit()23}2425// Transaction: atomic read-then-write26// Use when the write depends on current values27// Example: incrementing a counter, transferring balance2829// Batch: atomic writes only30// Use when you already know what to write31// Example: publishing a post + updating tagsExpected result: You can choose the right tool: transactions for read-dependent writes, batch writes for known-value atomic writes.
Use transactions on the server with Admin SDK
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.
1// Cloud Function using Admin SDK transaction2import { getFirestore } from 'firebase-admin/firestore'34const db = getFirestore()56export async function processOrder(orderId: string) {7 const orderRef = db.doc(`orders/${orderId}`)8 const inventoryRef = db.doc(`products/${orderId}`)910 await db.runTransaction(async (transaction) => {11 const orderDoc = await transaction.get(orderRef)12 const inventoryDoc = await transaction.get(inventoryRef)1314 if (!orderDoc.exists || !inventoryDoc.exists) {15 throw new Error('Order or product not found')16 }1718 const quantity = orderDoc.data()!.quantity19 const stock = inventoryDoc.data()!.stock2021 if (stock < quantity) {22 throw new Error('Insufficient stock')23 }2425 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
1import { initializeApp } from 'firebase/app'2import {3 getFirestore,4 doc,5 runTransaction,6 writeBatch,7 serverTimestamp,8} from 'firebase/firestore'910const 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})1516const db = getFirestore(app)1718export 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 : 023 tx.set(ref, { count: current + 1 }, { merge: true })24 return current + 125 })26}2728export async function transferCredits(29 fromId: string,30 toId: string,31 amount: number32): Promise<void> {33 const fromRef = doc(db, 'users', fromId)34 const toRef = doc(db, 'users', toId)3536 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')4041 const balance = fromSnap.data().credits42 if (balance < amount) throw new Error('Insufficient credits')4344 tx.update(fromRef, { credits: balance - amount })45 tx.update(toRef, { credits: toSnap.data().credits + amount })46 })47}4849export 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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation