Firestore Transaction Testing: Concurrency, Contention, and Consistency Validation

Firestore Transaction Testing: Concurrency, Contention, and Consistency Validation

Firestore transactions are the safety net for complex multi-document operations. They guarantee that either all writes succeed or none do. But they also introduce behaviors that are easy to mistest: automatic retries, contention between concurrent transactions, and read-modify-write races.

Most Firestore testing guides cover basic CRUD with the emulator. This guide focuses on what they skip: how to test transactions, batch writes, and concurrent access correctly.

What Firestore Transactions Actually Do

A Firestore transaction reads documents, optionally modifies them, and writes back — atomically. If another client modifies one of the read documents before your transaction commits, Firestore retries the transaction (up to 5 times by default).

This retry behavior is important for testing:

const { runTransaction, doc, getDoc } = require('firebase/firestore');

async function transferCredits(db, fromUserId, toUserId, amount) {
  return runTransaction(db, async (transaction) => {
    // Read phase
    const fromRef = doc(db, 'users', fromUserId);
    const toRef = doc(db, 'users', toUserId);

    const fromDoc = await transaction.get(fromRef);
    const toDoc = await transaction.get(toRef);

    if (!fromDoc.exists()) throw new Error('Source user not found');
    if (!toDoc.exists()) throw new Error('Target user not found');

    const fromCredits = fromDoc.data().credits;
    if (fromCredits < amount) throw new Error('Insufficient credits');

    // Write phase
    transaction.update(fromRef, { credits: fromCredits - amount });
    transaction.update(toRef, { credits: toDoc.data().credits + amount });
  });
}

Testing this correctly means testing:

  1. The happy path (valid transfer)
  2. Insufficient credits (application error)
  3. Missing user (data error)
  4. Concurrent transfers (transaction contention)

Setting Up Transaction Tests

const { initializeApp } = require('firebase/app');
const {
  getFirestore,
  connectFirestoreEmulator,
  doc,
  setDoc,
  getDoc,
  deleteDoc,
  collection,
  getDocs,
} = require('firebase/firestore');

const app = initializeApp({ projectId: 'transaction-tests' });
const db = getFirestore(app);
connectFirestoreEmulator(db, 'localhost', 8080);

// Seed helper
async function seedUser(id, credits) {
  await setDoc(doc(db, 'users', id), { id, credits });
}

// Cleanup helper
async function cleanupUsers() {
  const snapshot = await getDocs(collection(db, 'users'));
  await Promise.all(snapshot.docs.map(d => deleteDoc(d.ref)));
}

describe('transferCredits transaction', () => {
  beforeEach(async () => {
    await seedUser('alice', 100);
    await seedUser('bob', 50);
  });

  afterEach(cleanupUsers);

Testing the Happy Path

  it('transfers credits correctly', async () => {
    await transferCredits(db, 'alice', 'bob', 30);

    const alice = await getDoc(doc(db, 'users', 'alice'));
    const bob = await getDoc(doc(db, 'users', 'bob'));

    expect(alice.data().credits).toBe(70);
    expect(bob.data().credits).toBe(80);
  });

  it('transfer is atomic: both balances update or neither does', async () => {
    // This test verifies atomicity by checking that intermediate states
    // are never observable — both should update together

    await transferCredits(db, 'alice', 'bob', 50);

    const [alice, bob] = await Promise.all([
      getDoc(doc(db, 'users', 'alice')),
      getDoc(doc(db, 'users', 'bob')),
    ]);

    // Total credits should be conserved
    const totalCredits = alice.data().credits + bob.data().credits;
    expect(totalCredits).toBe(150); // Alice(100) + Bob(50) = 150
  });

Testing Error Paths in Transactions

  it('throws and does not modify balances when credits are insufficient', async () => {
    await expect(
      transferCredits(db, 'alice', 'bob', 200) // Alice only has 100
    ).rejects.toThrow('Insufficient credits');

    // Balances should be unchanged
    const alice = await getDoc(doc(db, 'users', 'alice'));
    const bob = await getDoc(doc(db, 'users', 'bob'));

    expect(alice.data().credits).toBe(100);
    expect(bob.data().credits).toBe(50);
  });

  it('throws when source user does not exist', async () => {
    await expect(
      transferCredits(db, 'nonexistent', 'bob', 10)
    ).rejects.toThrow('Source user not found');

    // Bob's balance should be unchanged
    const bob = await getDoc(doc(db, 'users', 'bob'));
    expect(bob.data().credits).toBe(50);
  });

  it('does not modify source when destination does not exist', async () => {
    await expect(
      transferCredits(db, 'alice', 'nonexistent', 10)
    ).rejects.toThrow('Target user not found');

    // Alice's balance should be unchanged
    const alice = await getDoc(doc(db, 'users', 'alice'));
    expect(alice.data().credits).toBe(100);
  });
});

Testing Concurrent Transaction Contention

This is the hardest part to test and the most important. When two transactions run simultaneously and read the same documents, Firestore retries the loser. Your code must be safe for retries:

