---
title: Using Firestore Transactions to Handle Race Conditions
url: https://varstatt.com/jurij/p/using-firestore-transactions-to-handle-race-conditions
author: Jurij Tokarski
date: 2026-03-10
description: When two Cloud Function instances race to create the same external resource, a Firestore transaction decides the winner.
section: Blog (https://varstatt.com/jurij/archive)
tags: firebase (https://varstatt.com/jurij/c/firebase)
---

The system creates an OpenAI vector store per company — a dedicated knowledge base the AI secretary queries when answering questions. Creating it is expensive: an API call to Azure OpenAI, followed by writing the returned ID back to the company doc in Firestore.

What I ran into: multiple cloud function instances can process triggers at the same time. If two invocations both check `workflow_2_vector_store_id`, find it empty, and both proceed to create a vector store — you've just orphaned one. It sits there, billed by the token, never used.

My first instinct was "just check before creating." That doesn't work — the check and the write are not atomic. Two instances read an empty field at the same millisecond, both proceed.

What worked is a [Firestore transaction](/production/firebase-audit) as the gate:

```typescript
export const patchCompanyWithVectorStoreIfMissing = async (
  companyId: string,
  vectorStoreId: string,
  assistantId: string,
): Promise<{ vectorStoreId: string; wePatched: boolean }> =>
  runDBTransaction(async (tx) => {
    const snapshot = await tx.get(DOC_COMPANY(companyId));
    const existing = snapshot.data()?.workflow_2_vector_store_id?.trim();

    if (existing) {
      return { vectorStoreId: existing, wePatched: false };
    }

    tx.update(DOC_COMPANY(companyId), {
      workflow_2_vector_store_id: vectorStoreId,
      workflow_2_assistant_id: assistantId,
    });

    return { vectorStoreId, wePatched: true };
  });
```

Both instances still create a vector store optimistically — that's unavoidable, the external API call can't be inside the transaction. But only one wins the write. The loser gets `wePatched: false` and immediately fires cleanup:

```typescript
const { vectorStoreId, wePatched } = await patchCompanyWithVectorStoreIfMissing(
  company.id,
  result.vectorStoreId,
  result.assistantId,
);

if (!wePatched) {
  llm.cleanupCompanyVectorStoreAndAssistant(result.vectorStoreId, result.assistantId)
    .catch(() => {});
}
```

The transaction is the single source of truth for "has this resource been claimed?" Optimistic creation outside it is fine — as long as the loser always cleans up.
