
Imagine launching a flash sale campaign. 10,000 users hit “Claim Coupon” within 30 seconds. Your backend spins up hundreds of serverless instances. Multiple instances try to grab the same coupon. Without proper safeguards, this could cause a lot of chaos. There could be duplicate assignments, race conditions and angry end users.
In one of my recent projects, I had to build a promotional coupon distribution for a mobile phone company that could handle massive concurrent traffic without ever assigning the same coupon to multiple users. What seemed like a straightforward problem turned into an interesting distributed systems challenge.
My system runs on Firebase with a serverless architecture. Being a solo engineer working on this project, this seems perfect, as I don’t have to worry much about infrastructure and scaling. In the limited time that I have for development, I’d rather focus on getting my application right.
Firebase Functions handle the backend - they automatically scale based on demand. When thousands of users hit the API simultaneously, Firebase spins up multiple instances. Each instance runs independently, which creates the core problem: multiple instances could be trying to claim the same coupon at the exact same moment.
Firestore stores the coupons - a distributed NoSQL database designed for scalability. It can handle massive read/write loads, but comes with an important constraint: eventual consistency. Without proper safeguards, multiple clients can read the same document simultaneously and all think a coupon is “available.”
The collection structure is simple:
coupons-collection/
├── coupon-1 { code: "ABC123", isAssigned: false, randomFloat: 0.234 }
├── coupon-2 { code: "DEF456", isAssigned: false, randomFloat: 0.891 }
└── coupon-3 { code: "GHI789", isAssigned: true, assignedTo: "+1234567890" }
The randomFloat? That is intentional, we will discuss it later.
Picture this scenario: 10,000 users competing for 5,000 available coupons, all clicking within the same 30-second window.
Here’s what happens behind the scenes:
This creates a classic race condition.
The Race Condition:
Time Instance A Instance B Database
T0 Read coupon-1 - { isAssigned: false }
T1 - Read coupon-1 { isAssigned: false }
T2 Update: assigned to A - { isAssigned: true, assignedTo: A }
T3 - Update: assigned to B { isAssigned: true, assignedTo: B } ❌
Result: Coupon assigned to BOTH users!
Database Contention:
When multiple instances try to update the same document, Firestore detects conflicts and aborts transactions. The failed instances retry, creating retry storms. With high concurrency, response times spike from 100ms to 10+ seconds. Users see timeout errors despite coupons being available.
The Hot-Spotting Problem:
If all instances query coupons in the same order (like “oldest first”), the first 100 coupons get hammered with thousands of attempts while the remaining 4,900 sit idle. Transaction conflict rates can hit 90%, and system throughput collapses.
Without a smart solution, you get:
I needed a better way.
The core idea is simple: instead of having all instances fight over the same coupons, we distribute the search space intelligently.
Step 1: Pre-Assigned Random Distribution
Remember the randomFloat that we saw earlier? Here’s how we’re using it. When creating coupons, each gets a random float between 0 and 1:
coupon.randomFloat = Math.random(); // 0.0 to 1.0
This ensures uniform distribution across the entire pool.
Step 2: Divide the Search Space
We split the range [0, 1) into 10 equal segments and shuffle them:
function generateShuffledRanges(): [number, number][] {
const ranges: [number, number][] = [];
// Create 10 ranges: [0.0-0.1, 0.1-0.2, ..., 0.9-1.0]
for (let i = 0; i < 10; i++) {
ranges.push([i * 0.1, (i + 1) * 0.1]);
}
// Shuffle using Fisher-Yates algorithm
for (let i = ranges.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[ranges[i], ranges[j]] = [ranges[j], ranges[i]];
}
return ranges;
}
The shuffling is key. If all concurrent requests started at range 0.0-0.1, they’d all compete for the same small pool. Shuffling distributes the load across the entire coupon space.
Step 3: Sequential Range Scanning
Instead of retrying the same range on conflict, we move to the next one:
export async function dbClaimRandomCoupon(phoneNumber: string): Promise<Coupon | null> {
// First, check if user already has a coupon
const existingSnapshot = await collection
.where("assignedTo", "==", phoneNumber)
.where("isAssigned", "==", true)
.limit(1)
.get();
if (!existingSnapshot.empty) {
return existingSnapshot.docs[0].data() as Coupon;
}
// Generate shuffled ranges
const ranges = generateShuffledRanges();
// Try each range sequentially
for (let i = 0; i < ranges.length; i++) {
const [rangeStart, rangeEnd] = ranges[i];
try {
const coupon = await tryClaimFromRange(rangeStart, rangeEnd, phoneNumber);
if (coupon) {
return coupon; // Success!
}
} catch (error) {
// If transaction conflict, move to next range
if (isContentionError(error)) {
continue; // Try next range
}
throw error; // Other errors bubble up
}
}
// All ranges exhausted
return null;
}
Step 4: Atomic Assignment with Firestore Transactions
The critical operation happens within a Firestore transaction:
async function tryClaimFromRange(
rangeStart: number,
rangeEnd: number,
phoneNumber: string
): Promise<Coupon | null> {
const collection = db.collection(COUPONS_COLLECTION);
return await db.runTransaction(async (transaction) => {
// Query for unassigned coupons within this range
const unassignedSnapshot = await transaction.get(
collection
.where("isAssigned", "==", false)
.where("randomFloat", ">=", rangeStart)
.where("randomFloat", "<", rangeEnd)
.orderBy("randomFloat")
.limit(5) // Fetch 5 for fallback options
);
if (unassignedSnapshot.empty) {
return null;
}
// Try to claim the first available coupon
const couponDoc = unassignedSnapshot.docs[0];
const couponRef = collection.doc(couponDoc.id);
// Re-read within transaction to detect conflicts
const couponSnapshot = await transaction.get(couponRef);
// Double-check it's still unassigned
if (!couponSnapshot.exists || couponSnapshot.data()?.isAssigned === true) {
// Already claimed! Try fallback coupons
for (let i = 1; i < unassignedSnapshot.docs.length; i++) {
const altCouponDoc = unassignedSnapshot.docs[i];
const altCouponRef = collection.doc(altCouponDoc.id);
const altSnapshot = await transaction.get(altCouponRef);
if (altSnapshot.exists && altSnapshot.data()?.isAssigned === false) {
// Found an available fallback
transaction.update(altCouponRef, {
isAssigned: true,
assignedTo: phoneNumber,
updatedOn: Timestamp.now(),
});
return altCouponDoc.data() as Coupon;
}
}
return null; // All fallbacks claimed
}
// Original coupon still available - claim it
transaction.update(couponRef, {
isAssigned: true,
assignedTo: phoneNumber,
updatedOn: Timestamp.now(),
});
return couponDoc.data() as Coupon;
});
}
Each coupon assignment happens within a transaction with guarantees:
isAssigned flag is reliably updated// First read (query)
const unassignedSnapshot = await transaction.get(query);
// Second read (document-level)
const couponSnapshot = await transaction.get(couponRef);
// Verify still unassigned before updating
if (couponSnapshot.data()?.isAssigned === false) {
transaction.update(couponRef, { isAssigned: true });
}
This ensures that even if two transactions read the same coupon initially, only one will successfully update it. The other will detect the change and move to a fallback coupon.
By fetching 5 coupons instead of 1, we create a buffer:
.limit(5) // Fetch 5 to have fallback options
If the first coupon gets claimed by another concurrent transaction, we have 4 more options before needing to move to the next range. This dramatically reduces contention failures.
Without shuffling:
With shuffling:
Traditional approach:
Try range 0.0-0.1 → Conflict → Retry 0.0-0.1 → Conflict → Retry 0.0-0.1 → ...
The approach:
Try range 0.3-0.4 → Conflict → Try 0.7-0.8 → Success!
I conducted load testing against the production Firebase Functions endpoint. The test simulated a flash sale scenario with the following configuration:
Test Configuration:
Results:
Key Observations:
Building this system taught me some valuable lessons about distributed systems:
Time Complexity:
Required Database Indexes:
Collection: coupons
Index 1: isAssigned ASC, randomFloat ASC
Index 2: assignedTo ASC, isAssigned ASC
The key insight here is that in distributed systems, avoiding conflict is better than resolving it. This range-based approach naturally distributes the load, minimizing conflicts while guaranteeing eventual coverage of the entire coupon pool.
This architecture can be adapted to any scenario requiring atomic assignment of limited resources under high concurrency: concert tickets, limited edition products, appointment slots, or any other constrained resource pool.
Building scalable systems is all about understanding the constraints of your infrastructure and designing around them. Sometimes the simplest ideas - like adding a random number to each document - can solve the most complex problems.