describe('concurrent transaction behavior', () => {
  it('handles two concurrent transfers without losing credits', async () => {
    await setDoc(doc(db, 'users', 'shared-account'), { credits: 100 });
    await setDoc(doc(db, 'users', 'recipient-1'), { credits: 0 });
    await setDoc(doc(db, 'users', 'recipient-2'), { credits: 0 });

    // Two concurrent transfers from the same source account
    await Promise.all([
      transferCredits(db, 'shared-account', 'recipient-1', 40),
      transferCredits(db, 'shared-account', 'recipient-2', 40),
    ]);

    const [source, r1, r2] = await Promise.all([
      getDoc(doc(db, 'users', 'shared-account')),
      getDoc(doc(db, 'users', 'recipient-1')),
      getDoc(doc(db, 'users', 'recipient-2')),
    ]);

    // Total credits must be conserved
    const total = source.data().credits + r1.data().credits + r2.data().credits;
    expect(total).toBe(100);

    // Both transfers should have succeeded
    expect(r1.data().credits).toBe(40);
    expect(r2.data().credits).toBe(40);
    expect(source.data().credits).toBe(20);
  });

  it('fails correctly when concurrent transfers exceed available credits', async () => {
    await setDoc(doc(db, 'users', 'limited-account'), { credits: 50 });
    await setDoc(doc(db, 'users', 'dest-a'), { credits: 0 });
    await setDoc(doc(db, 'users', 'dest-b'), { credits: 0 });

    // Try to transfer 40 to each, but only 50 total available
    const results = await Promise.allSettled([
      transferCredits(db, 'limited-account', 'dest-a', 40),
      transferCredits(db, 'limited-account', 'dest-b', 40),
    ]);

    // One should succeed, one should fail
    const succeeded = results.filter(r => r.status === 'fulfilled');
    const failed = results.filter(r => r.status === 'rejected');

    expect(succeeded).toHaveLength(1);
    expect(failed).toHaveLength(1);
    expect(failed[0].reason.message).toMatch(/Insufficient credits/);

    // Credits must be conserved
    const [source, destA, destB] = await Promise.all([
      getDoc(doc(db, 'users', 'limited-account')),
      getDoc(doc(db, 'users', 'dest-a')),
      getDoc(doc(db, 'users', 'dest-b')),
    ]);

    const total = source.data().credits + destA.data().credits + destB.data().credits;
    expect(total).toBe(50);
  });
});

Testing Batch Writes

Batch writes are atomic but don't retry on contention (unlike transactions). Test them for atomicity:

const { writeBatch } = require('firebase/firestore');

async function archiveUserData(db, userId) {
  const batch = writeBatch(db);

  const userRef = doc(db, 'users', userId);
  const archiveRef = doc(db, 'archived_users', userId);

  const userDoc = await getDoc(userRef);
  if (!userDoc.exists()) throw new Error('User not found');

  batch.set(archiveRef, { ...userDoc.data(), archivedAt: new Date() });
  batch.delete(userRef);

  await batch.commit();
}

describe('archiveUserData batch write', () => {
  it('moves user to archive and removes from active collection', async () => {
    await setDoc(doc(db, 'users', 'to-archive'), {
      name: 'Dave',
      email: 'dave@test.com',
    });

    await archiveUserData(db, 'to-archive');

    const activeUser = await getDoc(doc(db, 'users', 'to-archive'));
    const archivedUser = await getDoc(doc(db, 'archived_users', 'to-archive'));

    expect(activeUser.exists()).toBe(false);
    expect(archivedUser.exists()).toBe(true);
    expect(archivedUser.data().name).toBe('Dave');
    expect(archivedUser.data().archivedAt).toBeDefined();
  });
});

Testing Writes That Must Not Happen Twice (Idempotency)

Pub/Sub delivers at-least-once. Cloud Functions can execute multiple times. Your Firestore writes must be idempotent:

async function processOrderPayment(db, orderId, paymentData) {
  const orderRef = doc(db, 'orders', orderId);

  return runTransaction(db, async (transaction) => {
    const orderDoc = await transaction.get(orderRef);

    if (!orderDoc.exists()) throw new Error('Order not found');

    // Idempotency check: don't process if already paid
    if (orderDoc.data().status === 'paid') {
      return { alreadyProcessed: true };
    }

    transaction.update(orderRef, {
      status: 'paid',
      paidAt: new Date(),
      paymentId: paymentData.paymentId,
    });

    return { processed: true };
  });
}

describe('processOrderPayment idempotency', () => {
  beforeEach(async () => {
    await setDoc(doc(db, 'orders', 'ord-001'), {
      status: 'pending',
      total: 99.99,
    });
  });

  it('processes payment on first call', async () => {
    const result = await processOrderPayment(db, 'ord-001', { paymentId: 'pay-abc' });
    expect(result.processed).toBe(true);

    const order = await getDoc(doc(db, 'orders', 'ord-001'));
    expect(order.data().status).toBe('paid');
  });

  it('is idempotent: second call returns alreadyProcessed without error', async () => {
    await processOrderPayment(db, 'ord-001', { paymentId: 'pay-abc' });

    // Second call with same payment — should not throw
    const result = await processOrderPayment(db, 'ord-001', { paymentId: 'pay-abc' });
    expect(result.alreadyProcessed).toBe(true);

    // State should not change
    const order = await getDoc(doc(db, 'orders', 'ord-001'));
    expect(order.data().paymentId).toBe('pay-abc');
  });
});

Running Transaction Tests in CI

# .github/workflows/firestore-transactions.yml
name: Firestore Transaction Tests

on: [push, pull_request]

jobs:
  transaction-tests:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v3
        with:
          node-version: '18'

      - run: npm ci

      - uses: actions/setup-java@v3
        with:
          java-version: '17'
          distribution: 'temurin'

      - run: npm install -g firebase-tools

      - name: Run transaction tests with emulator
        run: |
          firebase emulators:exec \
            --only firestore \
            --project transaction-tests \
            "npm test -- --testPathPattern='transaction'"

Transaction and concurrency tests are the highest-value Firestore tests to have. They catch the bugs that only appear under real concurrent load — the same bugs that cause data loss or corruption in production.


HelpMeTest provides continuous behavioral testing for production applications, catching integration issues that unit and transaction tests miss. Start free →

Read more

Start now free