
Jurij Tokarski
Firestore Transactions: Handling Race Conditions Between Cloud Functions
A runTransaction example for the case where two Cloud Function instances race to create the same external resource and one needs to win.
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.
This is a classic race-condition shape: cheap to detect, expensive to double, and the check-then-write pattern doesn't survive concurrency.
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 Firestore 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. The same pattern works for any expensive idempotent creation: Stripe customers, OpenAI assistants, S3 buckets — anywhere the write needs to be atomic but the upstream call can't be.
If your Firebase setup has shapes like this hiding in it — concurrency, security rules, billing surprises — that's the kind of thing a Firebase audit is for.
Subscribe to the newsletter:
About Jurij Tokarski
I run Varstatt and create software. Usually, I'm deep in work shipping for clients or building for myself. Sometimes, I share bits I don't want to forget.
x.comlinkedin.commedium.comdev.tohashnode.devjurij@varstatt.comRSS