How I Built a Zero-Duplicate Coupon System


Header Image

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.


The Setup

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.


The Problem

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:

  1. Firebase automatically spins up multiple function instances to handle the surge
  2. Each instance tries to find and claim an available coupon from Firestore
  3. Multiple instances read the same coupon document before any has updated it

This creates a classic race condition.


What Could Go Wrong?

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.


The Real-World Impact

Without a smart solution, you get:

I needed a better way.


The Solution: Range-Based Transaction Strategy

The core idea is simple: instead of having all instances fight over the same coupons, we distribute the search space intelligently.

How It Works

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;
  });
}


Why This Prevents Duplicates

1. Firestore Transactions Are Atomic

Each coupon assignment happens within a transaction with guarantees:


2. The Double-Read Pattern

// 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.


3. Fallback Pool Strategy

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.


4. Load Distribution via Shuffled Ranges

Without shuffling:

With shuffling:


5. Progressive Search Instead of Retries

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!


The Results

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:

  1. Perfect Success Rate: Despite concurrent load, every single request successfully claimed a unique coupon
  2. No Duplicates: Database verification confirmed zero duplicate assignments
  3. Predictable Performance: Response times stayed within acceptable range even under load
  4. Scalability: The range-based approach successfully distributed load across the coupon pool


Key Takeaways

Building this system taught me some valuable lessons about distributed systems:

  1. Partition your search space - Don’t let all users compete for the same resources
  2. Use transactions wisely - Firestore transactions are powerful but can conflict under load
  3. Implement fallbacks - Have a backup plan when your first choice is taken
  4. Move forward, not backward - Progress to new ranges instead of retrying busy ones
  5. Pre-distribute load - Random values assigned at creation enable runtime distribution


Performance Notes

Time Complexity:

Required Database Indexes:

Collection: coupons

Index 1: isAssigned ASC, randomFloat ASC
Index 2: assignedTo ASC, isAssigned ASC


Final Thoughts

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.