Jurij Tokarski

Jurij Tokarski

Using Firestore Transactions to Handle Race Conditions

When two Cloud Function instances race to create the same external resource, a Firestore transaction decides the winner.

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 as the gate:

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:

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.

Got thoughts on this post? Reply viaEmail/Twitter/X/LinkedIn

Subscribe to the newsletter:

About Jurij Tokarski

Hey 👋 I'm Jurij. I run Varstatt and create software. Usually, I'm deep in the work shipping for clients or building for myself. Sometimes, I share bits I don't want to forget: mostly about software, products and self-employment.

RSSjurij@varstatt.comx.comlinkedin.com